Refresh crafter map branch while preserving shared item-use fixtures

Retain map metadata and max-scale controls alongside current fixture groups and explicit boat-use packet actions.

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-05 03:37:57 +01:00
commit 123d271505
441 files changed
+30242 -1980

No files matched your search

@@ -143,7 +143,9 @@ impl<'a> Fields<'a> {
/// `RandomizableContainer.tryLoadLootTable` / `trySaveLootTable`; true when the
/// block entity has a loot table (and so saves no items).
fn loot_table(&mut self) -> bool {
let Some(table) = text(self.input.get("LootTable")) else {
let Some(table) =
text(self.input.get("LootTable")).and_then(|id| crate::identifier::normalize_id(&id))
else {
return false;
};
self.put("LootTable", string(&table));
@@ -905,6 +907,16 @@ mod tests {
assert_eq!(out.get("LootTable"), Some(&string("minecraft:chests/x")));
}
#[test]
fn invalid_loot_table_leaves_items_intact() {
let out = canon(
"{id:\"minecraft:barrel\",x:1,y:2,z:3,LootTable:\"INVALID TABLE\",\
Items:[{Slot:0b,id:\"minecraft:stone\",count:1}]}",
);
assert!(out.get("Items").is_some());
assert!(out.get("LootTable").is_none());
}
#[test]
fn items_are_validated_and_sorted_by_slot() {
let out = canon(
+5 -3
View File
@@ -207,9 +207,11 @@ fn style_flag(v: Option<&Value>) -> Option<bool> {
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
/// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
/// https only, scheme case-insensitive. Every `open_url` a server sends must
/// pass it: anything else (`file:`, a UNC path, a custom protocol handler)
/// could start a program through the system's URL handler.
pub fn untrusted_http_url(url: &str) -> bool {
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
+192 -3
View File
@@ -388,6 +388,36 @@ pub fn apply_deprecated(map: &mut HashMap<String, String>, json: &[u8]) {
}
}
/// `Language.loadFromJson` for one `lang/<code>.json`: its entries go into `map` in file order.
/// A file that is plain JSON reads as before; any other goes through Gson's lenient reader
/// (`#`, `//` and `/* */` comments, unquoted or single-quoted names and values, raw control
/// characters in strings, `=` or `=>` and `;`), which vanilla reads lang files with (expected
/// from `Language.loadFromJson`, Gson). A file that is not JSON even so adds nothing; a value
/// that is not a string, number or boolean (`GsonHelper.convertToString`) ends the file there.
fn read_lang_file(bytes: &[u8], map: &mut HashMap<String, String>) {
use horizon_world::datafix::json::{Json, parse_lenient};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(bytes) {
map.extend(entries);
return;
}
let text = String::from_utf8_lossy(bytes);
// Gson's JsonReader skips a leading byte order mark.
let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
let utf16: Vec<u16> = text.encode_utf16().collect();
let Some(Json::Obj(entries)) = parse_lenient(&utf16) else {
return;
};
for (key, value) in entries {
let value = match value {
Json::Str(s) => String::from_utf16_lossy(&s),
Json::Num(n) => n,
Json::Bool(b) => b.to_string(),
Json::Null | Json::Arr(_) | Json::Obj(_) => break,
};
map.insert(String::from_utf16_lossy(&key), value);
}
}
impl Language {
/// ClientLanguage.loadFrom: en_us first, then the selected language, each
/// from every namespace and every source in priority order (later sources
@@ -413,9 +443,7 @@ impl Language {
else {
continue;
};
if let Ok(entries) = serde_json::from_slice::<HashMap<String, String>>(&bytes) {
map.extend(entries);
}
read_lang_file(&bytes, &mut map);
}
}
}
@@ -875,4 +903,165 @@ mod tests {
["en_us", "fr_fr"]
);
}
fn read(body: &str) -> HashMap<String, String> {
let mut map = HashMap::new();
read_lang_file(body.as_bytes(), &mut map);
map
}
fn entries(pairs: &[(&str, &str)]) -> HashMap<String, String> {
pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect()
}
#[test]
fn a_plain_json_lang_file_reads_as_before() {
let body = "{\"a\": \"Apple \\\"quoted\\\" \\u00e9\", \"b\": \"\", \"a\": \"Again\"}";
let strict: HashMap<String, String> = serde_json::from_str(body).unwrap();
assert_eq!(read(body), strict);
assert_eq!(read(body), entries(&[("a", "Again"), ("b", "")]));
}
#[test]
fn lang_files_read_with_comments() {
let want = entries(&[("a", "Apple"), ("b", "Banana # not a comment // either")]);
for body in [
"# header\n{\n# a comment\n\"a\": \"Apple\",\n\"b\": \"Banana # not a comment // either\"\n}\n",
"// header\n{\"a\": \"Apple\", // after\n\"b\": \"Banana # not a comment // either\"}",
"/* header\n over lines */{\"a\": /* inline */ \"Apple\", \"b\": \"Banana # not a comment // either\"}",
] {
assert_eq!(read(body), want, "{body}");
}
}
#[test]
fn lang_files_read_unquoted_and_single_quoted_names_and_values() {
assert_eq!(
read("{a: 'Apple', 'b': \"Banana\", c: Cherry}"),
entries(&[("a", "Apple"), ("b", "Banana"), ("c", "Cherry")])
);
// A single-quoted string may hold a double quote, and the reverse.
assert_eq!(
read("{'a': 'say \"hi\"', \"b\": \"it's\"}"),
entries(&[("a", "say \"hi\""), ("b", "it's")])
);
}
#[test]
fn lang_files_read_raw_control_characters_in_strings() {
assert_eq!(
read("{\"a\": \"tab\there\", \"b\": \"two\nlines\"}"),
entries(&[("a", "tab\there"), ("b", "two\nlines")])
);
}
#[test]
fn lang_files_read_gson_separators_and_a_byte_order_mark() {
assert_eq!(
read("\u{feff}{\"a\" = \"Apple\"; \"b\" => \"Banana\"}"),
entries(&[("a", "Apple"), ("b", "Banana")])
);
// A trailing comma before `}` is not JSON even to Gson's lenient reader.
assert_eq!(read("{# c\n\"a\": \"Apple\",}"), HashMap::new());
}
#[test]
fn numbers_and_booleans_read_as_their_text() {
// GsonHelper.convertToString: a primitive reads as its text.
assert_eq!(
read("{\"a\": 1.50, \"b\": true, \"c\": -2e3}"),
entries(&[("a", "1.50"), ("b", "true"), ("c", "-2e3")])
);
}
#[test]
fn a_value_that_is_no_text_ends_the_file_there() {
assert_eq!(
read("# c\n{\"a\": \"Apple\", \"b\": [\"x\"], \"c\": \"Cherry\"}"),
entries(&[("a", "Apple")])
);
assert_eq!(read("{\"a\": null, \"b\": \"Banana\"}"), HashMap::new());
}
#[test]
fn a_lang_file_that_is_not_json_adds_nothing() {
for body in [
"# c\n{\"a\": \"Apple\"",
"[\"a\"]",
"\"a\"",
"",
"{\"a\": \"Apple\"} {",
] {
assert_eq!(read(body), HashMap::new(), "{body:?}");
}
}
#[test]
fn a_pack_lang_file_with_comments_layers_over_english() {
let base = Mem(HashMap::from([(
"assets/minecraft/lang/en_us.json".into(),
br#"{"a":"Apple","b":"Banana"}"#.to_vec(),
)]));
let pack = Mem(HashMap::from([(
"assets/minecraft/lang/fr_fr.json".into(),
b"{\n# Fruits\n\"a\": \"Pomme\"\n}\n".to_vec(),
)]));
let sources: [&dyn ResourceSource; 2] = [&base, &pack];
let fr = Language::load(&sources, "fr_fr");
assert_eq!(fr.get("a"), Some("Pomme"));
assert_eq!(fr.get("b"), Some("Banana"));
}
/// Every lang file of a downloaded pack, e.g. BACAP Language Pack:
/// `HORIZON_LANG_PACK_DIR=<pack>/assets/minecraft/lang cargo test -p horizon-client-core
/// lang::tests::every_lang_file_of_a_pack_reads -- --ignored --nocapture`.
#[test]
#[ignore = "needs a downloaded pack in HORIZON_LANG_PACK_DIR"]
fn every_lang_file_of_a_pack_reads() {
let Ok(dir) = std::env::var("HORIZON_LANG_PACK_DIR") else {
return;
};
let mut failed = Vec::new();
let mut paths: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
paths.sort();
for path in paths {
let bytes = std::fs::read(&path).unwrap();
let strict = serde_json::from_slice::<HashMap<String, String>>(&bytes).is_ok();
let mut map = HashMap::new();
read_lang_file(&bytes, &mut map);
// An independent count: the distinct `"name":` that start a line.
let text = String::from_utf8_lossy(&bytes);
let names: std::collections::HashSet<&str> = text
.lines()
.filter_map(|line| {
let rest = line.trim_start().strip_prefix('"')?;
let mut escaped = false;
let end = rest.char_indices().find_map(|(i, c)| {
let close = c == '"' && !escaped;
escaped = c == '\\' && !escaped;
close.then_some(i)
})?;
rest[end + 1..]
.trim_start()
.starts_with(':')
.then(|| &rest[..end])
})
.collect();
println!(
"{}: strict {strict}, {} keys, {} names",
path.display(),
map.len(),
names.len()
);
if map.is_empty() || map.len() != names.len() {
failed.push(path);
}
}
assert!(failed.is_empty(), "{failed:?}");
}
}
@@ -194,6 +194,20 @@ impl NbtPath {
pub fn is_empty(&self) -> bool {
self.nodes.is_empty()
}
/// Whether this selects a compound-field chain, including filtered or quoted fields.
/// Matching a root or child does not change that subtree's writer provenance.
pub fn is_field_path(&self, fields: &[&str]) -> bool {
let mut fields = fields.iter();
for node in &self.nodes {
match node {
Node::MatchRoot(_) => {}
Node::Child(name) | Node::MatchChild(name, _)
if fields.next().copied() == Some(name.as_str()) => {}
_ => return false,
}
}
fields.next().is_none()
}
fn not_found(&self, node: usize) -> PathError {
let end = self.ends[node];
PathError::new(
@@ -826,6 +840,25 @@ fn parse_compound(text: &str, i: usize) -> Result<(Tag, usize), PathError> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn field_provenance_handles_quotes_and_filters_without_matching_lists() {
for text in [
"blockState.properties",
"\"blockState\".\"properties\"",
"{source:1b}.blockState.properties",
"blockState{id:\"minecraft:piston_head\"}.properties",
] {
let path = NbtPath::parse(text).unwrap();
assert!(path.is_field_path(&["blockState", "properties"]));
assert!(!path.is_field_path(&["blockState"]));
}
assert!(NbtPath::parse("{source:1b}").unwrap().is_field_path(&[]));
assert!(
!NbtPath::parse("blockState[].properties")
.unwrap()
.is_field_path(&["blockState", "properties"])
);
}
fn tag(s: &str) -> Tag {
parse_snbt(s).unwrap().0
+115 -13
View File
@@ -8,11 +8,20 @@ pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to
// ---- the coloured form -----------------------------------------------------
/// Writer provenance for a live moving-piston command read, or its selected subtree.
#[derive(Clone, Copy)]
pub enum PistonPart {
Entity,
State,
Properties,
}
struct Pretty {
indentation: String,
result: Text,
indent_depth: usize,
depth: usize,
piston: Option<PistonPart>,
}
fn is_numeric(t: &Tag) -> bool {
matches!(
@@ -58,6 +67,10 @@ impl Pretty {
self.lit("]", None);
}
fn sub(&mut self, tag: &Tag, indent: bool) {
self.sub_with(tag, indent, None);
}
fn sub_with(&mut self, tag: &Tag, indent: bool, piston: Option<PistonPart>) {
let previous = std::mem::replace(&mut self.piston, piston);
if indent {
self.indent_depth += 1;
}
@@ -67,6 +80,7 @@ impl Pretty {
self.indent_depth -= 1;
}
self.depth -= 1;
self.piston = previous;
}
fn visit(&mut self, tag: &Tag) {
match tag {
@@ -157,7 +171,11 @@ impl Pretty {
"\n"
};
let n = c.len();
for (i, (k, v)) in java_hash_order(c).into_iter().enumerate() {
let entries = match self.piston {
Some(part) => piston_hash_order(c, part),
None => java_hash_order(c),
};
for (i, (k, v)) in entries.into_iter().enumerate() {
self.lit(&entry, None);
let key = string_of(k);
if !key.is_empty()
@@ -171,7 +189,12 @@ impl Pretty {
}
self.lit(":", None);
self.lit(" ", None);
self.sub(v, true);
let child = match (self.piston, key.as_str()) {
(Some(PistonPart::Entity), "blockState") => Some(PistonPart::State),
(Some(PistonPart::State), "properties") => Some(PistonPart::Properties),
_ => None,
};
self.sub_with(v, true, child);
if i + 1 < n {
self.lit(",", None);
self.lit(spacing, None);
@@ -186,22 +209,61 @@ impl Pretty {
}
}
}
fn hash_bucket(len: usize, key: &horizon_world::nbt::JavaString) -> usize {
let mut capacity = 16usize;
while len > capacity * 3 / 4 {
capacity *= 2;
}
let h = string_of(key)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
}
fn piston_hash_order(
c: &horizon_world::nbt::Compound,
part: PistonPart,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut entries: Vec<_> = c.iter().collect();
match part {
PistonPart::Entity => {
// saveAdditional, then the base's components and saveMetadata.
let fields = [
"blockState",
"facing",
"progress",
"extending",
"source",
"components",
"id",
"x",
"y",
"z",
];
entries.sort_by_key(|(k, _)| {
(
hash_bucket(c.len(), k),
fields
.iter()
.position(|s| *s == string_of(k))
.unwrap_or(fields.len()),
)
});
}
PistonPart::Properties => {
// StateDefinition builds property codecs in sorted-name order.
// DFU's nested map-pair encoder writes the second codec first.
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), std::cmp::Reverse(*k)));
}
PistonPart::State => return java_hash_order(c),
}
entries
}
/// The iteration order of the `HashMap<String, Tag>` a `CompoundTag` holds,
/// for a map that grew to its size by insertion: buckets by
/// `HashMap.hash(String.hashCode())`, keys sharing a bucket in key order.
pub fn java_hash_order(
c: &horizon_world::nbt::Compound,
) -> Vec<(&horizon_world::nbt::JavaString, &Tag)> {
let mut capacity = 16usize;
while c.len() > capacity * 3 / 4 {
capacity *= 2;
}
let bucket = |k: &horizon_world::nbt::JavaString| {
let h = string_of(k)
.encode_utf16()
.fold(0u32, |h, ch| h.wrapping_mul(31).wrapping_add(u32::from(ch)));
((h ^ (h >> 16)) as usize) & (capacity - 1)
};
// Keys sharing a bucket iterate in insertion order: the order vanilla's
// savers write them, as far as it is known here.
const WRITE_ORDER: &[&str] = &[
@@ -268,7 +330,7 @@ pub fn java_hash_order(
.unwrap_or(WRITE_ORDER.len())
};
let mut entries: Vec<_> = c.iter().collect();
entries.sort_by_key(|(k, _)| (bucket(k), rank(k)));
entries.sort_by_key(|(k, _)| (hash_bucket(c.len(), k), rank(k)));
entries
}
/// `NbtUtils.toPrettyComponent`: the coloured, unindented form.
@@ -281,6 +343,19 @@ pub fn pretty_indented(tag: &Tag, indentation: &str) -> Text {
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: None,
};
p.visit(tag);
p.result
}
/// Command-only writer order; ordinary pretty printing keeps its existing policy.
pub fn pretty_piston(tag: &Tag, part: PistonPart) -> Text {
let mut p = Pretty {
indentation: String::new(),
result: Text::empty(),
indent_depth: 0,
depth: 0,
piston: Some(part),
};
p.visit(tag);
p.result
@@ -295,6 +370,33 @@ mod tests {
to_snbt(&parse_snbt(s).unwrap().0)
}
#[test]
fn live_piston_writer_order_does_not_change_default_pretty() {
let tag = parse_snbt(
r#"{id:"minecraft:piston",blockState:{id:"minecraft:piston_head",properties:{facing:"east",short:"false",type:"normal"}},components:{},x:1,y:100,z:0,facing:5b,progress:0.0f,source:1b,extending:1b}"#,
).unwrap().0;
let ordinary = r#"{blockState: {id: "minecraft:piston_head", properties: {facing: "east", short: "false", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, id: "minecraft:piston", source: 1b, extending: 1b}"#;
assert_eq!(pretty(&tag).plain(), ordinary);
assert_eq!(
pretty_piston(&tag, PistonPart::Entity).plain(),
r#"{blockState: {id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}, components: {}, x: 1, facing: 5b, progress: 0.0f, y: 100, z: 0, source: 1b, id: "minecraft:piston", extending: 1b}"#,
);
let state = tag.get("blockState").unwrap();
assert_eq!(
pretty_piston(state, PistonPart::State).plain(),
r#"{id: "minecraft:piston_head", properties: {short: "false", facing: "east", type: "normal"}}"#,
);
let props = state.get("properties").unwrap();
assert_eq!(
pretty_piston(props, PistonPart::Properties).plain(),
r#"{short: "false", facing: "east", type: "normal"}"#,
);
assert_eq!(
pretty(props).plain(),
r#"{facing: "east", short: "false", type: "normal"}"#
);
assert_eq!(pretty(&tag).plain(), ordinary);
}
#[test]
fn compact_form_matches_vanilla() {
assert_eq!(
rt("{b:1b,a:\"x\",c:[1,2],d:1.5f,e:2d,f:3L,g:4s}"),
+52 -1
View File
@@ -119,6 +119,19 @@ impl Button {
][face],
}
}
/// The face button that plays this one's part on a Nintendo pad going by its letters
/// (`PadSettings::nintendo_letters`): the A it prints on the right is South, which confirms
/// and jumps as A does on a Switch; B (bottom) is East, X (top) West, Y (left) North. It is
/// its own inverse, so it turns a part into the button playing it and back.
pub fn by_letter(self) -> Button {
match self {
Button::South => Button::East,
Button::East => Button::South,
Button::West => Button::North,
Button::North => Button::West,
other => other,
}
}
}
/// A pad's inputs at one moment. Sticks are `[x, y]` with right and up
@@ -142,6 +155,14 @@ impl PadState {
pub fn held(&self, button: Button) -> bool {
self.buttons & button.bit() != 0
}
/// A Nintendo pad's buttons read by their letters (`Button::by_letter`).
pub fn by_letter(&self) -> PadState {
let mut state = *self;
for part in [Button::South, Button::East, Button::West, Button::North] {
state.set(part, self.held(part.by_letter()));
}
state
}
/// Two sources at once (a real pad and `horizon-ctl pad`): buttons held on
/// either, sticks and triggers added.
pub fn merge(&self, other: &PadState) -> PadState {
@@ -454,6 +475,10 @@ pub struct PadSettings {
/// holding it, unless vanilla's toggle crouch is on already.
pub toggle_sneak: bool,
pub glyphs: GlyphStyle,
/// A Nintendo pad's face buttons go by their letters (A confirms and jumps, as on the
/// Switch's own menus and keyboard) instead of their places (B, at the bottom, does): on
/// by default on the Switch only.
pub nintendo_letters: bool,
pub keyboard: TextKeyboard,
/// Menu pointer multiplier, scroll lines/second, and repeat times in seconds.
pub pointer_speed: f32,
@@ -478,6 +503,7 @@ impl Default for PadSettings {
trigger_threshold: 0.4,
toggle_sneak: true,
glyphs: GlyphStyle::Auto,
nintendo_letters: cfg!(target_os = "horizon"),
keyboard: TextKeyboard::Auto,
pointer_speed: 1.0,
scroll_speed: 12.0,
@@ -545,6 +571,7 @@ impl PadSettings {
}
"toggleSneak" => s.toggle_sneak = flag(s.toggle_sneak),
"glyphs" => s.glyphs = GlyphStyle::parse(value),
"nintendoLetters" => s.nintendo_letters = flag(s.nintendo_letters),
"keyboard" => s.keyboard = TextKeyboard::parse(value),
"pointerSpeed" => s.pointer_speed = bounded(s.pointer_speed, 0.25, 3.0),
"scrollSpeed" => s.scroll_speed = bounded(s.scroll_speed, 1.0, 36.0),
@@ -568,7 +595,7 @@ impl PadSettings {
}
pub fn serialize(&self) -> String {
let mut text = format!(
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nkeyboard:{}\n",
"enabled:{}\nvibration:{}\nadaptiveTriggers:{}\nlightbar:{}\nlookSensitivity:{}\ninvertX:{}\ninvertY:{}\nmoveDeadzone:{}\nlookDeadzone:{}\ntriggerThreshold:{}\ntoggleSneak:{}\nglyphs:{}\nnintendoLetters:{}\nkeyboard:{}\n",
bool_text(self.enabled),
bool_text(self.vibration),
bool_text(self.adaptive_triggers),
@@ -581,6 +608,7 @@ impl PadSettings {
self.trigger_threshold,
bool_text(self.toggle_sneak),
self.glyphs.name(),
bool_text(self.nintendo_letters),
self.keyboard.name(),
);
text.push_str(&format!(
@@ -1391,7 +1419,9 @@ mod tests {
s.invert_y = true;
s.look_sensitivity = 0.75;
s.glyphs = GlyphStyle::PlayStation;
s.nintendo_letters = !s.nintendo_letters;
let text = s.serialize();
assert!(text.contains(&format!("nintendoLetters:{}\n", s.nintendo_letters)));
assert!(text.contains("bind_key.jump:east\n"));
assert!(text.contains("bind_key.drop:none\n"));
assert!(!text.contains("key.sneak"));
@@ -1437,6 +1467,27 @@ mod tests {
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn a_nintendo_pad_by_its_letters_confirms_with_a() {
// The pad's A sits on the right (East): going by letters it plays South, confirm and jump.
let mut pad = PadState::default();
pad.set(Button::East, true);
pad.set(Button::LeftBumper, true);
pad.left = [0.5, -0.25];
let parts = pad.by_letter();
assert!(parts.held(Button::South));
assert!(!parts.held(Button::East));
// Other buttons and the sticks stay; reading by letters again gives the pad back.
assert!(parts.held(Button::LeftBumper));
assert_eq!(parts.left, [0.5, -0.25]);
assert_eq!(parts.by_letter(), pad);
// A part's glyph is the letter of the button playing it.
assert_eq!(Button::South.by_letter().label(GlyphStyle::Nintendo), "A");
assert_eq!(Button::East.by_letter().label(GlyphStyle::Nintendo), "B");
assert_eq!(Button::West.by_letter().label(GlyphStyle::Nintendo), "X");
assert_eq!(Button::North.by_letter().label(GlyphStyle::Nintendo), "Y");
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
@@ -355,6 +355,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -377,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -352,6 +352,7 @@ impl RenderAssets {
}
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
}
let fusion = if draws {
let _step = crate::spans::startup::step("core.fusion");
+170 -3
View File
@@ -208,6 +208,18 @@ fn attach(mixer: &Mutex<Mixer>, channel: ChannelId, samples: Samples, looping: b
type Stream = cpal::Stream;
#[cfg(not(feature = "audio"))]
type Stream = std::convert::Infallible;
/// A level sound retained for the network broadcaster, in playback order.
#[derive(Clone, Debug)]
pub struct CapturedSound {
pub event: String,
pub position: [f64; 3],
pub volume: f32,
pub pitch: f32,
pub source: usize,
pub entity: Option<u32>,
pub except_entity: Option<u32>,
}
pub struct Audio {
_stream: Option<Stream>,
device_name: String,
@@ -224,7 +236,7 @@ pub struct Audio {
source_override: Option<usize>,
/// Positional sounds played since the last drain, kept while the world
/// is open to the network so guests hear them too.
pub capture: Option<Vec<(String, [f64; 3], f32, f32)>>,
pub capture: Option<Vec<CapturedSound>>,
/// How many times each positional sound played (the control socket's `status`),
/// with or without an audio device.
pub heard: std::collections::BTreeMap<String, u32>,
@@ -1297,7 +1309,17 @@ impl Audio {
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
@@ -1336,6 +1358,66 @@ impl Audio {
preview.preview(self, source, volume);
self.preview = preview;
}
/// An incoming entity-sound packet: retain its seed and category without rebroadcasting it.
pub fn play_entity_seeded(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
seed: i64,
entity: crate::sound_engine::SoundEntity,
) {
*self.heard.entry(event.to_owned()).or_default() += 1;
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::entity_bound(
&event,
source,
volume,
pitch,
LegacyRandom::new(seed),
entity,
);
self.play_instance(instance, false);
}
/// A level's entity overload: preserve the emitter and excluded player for
/// the server broadcaster while the local sound follows the same object.
pub fn play_bound(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
entity: crate::sound_engine::SoundEntity,
except_entity: Option<u32>,
) {
if let Some(capture) = self.capture.as_mut() {
capture.push(CapturedSound {
event: event.to_owned(),
position: entity.state().position,
volume,
pitch,
source,
entity: Some(entity.id),
except_entity,
});
}
let seed = self.random.next_long();
self.play_entity_seeded(event, source, volume, pitch, seed, entity);
}
/// Removal belongs to the old object even if its numeric id is immediately reused.
pub fn remove_sound_entity(&mut self, id: u32) {
for entity in self
.engine
.bound_entities()
.into_iter()
.filter(|e| e.id == id)
{
let mut state = entity.state();
state.removed = true;
entity.update(state);
}
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
@@ -1346,7 +1428,17 @@ impl Audio {
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
capture.push(CapturedSound {
event: event.to_owned(),
position: at,
volume,
pitch,
source: self.source_override.unwrap_or_else(|| {
crate::settings::sound_source(&crate::sound_events::normalize(event))
}),
entity: None,
except_entity: None,
});
}
if at.is_some() {
*self.heard.entry(event.to_owned()).or_default() += 1;
@@ -1486,6 +1578,81 @@ impl Sounds for Audio {
mod tests {
use super::*;
#[test]
fn level_capture_keeps_order_category_entity_and_actor_exclusion() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
audio.play_on(
7,
"minecraft:block.amethyst_block.chime",
Some([1.0; 3]),
0.5,
1.0,
);
let entity = crate::sound_engine::SoundEntity::new(37, [2.123456789, 64.0, -3.0], false);
audio.play_bound(
"minecraft:block.amethyst_block.chime",
6,
2.0,
1.2,
entity,
Some(17),
);
let sounds = audio.capture.as_ref().unwrap();
assert_eq!(sounds.len(), 2);
assert_eq!(
(sounds[0].source, sounds[0].entity, sounds[0].except_entity),
(7, None, None)
);
let bound = &sounds[1];
assert_eq!(
(bound.source, bound.entity, bound.except_entity),
(6, Some(37), Some(17))
);
assert_eq!(bound.position, [2.123456789, 64.0, -3.0]);
assert_eq!((bound.volume, bound.pitch), (2.0, 1.2));
expected.next_long();
expected.next_long();
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn entity_sound_packets_neither_rebroadcast_nor_advance_the_local_seed_generator() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
audio.capture = Some(Vec::new());
let mut expected = audio.random.clone();
let entity = crate::sound_engine::SoundEntity::new(7, [1.0, 2.0, 3.0], false);
audio.play_entity_seeded("minecraft:fixture.bound", 6, 0.7, 1.2, 42, entity);
assert!(audio.capture.as_ref().unwrap().is_empty());
assert_eq!(audio.random.next_long(), expected.next_long());
}
#[test]
fn removal_marks_only_existing_references_to_that_entity() {
use crate::sound_engine::SoundEntity;
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-entity-sound-test"));
let old = SoundEntity::new(7, [0.0; 3], false);
let other = SoundEntity::new(8, [0.0; 3], false);
for entity in [old.clone(), other.clone()] {
audio.engine.add(
SoundInstance::entity_bound(
"minecraft:fixture.bound",
6,
1.0,
1.0,
LegacyRandom::new(42),
entity,
),
true,
);
}
audio.remove_sound_entity(7);
let replacement = SoundEntity::new(7, [10.0; 3], false);
assert!(old.state().removed);
assert!(!other.state().removed);
assert!(!replacement.state().removed);
}
#[test]
fn button_click_uses_vanilla_ui_parameters_without_advancing_world_random() {
let mut audio = Audio::silent(Path::new("/nonexistent/horizon-test-assets"));
+4
View File
@@ -49,6 +49,10 @@ impl AuthSession {
access_token: access_token.to_owned(),
}
}
pub fn access_token(&self) -> &str {
&self.access_token
}
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
@@ -696,11 +696,19 @@ const TEXT_OVER_OUTLINE: f32 = 0.05;
/// reader instead (a twentieth of a font pixel, invisible) rather than
/// z-fighting with the outline under it.
fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
text_vertices(scene.glyph, call, out);
}
/// `draw_text` with the font's glyph cells and advances from `glyph`: the
/// text of `call` as glyph quads in its matrix's plane, 1 unit a font pixel,
/// y down as the font's, with its outline when it has one. A native mod's
/// block entity draws its text this way (`native_draw_text`).
pub fn text_vertices(glyph: &dyn Fn(char) -> Glyph, call: &TextCall, out: &mut Vec<Vertex>) {
let mut emit = |dx: f32, dy: f32, z: f32, argb: u32| {
let mut x = call.x + dx;
let y0 = call.y + dy;
for ch in call.text.chars() {
let g = (scene.glyph)(ch);
let g = glyph(ch);
if let Some(cell) = g.rect {
// BakedSheetGlyph: the glyph's own box from the pen, not a
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
+202 -21
View File
@@ -174,6 +174,12 @@ impl ChatOptions {
#[derive(Clone, Debug)]
struct Message {
added: u64,
/// Local hour and minute it came in (PvP & HUD's chat timestamps).
time: (u8, u8),
/// How many times in a row it came (PvP & HUD's compact chat), 1 for once.
repeats: u32,
/// The lines it wrapped to the last time it was laid out (0 while hidden).
wrapped: usize,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
@@ -186,6 +192,32 @@ struct Line {
added: u64,
content: Text,
tag: Option<std::sync::Arc<Tag>>,
/// The whole message's text, for PvP & HUD's copy on click.
message: std::sync::Arc<str>,
}
/// PvP & HUD's chat options (`pvp::ChatQol`): each changes the lines, so a change rewraps them.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChatQol {
/// Each message starts with the local time it came in.
pub timestamps: bool,
/// A message equal to the one before it raises that one's count ("(x3)") instead of adding lines.
pub compact: bool,
}
/// Whether `next` repeats `last`: the same text, kind and tag, neither one signed (a server may
/// delete a signed message by itself).
fn repeats(last: &Message, next: &Message) -> bool {
last.signature.is_none()
&& next.signature.is_none()
&& last.source == next.source
&& last.tag == next.tag
&& last.content == next.content
}
/// The local hour and minute.
fn local_time() -> (u8, u8) {
use chrono::Timelike;
let now = chrono::Local::now();
(now.hour() as u8, now.minute() as u8)
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
@@ -270,6 +302,14 @@ pub struct Chat {
/// `ChatComponent.messageDeletionQueue`: deletions of messages still
/// too new to replace, with the tick from which they may be.
deletions: Vec<(Box<Signature>, u64)>,
qol: ChatQol,
/// PvP & HUD's chat search is on: the screen shows its prompt.
pub search_prompt: bool,
/// PvP & HUD's chat search: only messages holding this (lower case) show.
search: Option<String>,
/// The tests' clock in place of the local time.
#[cfg(test)]
clock: Option<(u8, u8)>,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -331,6 +371,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
@@ -352,6 +395,9 @@ impl Chat {
font,
Message {
added: ticks,
time: (0, 0),
repeats: 1,
wrapped: 0,
content,
tag,
source,
@@ -360,7 +406,7 @@ impl Chat {
focused,
);
}
fn push_message(&mut self, font: &crate::font::Font, message: Message, focused: bool) {
fn push_message(&mut self, font: &crate::font::Font, mut message: Message, focused: bool) {
if !self.abilities.shows(message.source) {
return;
}
@@ -370,12 +416,52 @@ impl Chat {
if self.log {
println!("[CHAT] {plain}");
}
self.add_lines(font, &message, focused);
message.time = self.now();
self.received += 1;
if self.qol.compact
&& let Some(last) = self.messages.first()
&& repeats(last, &message)
{
// The newest lines are that message's: they make way for its lines with the count.
let mut last = self.messages.remove(0);
let shown = last.wrapped.min(self.lines.len());
self.lines.drain(..shown);
// A reader scrolled up keeps their view: only lines the merge adds move it.
let (scroll, new_since_scroll) = (self.scroll, self.new_since_scroll);
last.repeats += 1;
last.added = message.added;
last.time = message.time;
last.wrapped = self.add_lines(font, &last, focused);
if scroll > 0 {
self.scroll = scroll;
self.new_since_scroll = new_since_scroll;
self.scroll_by(last.wrapped as i32 - shown as i32, focused);
}
self.messages.insert(0, last);
return;
}
message.wrapped = self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
fn now(&self) -> (u8, u8) {
#[cfg(test)]
if let Some(time) = self.clock {
return time;
}
local_time()
}
/// The lines `message` wraps to (none when the chat search hides it), added newest first.
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) -> usize {
// What the chat shows: legacy `§` codes format the text, they aren't part of it.
let whole = message.content.strip_legacy().plain();
if let Some(query) = &self.search
&& !whole.to_lowercase().contains(query.as_str())
{
return 0;
}
let whole: std::sync::Arc<str> = whole.into();
let mut count = 0;
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
@@ -383,8 +469,9 @@ impl Chat {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
let content = self.message_text(message);
for line in font.wrap_components(&content, width) {
count += 1;
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -395,10 +482,12 @@ impl Chat {
added: message.added,
content: line,
tag: message.tag.clone(),
message: whole.clone(),
},
);
}
self.lines.truncate(100);
count
}
/// What a message's lines wrap (ComponentRenderUtils.wrapComponents):
/// without Chat Colors its `§` codes are stripped (stripColor); with
@@ -410,6 +499,67 @@ impl Chat {
content.strip_legacy()
}
}
/// A message's lines before wrapping: [`Chat::displayed`], with PvP & HUD's time before it
/// and count after it, in grey.
fn message_text(&self, message: &Message) -> Text {
let content = self.displayed(&message.content);
if !self.qol.timestamps && message.repeats <= 1 {
return content;
}
let mut out = Text::empty();
if self.qol.timestamps {
let (h, m) = message.time;
let time = Text::literal(format!("{h:02}:{m:02}"));
out = out.append(
Text::translate_with_fallback("horizon.chat.timestamp", "[%s] ", &[time])
.with_color(text::GRAY),
);
}
out = out.append(content);
if message.repeats > 1 {
let count = Text::literal(message.repeats.to_string());
out = out.append(
Text::translate_with_fallback("horizon.chat.repeats", " (x%s)", &[count])
.with_color(text::GRAY),
);
}
out
}
/// PvP & HUD's chat options; a change lays the lines out again.
pub fn set_qol(&mut self, atlas: &HudAtlas, qol: ChatQol) {
if self.qol != qol {
self.qol = qol;
self.rewrap(&atlas.font);
}
}
/// PvP & HUD's chat search: only the messages holding `query` (any case) show; `None` or
/// an empty query shows them all again.
pub fn set_search(&mut self, atlas: &HudAtlas, query: Option<&str>) {
self.set_search_with(&atlas.font, query);
}
fn set_search_with(&mut self, font: &crate::font::Font, query: Option<&str>) {
// Called each frame while searching: no work (nor allocation) for the same query.
let blank = |q: &str| q.trim().is_empty();
let same = match (&self.search, query) {
(None, None) => true,
(None, Some(q)) => blank(q),
(Some(_), None) => false,
(Some(current), Some(q)) => q.chars().flat_map(char::to_lowercase).eq(current.chars()),
};
if same {
return;
}
let query = query
.map(str::to_lowercase)
.filter(|q| !q.trim().is_empty());
if self.search != query {
self.search = query;
self.rewrap(font);
}
}
pub fn searching(&self) -> bool {
self.search.is_some()
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
@@ -419,16 +569,15 @@ impl Chat {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(font, m, false);
let mut messages = std::mem::take(&mut self.messages);
for m in messages.iter_mut().rev() {
m.wrapped = if self.abilities.shows(m.source) {
self.add_lines(font, m, false)
} else {
0
};
}
self.messages = messages;
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
@@ -490,6 +639,9 @@ impl Chat {
}
*message = Message {
added: message.added,
time: message.time,
repeats: 1,
wrapped: 0,
content: Self::deleted_marker(),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
@@ -694,15 +846,42 @@ impl Chat {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: f32, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
if focused && self.abilities.any_restriction() {
let restricted = focused && self.abilities.any_restriction();
if restricted {
let prompt = self.restricted_prompt(&atlas.font, batch.height as i32);
hud_layout::paint(batch, atlas, &prompt);
}
if focused && self.search_prompt {
let row = if restricted { 2 } else { 1 };
let prompt = self.search_prompt(&atlas.font, batch.height as i32, row);
hud_layout::paint(batch, atlas, &prompt);
}
}
/// `ChatComponent.extractRenderState` for `FOREGROUND_RESTRICTED`: a
/// background row and the red, underlined "Chat is restricted" line in
/// the row above the messages, clipped to the chat's width.
fn restricted_prompt(&self, font: &crate::font::Font, height: i32) -> Vec<GuiDraw> {
let prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
self.prompt_above(font, height, prompt, 1)
}
/// PvP & HUD's chat search: a yellow "Search chat:" line above the lines while the box holds
/// the query.
fn search_prompt(&self, font: &crate::font::Font, height: i32, row: i32) -> Vec<GuiDraw> {
let label = Text::translate_with_fallback("horizon.chat.search", "Search chat:", &[]);
self.prompt_above(font, height, label.with_color(text::YELLOW), row)
}
/// A line `row` lines above the shown ones, on the chat's background.
fn prompt_above(
&self,
font: &crate::font::Font,
height: i32,
prompt: Text,
row: i32,
) -> Vec<GuiDraw> {
let o = &self.options;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
@@ -715,12 +894,8 @@ impl Chat {
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let top = chat_bottom - (shown as i32 + 1) * entry_height;
let mut prompt = Text::translate("chat_screen.restricted", &[]).with_style(&Style {
color: Some(text::RED),
underlined: Some(true),
..Style::default()
});
let top = chat_bottom - (shown as i32 + row) * entry_height;
let mut prompt = prompt;
// ComponentRenderUtils.clipText.
let mut width = 0.0;
let fits = prompt
@@ -765,6 +940,12 @@ impl Chat {
) -> Option<ClickEvent> {
self.style_at(font, height, x, y)?.click
}
/// The whole text of the message under a GUI-space cursor while chat is focused (PvP &
/// HUD's copy on click), without its time or count.
pub fn message_at(&self, height: i32, x: f64, y: f64) -> Option<&str> {
let (line, x) = self.line_at(height, x, y)?;
(x >= 0.0).then_some(&*line.message)
}
/// The hover text under a GUI-space cursor while chat is focused
/// (ChatScreen.render's `renderComponentHoverEffect`).
pub fn hover_at(&self, font: &crate::font::Font, height: i32, x: f64, y: f64) -> Option<Text> {
@@ -34,6 +34,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
added: 0,
content,
tag: None,
message: "".into(),
},
);
}
@@ -51,6 +52,7 @@ fn smooth_chat_slides_a_line_in_and_can_hide_the_indicator() {
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
message: "hi".into(),
});
let text_alpha = |draws: &[GuiDraw]| {
draws.iter().find_map(|d| match d {
@@ -701,3 +703,140 @@ fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
let mixed = format!("{}\u{1f600}", "b".repeat(255));
assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
}
/// PvP & HUD's chat options: the time before each message, and a repeated message counted on
/// its own lines instead of filling the chat.
#[test]
fn timestamps_and_compact_chat_change_the_lines_not_the_messages() {
let font = font();
let mut chat = Chat {
clock: Some((9, 5)),
..Chat::default()
};
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "gg");
says(&mut chat, "gg");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg (x3)"]);
says(&mut chat, "wp");
says(&mut chat, "gg");
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
assert_eq!(chat.received, 5, "every message still counts as received");
chat.qol.timestamps = true;
chat.rewrap(&font);
assert_eq!(
shown(&chat),
["[09:05] gg", "[09:05] wp", "[09:05] gg (x3)"]
);
// Copying takes the message as it came: no time, no count.
assert_eq!(&*chat.lines[2].message, "gg");
// Turned off, the history shows each repeat as it was counted.
chat.qol = ChatQol::default();
chat.rewrap(&font);
assert_eq!(shown(&chat), ["gg", "wp", "gg (x3)"]);
}
/// PvP & HUD's chat search: only the messages holding the query show, in any case; clearing
/// it brings every line back.
#[test]
fn the_chat_search_keeps_the_matching_messages() {
let font = font();
let mut chat = Chat::default();
for text in ["Alpha one", "beta", "ALPHABET soup"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
chat.set_search_with(&font, Some("alpha"));
assert!(chat.searching());
assert_eq!(shown(&chat), ["ALPHABET soup", "Alpha one"]);
// A new message that doesn't match stays out of sight until the search ends.
chat.add_message(
&font,
Text::literal("gamma"),
None,
ChatSource::Server,
0,
false,
);
assert_eq!(shown(&chat).len(), 2);
chat.set_search_with(&font, Some(" "));
assert!(!chat.searching());
assert_eq!(
shown(&chat),
["gamma", "ALPHABET soup", "beta", "Alpha one"]
);
}
/// Search and copy read a message as the chat shows it: legacy `§` codes format it, they
/// aren't text.
#[test]
fn the_chat_search_and_copy_skip_legacy_codes() {
let font = font();
let mut chat = Chat::default();
for text in ["He§cllo", "§aplain"] {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
false,
);
}
assert_eq!(&*chat.lines[1].message, "Hello");
chat.set_search_with(&font, Some("hello"));
assert_eq!(chat.lines.len(), 1);
// `c` and `a` are codes here, not letters of either message.
chat.set_search_with(&font, Some("c"));
assert_eq!(chat.lines.len(), 0);
}
/// A repeat merged by compact chat leaves a reader scrolled up where they were.
#[test]
fn a_compact_repeat_keeps_the_scrolled_view_still() {
let font = font();
let mut chat = Chat::default();
chat.qol.compact = true;
let says = |chat: &mut Chat, text: &str| {
chat.add_message(
&font,
Text::literal(text),
None,
ChatSource::Server,
0,
true,
);
};
for i in 0..60 {
says(&mut chat, &format!("line {i}"));
}
says(&mut chat, "gg");
chat.scroll_by(5, true);
assert_eq!(chat.scroll, 5);
says(&mut chat, "gg");
assert_eq!(chat.lines[0].content.plain(), "gg (x2)");
assert_eq!(chat.scroll, 5);
// A new message still pushes the view up by its line.
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
@@ -120,6 +120,16 @@ impl<W: BlockCollisionWorld> BlockCollisionWorld for EntityCollisions<'_, W> {
) -> std::result::Result<bool, Self::Error> {
self.blocks.may_reach_out(chunk, section_y)
}
fn section_is_empty(
&mut self,
chunk: Self::Chunk,
section_y: i32,
) -> std::result::Result<bool, Self::Error> {
self.blocks.section_is_empty(chunk, section_y)
}
fn missing_chunks_stay_missing(&self) -> bool {
self.blocks.missing_chunks_stay_missing()
}
fn context_shape(
&mut self,
block: CollisionBlock,
@@ -424,6 +434,26 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
Ok(self.section_reaches_out(chunk, section_y))
}
/// From the section's palette: every block it can hold has an empty shape (air, plants).
/// A palette of the whole registry, or a section outside the column, is read cell by cell.
fn section_is_empty(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
Ok(blocks.is_some_and(|blocks| {
!blocks.is_global()
&& !blocks.maybe_has(|v| {
self.binding
.collision_block(BlockStateId(v))
.map_or(true, |block| !block.empty_shape)
})
}))
}
/// The resident columns do not change while a scan runs, and looking one up changes nothing.
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn is_suffocating(
&mut self,
_: Self::Chunk,
@@ -555,6 +585,9 @@ impl BlockCollisionWorld for EmptyCollisions {
fn collision_chunk(&mut self, _: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(None)
}
fn missing_chunks_stay_missing(&self) -> bool {
true
}
fn collision_block(&mut self, _: ChunkPos, _: BlockPos) -> io::Result<CollisionBlock> {
Err(io::Error::other("no chunk is resident"))
}
+4 -3
View File
@@ -608,7 +608,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `data_maps/` and `tags/…/` files, by
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `dialog/`, `damage_type/`, `data_maps/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -617,7 +617,7 @@ pub struct DataStore {
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `recipe_filter/`, `loot_table/`, `advancement/`, `trade_set/`,
/// `villager_trade/`, `dialog/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// `villager_trade/`, `dialog/`, `damage_type/`, `data_maps/` or `tags/<sub>/`, where the id keeps the sub
/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
@@ -625,7 +625,7 @@ fn content_id(file: &str) -> Option<String> {
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "recipe_filter" | "loot_table" | "advancement" | "trade_set"
| "villager_trade" | "dialog" | "data_maps" => kind,
| "villager_trade" | "dialog" | "damage_type" | "data_maps" => kind,
// A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
// test:sub/mytag`), the whole path after the namespace, so nested
// registries keep their directory (`TagLoader`'s id shape).
@@ -656,6 +656,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"damage_type",
"data_maps",
"dialog",
"function",
+13 -2
View File
@@ -87,7 +87,11 @@ fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
// primed TNT flash (5) and at column overlay - 16 for 16..=31 (a creeper's
// swelling). Alpha 1 changes nothing.
fn overlay_texel(overlay: u32) -> vec4<f32> {
if overlay == 1u || overlay == 8u { return HURT_OVERLAY; }
if overlay == 1u || overlay == 8u {
// PvP & HUD's hit colour (pvp.rs `Hit::texel`) rides in colored_count.y; 0 is vanilla's.
let hit = globals.colored_count.y;
return select(HURT_OVERLAY, unpack4x8unorm(hit), hit != 0u);
}
if overlay == 5u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[15]); }
if overlay >= 16u && overlay < 32u { return vec4(1.0, 1.0, 1.0, WHITE_OVERLAY_ALPHA[overlay - 16u]); }
return vec4(1.0);
@@ -246,11 +250,18 @@ fn armor_glint_uv(model: vec2<f32>) -> vec2<f32> {
return vec2(scaled.x * c - scaled.y * s - offset0,
scaled.x * s + scaled.y * c + offset1);
}
// PvP & HUD's glint colour (pvp.rs `Tweaks::glint_texel`, 0 for vanilla's): the sample's
// brightest channel over the chosen colour, so the shimmer keeps its pattern and strength.
fn tinted_glint(glint: vec4<f32>, packed: u32) -> vec4<f32> {
if packed == 0u { return glint; }
let tint = unpack4x8unorm(packed).rgb;
return vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
// RenderPipelines.GLINT: the source colour is scaled by itself, so `GlintAlpha`
// is squared along with the sample.
fn glint_over(color: vec4<f32>, glint: vec4<f32>) -> vec4<f32> {
let alpha = globals.fog_ends.w;
let g = alpha * glint;
let g = alpha * tinted_glint(glint, globals.colored_count.z);
return vec4(color.rgb + g.rgb * g.rgb, max(color.a, alpha));
}
// CRUMBLING_UV_BEGIN
+1 -1
View File
@@ -46,7 +46,7 @@ impl Corner {
Corner::BottomRight => "Bottom right",
}
}
fn parse(s: &str, default: Corner) -> Corner {
pub fn parse(s: &str, default: Corner) -> Corner {
Self::ALL
.into_iter()
.find(|c| c.name() == s)
+35 -2
View File
@@ -212,8 +212,11 @@ pub struct FogInputs<'a> {
pub boss_world_fog: bool,
/// The camera entity is a spectator (`LavaFogEnvironment`).
pub spectator: bool,
/// The camera entity has Fire Resistance (`LavaFogEnvironment`).
/// The camera entity has Fire Resistance (`LavaFogEnvironment`), or PvP & HUD's clear lava
/// asks for its fog.
pub fire_resistance: bool,
/// PvP & HUD's clear water: the water vision `FogState` reaches after 600 ticks under water.
pub full_water_vision: bool,
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
@@ -224,7 +227,11 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog
let rd = input.render_distance_chunks as f32 * 16.0;
let sky = input.sky;
let eye_in_water = input.fog_type == FogType::Water;
let water_vision = state.water_vision(eye_in_water);
let water_vision = if input.full_water_vision && eye_in_water {
1.0
} else {
state.water_vision(eye_in_water)
};
// Base colour of the applicable environment.
let mut color = match input.fog_type {
FogType::Lava => rgb(0x99_1A00),
@@ -451,6 +458,7 @@ mod tests {
boss_world_fog: false,
spectator: false,
fire_resistance: false,
full_water_vision: false,
night_vision: 0.0,
}
}
@@ -673,6 +681,31 @@ mod tests {
assert_eq!(s.water_vision(false), 0.0);
}
#[test]
fn clear_water_sees_as_far_as_after_thirty_seconds_under_water() {
let sky = Sky::default();
let tables = FogTables {
water_fog_end: vec![1.0],
};
let mut input = inputs(&sky);
input.fog_type = FogType::Water;
// Just in: vanilla's water vision is 0, so the fog ends at a quarter of 96 blocks.
let mut state = FogState::default();
state.tick(true);
let first = setup_fog(&tables, &state, &input);
assert_eq!(first.environmental_end, 24.0);
for _ in 0..600 {
state.tick(true);
}
let settled = setup_fog(&tables, &state, &input);
assert_eq!(settled.environmental_end, 96.0);
input.full_water_vision = true;
let mut fresh = FogState::default();
fresh.tick(true);
let clear = setup_fog(&tables, &fresh, &input);
assert_eq!(clear.environmental_end, settled.environmental_end);
assert_eq!(clear.color, settled.color);
}
#[test]
fn rain_fog_pulls_fog_in() {
let sky = Sky {
fog_end: 1024.0,
+37 -5
View File
@@ -209,6 +209,7 @@ mod view;
mod visbuffer;
mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -883,6 +884,8 @@ pub struct Frame<'a> {
pub outline_after_terrain: bool,
/// `BlockOutlineRenderState.highContrast`: a thick black outline under a bright one.
pub outline_high_contrast: bool,
/// The PvP & HUD page's outline and hit colour (`pvp::Frame`; default: vanilla's).
pub pvp: crate::pvp::Frame,
/// `lines` with a colour and pixel width (`gizmos::camera_relative`):
/// the fishing line and debug gizmos, depth tested, then gizmos drawn
/// over everything.
@@ -933,6 +936,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Native mods' lines, boxes, beams and text (`native_world_primitives`), drawn at the end
/// of the world pass, before the first-person hand.
pub world_primitives: world_primitives::Batch<'a>,
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
@@ -1158,6 +1164,8 @@ pub struct Renderer {
lightning_pipeline: wgpu::RenderPipeline,
lightning: lightning::Renderer,
see_through_pipeline: wgpu::RenderPipeline,
/// Native mods' world primitives (`Frame::world_primitives`).
world_primitives: world_primitives::WorldPrimitives,
text_polygon_offset_pipeline: wgpu::RenderPipeline,
celestial_pipeline: wgpu::RenderPipeline,
celestial_buffer: wgpu::Buffer,
@@ -4455,6 +4463,7 @@ impl Renderer {
on_off(gpu_timer.is_some()),
));
}
let world_primitives = world_primitives::WorldPrimitives::new(&device, frame_format);
Ok(Self {
display: DisplayWatch::default(),
display_fresh: false,
@@ -4542,6 +4551,7 @@ impl Renderer {
lightning_pipeline,
lightning: lightning::Renderer::new(),
see_through_pipeline,
world_primitives,
text_polygon_offset_pipeline,
celestial_pipeline,
celestial_buffer,
@@ -7252,8 +7262,10 @@ impl Renderer {
},
colored_count: [
frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
0,
0,
// PvP & HUD's hit colour: entity.wgsl's hurt texel, 0 for vanilla's.
frame.pvp.hurt_texel,
// PvP & HUD's glint colour: entity.wgsl's `tinted_glint`, 0 for vanilla's.
frame.pvp.glint,
0,
],
emissive_layers: {
@@ -8118,6 +8130,19 @@ impl Renderer {
frame.weather,
frame.weather_time,
);
// Native mods' world primitives, drawn as the world is: its jittered projection, the
// line widths in the frame's pixels as the gizmo lines'.
let world_primitives = !frame.title && {
let (uploads, encoder) = (&mut self.uploads, &mut encoder);
self.world_primitives.prepare(
&self.device,
&mut |buffer, bytes| uploads.buffer(encoder, buffer, 0, bytes),
&frame.world_primitives,
jittered,
[self.config.width, self.config.height],
&self.entity_atlas,
)
};
let outline_edges = frame.outline.len().min(MAX_OUTLINE_EDGES as usize);
if outline_edges > 0 {
// The same per-line colour and pixel width layout as the gizmo lines. A high
@@ -8133,12 +8158,14 @@ impl Renderer {
line
})
};
// A custom outline (PvP & HUD) takes the bright stroke's colour, scales both widths.
let (custom, scale) = frame.pvp.outline.map_or((None, 1.0), |(c, s)| (Some(c), s));
let lines: Vec<[f32; 8]> = if frame.outline_high_contrast {
stroke(0xff00_0000, 7.0)
.chain(stroke(0xff57_ffe1, width))
stroke(0xff00_0000, 7.0 * scale)
.chain(stroke(custom.unwrap_or(0xff57_ffe1), width * scale))
.collect()
} else {
stroke(0x6600_0000, width).collect()
stroke(custom.unwrap_or(0x6600_0000), width * scale).collect()
};
self.uploads
.buffer(&mut encoder, &self.outline, 0, bytemuck::cast_slice(&lines));
@@ -10039,6 +10066,11 @@ impl Renderer {
0..1,
);
}
// Native mods' primitives: after the entities and the rest of the world, into its
// targets (its resolution, reversed depth and TAA), before the hand.
if world_primitives {
self.world_primitives.draw(&mut pass);
}
if hand_in_world {
pass.set_pipeline(&self.depth_reset);
pass.set_bind_group(0, &self.group, &[]);
@@ -517,6 +517,7 @@ impl Scene {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -548,6 +549,7 @@ impl Scene {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &self.celestial,
stars: f
@@ -173,7 +173,8 @@ struct HorizonMaterialSolid {
@location(0) color: vec4<f32>,
// The surface target's data: `set_surface`'s, then the fragment's depth, which the post
// stage's `surface_at` compares with the final depth.
@location(1) surface: vec4<f32>,
// Its four floats' bits (`SURFACE_FORMAT` is an integer format).
@location(1) surface: vec4<u32>,
}
// What `fs_solid` leaves in the frame's alpha for HDR output, from the sprite before the pack's
// material: the pack decides the colour, Horizon still knows which pixels are light sources.
@@ -209,7 +210,7 @@ fn horizon_material_solid(in: HorizonMaterialOut) -> HorizonMaterialSolid {
var out: HorizonMaterialSolid;
let mark = horizon_material_mark(in, texel);
out.color = horizon_material_fogged(vec4(color.rgb, mark), in.spherical, in.cylindrical);
out.surface = vec4(horizon_surface.xyz, in.clip.z);
out.surface = bitcast<vec4<u32>>(vec4(horizon_surface.xyz, in.clip.z));
return out;
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
@@ -335,9 +336,12 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
/// The surface target (the post stage's `scene_surface`): the bits of four floats, x the normal
/// (two snorm16 in its bits), y smoothness, z the pack's data, w the fragment's depth. Stored
/// as unsigned integers, not `Rgba32Float`: RGBA32UI is colour-renderable on every backend,
/// where OpenGL (GLES 3 semantics) refuses RGBA32F as a render attachment, and integers keep
/// the packed normal's bits exact.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Uint;
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`, and the vertex stage in its module when `vertex`.
@@ -687,7 +687,7 @@ impl NativePost {
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
texture_entry(11, wgpu::TextureSampleType::Uint),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
@@ -1547,4 +1547,306 @@ file = \"b.wgsl\"
"{fourth:?}"
);
}
/// OpenGL refuses `Rgba32Float` as a render attachment (GLES 3 semantics, absent
/// `EXT_color_buffer_float`) but always renders to the integer formats, so the surface
/// is an integer format, and `surface_at` gets the floats back from the stored bits.
#[test]
fn the_surface_is_an_integer_target_that_the_post_stage_reads_bit_exact() {
assert_eq!(
super::super::native_material::SURFACE_FORMAT.sample_type(None, None),
Some(wgpu::TextureSampleType::Uint)
);
let Some((device, queue)) = device() else {
return;
};
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"a.wgsl\"\n";
// The surface's fields on the colour: normal x, smoothness, data, and 1 when present.
let a = "fn post(input: PostInput) -> vec4<f32> {\n \
let s = surface_at(vec2<i32>(input.pixel));\n \
return vec4<f32>(s.normal.x, s.smoothness, s.data, select(0.0, 1.0, s.present));\n}\n";
let files = [(MANIFEST, manifest), ("a.wgsl", a)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let view = screen.create_view(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// What the material stage stores, as the bits of floats: the normal +x (octahedral
// (1, 0), two snorm16), smoothness 0.5, data 0.25 and the fragment's depth 0.5.
let writer = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs() -> @location(0) vec4<u32> {
return bitcast<vec4<u32>>(vec4<f32>(
bitcast<f32>(pack2x16snorm(vec2<f32>(1.0, 0.0))), 0.5, 0.25, 0.5));
}"
.into(),
),
});
let writer = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &writer,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &writer,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let mut read = |depth_of_world: f64| {
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &frame.surface_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&writer);
pass.draw(0..3, 0..1);
}
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(depth_of_world as f32),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
post.run(
&device,
&mut encoder,
(&color, &depth, &frame.surface_view),
&view,
[4, 4],
true,
None,
);
encoder.copy_texture_to_buffer(
screen.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
screen.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
buffer.unmap();
pixel
};
// Present where the world's depth is the stored one: +x, 0.5, 0.25 as 8-bit.
assert_eq!(read(0.5), [255, 128, 64, 255]);
// Hidden by something nearer drawn since: the stored values no longer show.
assert_eq!(read(0.25), [0, 0, 0, 0]);
}
/// The surface holds float bit patterns, some of which are NaNs (a normal's two snorm16
/// can form one). The material stage writes `bitcast<vec4<u32>>(vec4<f32>(..))`; this
/// runs that same expression (not the whole material fragment, which needs a full terrain
/// pipeline) on bits fed as integers and checks the texture holds them exactly: payloads,
/// signs and denormals included.
#[test]
fn the_surface_keeps_every_bit_of_the_floats_written_through_the_material_stage_bitcast() {
let Some((device, queue)) = device() else {
return;
};
let cases: [[u32; 4]; 4] = [
[0x7fc0_1234, 0xffbf_ffff, 0x7fff_8001, 0x7f80_0001],
[0x8000_0000, 0x0000_0001, 0x007f_ffff, 0x8000_0001],
[0xffff_ffff, 0x7f80_0000, 0xff80_0000, 0x3f80_0000],
[0x7fff_8001, 0x0000_0000, 0x1234_5678, 0xdead_beef],
];
let mut bits = [[0u32; 4]; 16];
bits[..4].copy_from_slice(&cases);
let inputs = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 256,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(&inputs, 0, bytemuck::cast_slice(&bits));
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(
"@group(0) @binding(0) var<uniform> bits: array<vec4<u32>, 16>;
@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32((i << 1u) & 2u) * 2.0 - 1.0, f32(i & 2u) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<u32> {
let v = bitcast<vec4<f32>>(bits[u32(p.y) * 4u + u32(p.x)]);
return bitcast<vec4<u32>>(v);
}"
.into(),
),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: super::super::native_material::SURFACE_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: inputs.as_entire_binding(),
}],
});
let surface = texture(
&device,
super::super::native_material::SURFACE_FORMAT,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let row = u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: row * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let view = surface.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
encoder.copy_texture_to_buffer(
surface.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
rows_per_image: Some(4),
},
},
surface.size(),
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap().to_vec();
buffer.unmap();
for (i, expected) in bits.iter().enumerate() {
let (x, y) = (i % 4, i / 4);
let at = y * row as usize + x * 16;
let got: [u32; 4] = std::array::from_fn(|c| {
u32::from_ne_bytes(data[at + c * 4..at + c * 4 + 4].try_into().unwrap())
});
assert_eq!(
got.map(|v| format!("{v:08x}")),
expected.map(|v| format!("{v:08x}")),
"texel {x},{y}"
);
}
}
}
@@ -614,6 +614,13 @@ impl PackAtlas {
"vec4(0.0)"
};
let attributes = [
// section_instance stores 1 - visibility in a positive origin's mantissa.
// Negative origins encode a distant-terrain handover, fully visible here
// just as terrain.wgsl's instance_visibility treats them.
(
"mc_chunkFade",
"(hz_origin.w > 0.0 ? 1.0 - float(floatBitsToUint(hz_origin.w) & 0x7fffffu) / 8388608.0 : 1.0)",
),
("mc_Entity", entity),
("at_midBlock", mid_block),
(
@@ -641,6 +648,10 @@ impl PackAtlas {
}
}
#[cfg(test)]
#[path = "pack_atlas/fade_test.rs"]
mod fade_test;
#[cfg(test)]
#[path = "pack_atlas/midpoint_test.rs"]
mod midpoint_test;
@@ -0,0 +1,406 @@
//! Original pack-to-pixel controls of terrain's visibility input, with no external fixture.
use super::PackAtlas;
use crate::gfx::{section_instance, section_visibility};
use crate::mesher::TerrainVertex;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
use wgpu::util::DeviceExt;
struct ProbePack(PathBuf);
impl Drop for ProbePack {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).unwrap();
}
}
#[test]
fn terrain_fade_reaches_pack_pixels_through_real_instances() {
let instance = crate::gfx::test_gpu::instance();
let adapter = match pollster::block_on(instance.request_adapter(&Default::default())) {
Ok(adapter) => adapter,
Err(error) => {
eprintln!("SKIP terrain fade GPU control: no adapter: {error}");
return;
}
};
eprintln!("terrain fade adapter: {:?}", adapter.get_info());
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let array = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 2,
height: 2,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&Default::default());
let mut atlas = PackAtlas::new(&device, &mut encoder, &[array], 1, 1, 2).unwrap();
atlas.separate_ao = true;
queue.submit([encoder.finish()]);
let folder = ProbePack(
std::env::temp_dir().join(format!("horizon-terrain-fade-{}", std::process::id())),
);
std::fs::create_dir_all(folder.0.join("shaders")).unwrap();
// The guard, initializer, varying and integer output are independent probe code.
let vertex = "#version 330\nout vec4 observation;\n\
#ifdef IRIS_FEATURE_FADE_VARIABLE\nfloat first = mc_chunkFade;\n#endif\n\
void main() {\n#ifdef IRIS_FEATURE_FADE_VARIABLE\n\
observation = vec4(mc_chunkFade, first, gl_Color.a, gl_MultiTexCoord0.x);\n\
#else\nobservation = vec4(1.0, 1.0, gl_Color.a, gl_MultiTexCoord0.x);\n#endif\n\
gl_Position = vec4(0.0, 0.0, 0.0, 1.0);\n}";
let fragment = "#version 330\nin vec4 observation;\nlayout(location = 0) out uvec4 pixel;\n\
void main() { pixel = floatBitsToUint(observation); }";
let geometry_vertex = vertex
.replace("float first = mc_chunkFade;", "")
.replace("mc_chunkFade, first,", "mc_chunkFade, mc_chunkFade,")
.replace("observation", "vertex_observation");
let geometry_shader = "#version 330\nlayout(triangles) in;\n\
layout(triangle_strip, max_vertices=3) out;\nin vec4 vertex_observation[];\n\
out vec4 observation;\nconst float mc_chunkFade = 0.8125;\nvoid main() {\n\
for (int k=0; k<3; ++k) {\nobservation = vertex_observation[k];\n\
observation.y = mc_chunkFade;\n\
gl_Position = vec4(k == 1 ? 3.0 : -1.0, k == 2 ? 3.0 : -1.0, 0.0, 1.0);\n\
EmitVertex();\n}\nEndPrimitive();\n}";
for name in ["gbuffers_terrain", "gbuffers_water", "shadow"] {
for (extension, code) in [("vsh", vertex), ("fsh", fragment)] {
std::fs::write(folder.0.join(format!("shaders/{name}.{extension}")), code).unwrap();
}
}
let uploaded = Instant::now();
let cases = [
("first frame", Some(&uploaded), 0, 1000, 0, 0.0),
("137ms", Some(&uploaded), 137, 1000, 0, 0.137),
("midpoint", Some(&uploaded), 500, 1000, 0, 0.5),
("finished", Some(&uploaded), 1000, 1000, 0, 1.0),
("disabled", Some(&uploaded), 0, 0, 0, 1.0),
("no upload transition", None, 0, 1000, 0, 1.0),
("distant handover", Some(&uploaded), 500, 1000, 17, 1.0),
];
let mut count = 0;
for requested in [false, true] {
std::fs::write(
folder.0.join("shaders/shaders.properties"),
if requested {
"shadow.enabled=true\niris.features.optional=FADE_VARIABLE\niris.features.required=FADE_VARIABLE\n"
} else {
"shadow.enabled=true\n"
},
)
.unwrap();
let pack = horizon_shaders::Pack::open(&folder.0).unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, notes) = horizon_shaders::read_properties(&pack, &mut macros);
assert!(notes.is_empty(), "{notes:?}");
for flat in [false, true] {
for geometry in [false, true].into_iter().filter(|g| !*g || flat) {
let geometry_path = folder.0.join("shaders/shadow.gsh");
if geometry {
std::fs::write(&geometry_path, geometry_shader).unwrap();
} else if geometry_path.exists() {
std::fs::remove_file(&geometry_path).unwrap();
}
std::fs::write(
folder.0.join("shaders/shadow.vsh"),
if geometry {
geometry_vertex.as_str()
} else {
vertex
},
)
.unwrap();
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&horizon_shaders::GameInputs {
terrain: Some(atlas.terrain_inputs(flat)),
terrain_geometry: geometry.then(|| horizon_shaders::GeometryInputs {
inputs: atlas.terrain_inputs(true),
pull: super::PULL.to_owned(),
index: "@".to_owned(),
}),
..Default::default()
},
);
for (name, program) in [
("terrain", &post.terrain),
("water", &post.water),
("shadow", &post.shadow),
] {
if geometry && name != "shadow" {
continue;
}
let program = program
.as_ref()
.unwrap_or_else(|| panic!("{name}: {:?}", post.notes));
for &(case, timestamp, elapsed, fade_ms, handover, expected) in &cases {
let visibility = section_visibility(
timestamp,
fade_ms,
uploaded + Duration::from_millis(elapsed),
);
let origin = section_instance([48.0, 0.0, -16.0], handover, visibility);
let pixel = sample(
&device,
&queue,
&atlas,
program.program.as_ref().unwrap(),
flat,
geometry,
origin,
);
let fade = if requested { expected } else { 1.0 };
let expected = [
fade,
if geometry { 0.8125 } else { fade },
128.0 / 255.0,
0.25,
];
assert!(
pixel
.iter()
.zip(expected)
.all(|(a, b)| a.is_finite() && (a - b).abs() < 2e-7),
"{name} {case} flat={flat} geometry={geometry} requested={requested}: {pixel:?} != {expected:?}"
);
count += 1;
}
}
}
}
}
eprintln!("terrain fade pack-to-pixel cases executed: {count}");
assert_eq!(count, 98);
}
fn sample(
device: &wgpu::Device,
queue: &wgpu::Queue,
atlas: &PackAtlas,
program: &horizon_shaders::Program,
flat: bool,
geometry: bool,
origin: [f32; 4],
) -> [f32; 4] {
let buffer = |bytes: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytes,
usage,
})
};
let uniform = buffer(
&vec![0; program.interface.uniform_size as usize],
wgpu::BufferUsages::UNIFORM,
);
let origins = buffer(
bytemuck::bytes_of(&origin),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let slots = buffer(bytemuck::bytes_of(&0u32), wgpu::BufferUsages::STORAGE);
let vertices: Vec<_> = [[0., 0., 0.], [1., 0., 0.], [1., 1., 0.], [0., 1., 0.]]
.into_iter()
.map(|pos| TerrainVertex::new(pos, [0.25, 0.75], 0, 0x80ffffff, 0))
.collect();
let data = buffer(
bytemuck::cast_slice(&vertices),
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::VERTEX,
);
let ids = buffer(bytemuck::bytes_of(&[0i32; 3]), wgpu::BufferUsages::STORAGE);
let rects = buffer(
bytemuck::cast_slice(&atlas.rects_table()),
wgpu::BufferUsages::STORAGE,
);
let binding = |n, storage| wgpu::BindGroupLayoutEntry {
binding: n,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: if storage {
wgpu::BufferBindingType::Storage { read_only: true }
} else {
wgpu::BufferBindingType::Uniform
},
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let entries = [
vec![binding(0, false)],
if flat {
vec![binding(0, true), binding(1, true)]
} else {
vec![]
},
vec![],
if flat {
vec![binding(0, true), binding(1, true), binding(2, true)]
} else {
vec![]
},
];
let layouts: Vec<_> = entries
.iter()
.map(|entries| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: None,
entries,
})
})
.collect();
let bindings = [
vec![(0, &uniform)],
if flat {
vec![(0, &slots), (1, &origins)]
} else {
vec![]
},
vec![],
if flat {
vec![(0, &ids), (1, &data), (2, &rects)]
} else {
vec![]
},
];
let groups: Vec<_> = layouts
.iter()
.zip(bindings)
.map(|(layout, buffers)| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout,
entries: &buffers
.into_iter()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding,
resource: buffer.as_entire_binding(),
})
.collect::<Vec<_>>(),
})
})
.collect();
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: None,
bind_group_layouts: &layouts.iter().map(Some).collect::<Vec<_>>(),
..Default::default()
});
let module = |module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
})
};
let vs = module(program.vertex.clone());
let fs = module(program.fragment.clone());
let vertices = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &crate::gfx::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &wgpu::vertex_attr_array![5 => Float32x4],
}),
];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &vs,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &vertices[..if flat { 1 } else { 2 }],
},
primitive: wgpu::PrimitiveState {
topology: if geometry {
wgpu::PrimitiveTopology::TriangleList
} else {
wgpu::PrimitiveTopology::PointList
},
..Default::default()
},
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fs,
entry_point: Some("main"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba32Uint,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
let output = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba32Uint,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 16,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let view = output.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
for (n, group) in groups.iter().enumerate() {
pass.set_bind_group(n as u32, group, &[]);
}
pass.set_vertex_buffer(0, data.slice(..));
if !flat {
pass.set_vertex_buffer(1, origins.slice(..));
}
pass.draw(0..if geometry { 3 } else { 1 }, 0..1);
}
encoder.copy_texture_to_buffer(
output.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: Default::default(),
},
output.size(),
);
queue.submit([encoder.finish()]);
read.map_async(wgpu::MapMode::Read, .., |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
*bytemuck::from_bytes(&read.get_mapped_range(..).unwrap())
}
@@ -678,6 +678,43 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
}
}
/// `format` where `renders` says it can be a colour target, else the nearest float format that can:
/// the same channels at 16 bits, then `Rgba8Unorm`. On OpenGL the 32-bit and 16-bit float formats
/// render only with `GL_EXT_color_buffer_float`/`half_float`, and wgpu aborts on creating a target
/// without. Other formats pass through. Float colour buffers are only sampled (never blended), so
/// RENDER_ATTACHMENT is the only usage that decides.
fn renderable_float(
format: wgpu::TextureFormat,
renders: impl Fn(wgpu::TextureFormat) -> bool,
) -> wgpu::TextureFormat {
use wgpu::TextureFormat as T;
let half = match format {
T::R32Float | T::R16Float => T::R16Float,
T::Rg32Float | T::Rg16Float => T::Rg16Float,
T::Rgba32Float | T::Rgba16Float => T::Rgba16Float,
_ => return format,
};
if renders(format) {
format
} else if renders(half) {
half
} else {
T::Rgba8Unorm
}
}
/// [`texture_format`] made renderable on this adapter (see [`renderable_float`]).
fn target_format(
f: Format,
features: wgpu::Features,
adapter: &wgpu::Adapter,
) -> wgpu::TextureFormat {
renderable_float(texture_format(f, features), |format| {
(adapter.get_texture_format_features(format).allowed_usages)
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
})
}
/// The SNORM wgpu format of SNORM pack format `f`, where this adapter can render to it. Iris
/// stores such a buffer clamped to [-1, 1] and quantised to its bits; elsewhere the half floats of
/// [`texture_format`] stand in.
@@ -1891,7 +1928,8 @@ impl PackPost {
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
let f = post.config.buffers[i].format;
snorm_format(f, features, adapter).unwrap_or_else(|| texture_format(f, features))
snorm_format(f, features, adapter)
.unwrap_or_else(|| target_format(f, features, adapter))
});
let sky_clear = Some(&post.config.buffers[0])
.filter(|b| b.clear)
@@ -1944,7 +1982,9 @@ impl PackPost {
// buffers 0 to 7.
let shadow_formats: Option<[wgpu::TextureFormat; SHADOW_COLORS]> =
post.shadow_pass.then(|| {
std::array::from_fn(|n| texture_format(shadow_config.colors[n].format, features))
std::array::from_fn(|n| {
target_format(shadow_config.colors[n].format, features, adapter)
})
});
let shadow_space: [wgpu::TextureFormat; BUFFERS] = std::array::from_fn(|i| {
shadow_formats
@@ -5284,6 +5324,27 @@ mod tests {
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
}
#[test]
fn float_targets_fall_back_when_not_renderable() {
let all = |_| true;
assert_eq!(renderable_float(T::Rgba32Float, all), T::Rgba32Float);
// OpenGL without GL_EXT_color_buffer_float: only half floats render.
let half_only = |f: T| matches!(f, T::R16Float | T::Rg16Float | T::Rgba16Float);
assert_eq!(renderable_float(T::Rgba32Float, half_only), T::Rgba16Float);
assert_eq!(renderable_float(T::Rg32Float, half_only), T::Rg16Float);
assert_eq!(renderable_float(T::R32Float, half_only), T::R16Float);
assert_eq!(renderable_float(T::Rgba16Float, half_only), T::Rgba16Float);
// Neither renders: the 8-bit last resort.
assert_eq!(renderable_float(T::Rgba32Float, |_| false), T::Rgba8Unorm);
assert_eq!(renderable_float(T::Rg16Float, |_| false), T::Rgba8Unorm);
// Other formats are never replaced.
assert_eq!(renderable_float(T::Rgba8Uint, |_| false), T::Rgba8Uint);
assert_eq!(
renderable_float(T::Rgb10a2Unorm, |_| false),
T::Rgb10a2Unorm
);
}
#[test]
fn samplers_read_custom_textures_of_their_type() {
use wgpu::TextureSampleType as T;
@@ -595,6 +595,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -618,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -438,6 +438,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -461,6 +462,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -328,6 +328,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -1447,6 +1447,7 @@ mod initial_history_tests {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -1470,6 +1471,7 @@ mod initial_history_tests {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
celestial: &[],
stars: None,
hand: &[],
@@ -0,0 +1,563 @@
//! Native mods' world primitives (`native_world_primitives`, docs/native-mods.md): lines, boxes,
//! beams and text a mod hands the game each frame, drawn here with Horizon's own pipelines at
//! the end of the world pass, after the entities and before the first-person hand. They draw
//! into the world's targets, so they take the world's reversed depth, its draw resolution and
//! TAA's jitter; a mod never touches wgpu for them.
use super::Vertex;
use glam::Mat4;
/// A frame's primitives, camera-relative, as the game tessellated them: quads of 4 vertices
/// from the shared atlas (plain colours on its white texel, text on its glyphs, beams on the
/// beacon beam sprite) and lines as `gizmos::camera_relative` gives them. `*_through` ones are
/// seen through walls: no depth test.
#[derive(Clone, Copy, Debug, Default)]
pub struct Batch<'a> {
pub quads: &'a [Vertex],
pub quads_through: &'a [Vertex],
pub lines: &'a [[f32; 8]],
pub lines_through: &'a [[f32; 8]],
}
impl Batch<'_> {
pub fn is_empty(&self) -> bool {
self.quads.is_empty()
&& self.quads_through.is_empty()
&& self.lines.is_empty()
&& self.lines_through.is_empty()
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Globals {
view_proj: [[f32; 4]; 4],
viewport: [f32; 4],
}
/// The pipelines and buffers that draw a [`Batch`].
pub struct WorldPrimitives {
/// Depth tested, then seen through walls.
quad_pipelines: [wgpu::RenderPipeline; 2],
line_pipelines: [wgpu::RenderPipeline; 2],
layout: wgpu::BindGroupLayout,
globals: wgpu::Buffer,
sampler: wgpu::Sampler,
quads: wgpu::Buffer,
lines: wgpu::Buffer,
indices: wgpu::Buffer,
index_quads: u32,
group: Option<wgpu::BindGroup>,
/// Quads depth tested, quads through walls, lines depth tested, lines through walls.
counts: [u32; 4],
}
/// Writes bytes into a buffer as part of the frame (the renderer's staging uploads, or the
/// queue in a test).
pub type Upload<'u> = &'u mut dyn FnMut(&wgpu::Buffer, &[u8]);
impl WorldPrimitives {
/// Pipelines drawing into `color_format` over a reversed Depth32Float depth.
pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("World primitives"),
source: wgpu::ShaderSource::Wgsl(include_str!("world_primitives.wgsl").into()),
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("World primitives"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("World primitives"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let quad_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint32];
let line_attributes =
wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x3,2=>Unorm8x4,3=>Float32];
let quad_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &quad_attributes,
})];
let line_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 32,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &line_attributes,
})];
let pipeline = |label: &str,
entry: [&str; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology,
through: bool| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(entry[0]),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
topology,
// Seen from inside or behind as well: a box around the camera, a beam.
cull_mode: None,
..Default::default()
},
// Blended over what is drawn, writing no depth: a translucent box doesn't hide
// the primitives behind it. Reversed depth: nearer is greater.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(if through {
wgpu::CompareFunction::Always
} else {
wgpu::CompareFunction::GreaterEqual
}),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(entry[1]),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color_format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
};
let quads = ["vs_quad", "fs_quad"];
let lines = ["vs_line", "fs_line"];
let list = wgpu::PrimitiveTopology::TriangleList;
let strip = wgpu::PrimitiveTopology::TriangleStrip;
let quad_pipelines = [
pipeline("World primitives", quads, &quad_buffers, list, false),
pipeline(
"World primitives through walls",
quads,
&quad_buffers,
list,
true,
),
];
let line_pipelines = [
pipeline("World primitive lines", lines, &line_buffers, strip, false),
pipeline(
"World primitive lines through walls",
lines,
&line_buffers,
strip,
true,
),
];
let buffer = |label, size, usage| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
quad_pipelines,
line_pipelines,
globals: buffer(
"World primitive globals",
std::mem::size_of::<Globals>() as u64,
wgpu::BufferUsages::UNIFORM,
),
sampler: device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("World primitives"),
..Default::default()
}),
layout,
quads: buffer("World primitive quads", 0, wgpu::BufferUsages::VERTEX),
lines: buffer("World primitive lines", 0, wgpu::BufferUsages::VERTEX),
indices: buffer("World primitive indices", 0, wgpu::BufferUsages::INDEX),
index_quads: 0,
group: None,
counts: [0; 4],
}
}
/// Uploads this frame's `batch`, drawn with `view_proj` (camera-relative world to clip,
/// jittered as the world is) into a frame `viewport` pixels big, sampling `atlas`. Whether
/// there is anything to draw.
pub fn prepare(
&mut self,
device: &wgpu::Device,
upload: Upload<'_>,
batch: &Batch<'_>,
view_proj: Mat4,
viewport: [u32; 2],
atlas: &wgpu::TextureView,
) -> bool {
let quads = [batch.quads, batch.quads_through].map(|q| (q.len() / 4) as u32);
let lines = [batch.lines, batch.lines_through].map(|l| l.len() as u32);
self.counts = [quads[0], quads[1], lines[0], lines[1]];
if batch.is_empty() {
return false;
}
let grow = |buffer: &mut wgpu::Buffer, bytes: u64| {
if buffer.size() < bytes {
*buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("World primitives"),
size: bytes.next_power_of_two(),
usage: buffer.usage(),
mapped_at_creation: false,
});
}
};
let total_quads = quads[0] + quads[1];
if total_quads > 0 {
let vertices: Vec<Vertex> = [batch.quads, batch.quads_through]
.into_iter()
.flat_map(|q| &q[..q.len() / 4 * 4])
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&vertices);
grow(&mut self.quads, bytes.len() as u64);
upload(&self.quads, bytes);
if total_quads > self.index_quads {
self.index_quads = total_quads.next_power_of_two();
let indices: Vec<u32> = (0..self.index_quads)
.flat_map(|q| [0, 1, 2, 0, 2, 3].map(|i| q * 4 + i))
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&indices);
grow(&mut self.indices, bytes.len() as u64);
upload(&self.indices, bytes);
}
}
if lines[0] + lines[1] > 0 {
let all: Vec<[f32; 8]> = batch
.lines
.iter()
.chain(batch.lines_through)
.copied()
.collect();
let bytes: &[u8] = bytemuck::cast_slice(&all);
grow(&mut self.lines, bytes.len() as u64);
upload(&self.lines, bytes);
}
let globals = Globals {
view_proj: view_proj.to_cols_array_2d(),
viewport: [viewport[0] as f32, viewport[1] as f32, 0.0, 0.0],
};
upload(&self.globals, bytemuck::bytes_of(&globals));
self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("World primitives"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.globals.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(atlas),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
}));
true
}
/// Draws what [`Self::prepare`] uploaded into a pass over the world's colour and depth:
/// the depth-tested quads and lines, then those seen through walls over everything.
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(group) = &self.group else {
return;
};
let [quads, quads_through, lines, lines_through] = self.counts;
pass.set_bind_group(0, group, &[]);
for (through, (quad_range, line_range)) in [
(0..quads, 0..lines),
(quads..quads + quads_through, lines..lines + lines_through),
]
.into_iter()
.enumerate()
{
if !quad_range.is_empty() {
pass.set_pipeline(&self.quad_pipelines[through]);
pass.set_vertex_buffer(0, self.quads.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(quad_range.start * 6..quad_range.end * 6, 0, 0..1);
}
if !line_range.is_empty() {
pass.set_pipeline(&self.line_pipelines[through]);
pass.set_vertex_buffer(0, self.lines.slice(..));
pass.draw(0..4, line_range);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use glam::Vec3;
const SIZE: u32 = 64;
/// RGBA, as `Vertex::color` holds it.
const RED: u32 = 0xFF00_00FF;
const GREEN: u32 = 0xFF00_FF00;
const BLUE: u32 = 0xFFFF_0000;
/// ARGB, as a line carries it.
const MAGENTA_ARGB: u32 = 0xFFFF_00FF;
/// A camera-relative quad of plain colour facing the camera, `z` blocks ahead of it, over
/// the pixels `x0..x1`, `y0..y1` of the frame (y down).
fn quad(view_proj: Mat4, [x0, y0, x1, y1]: [f32; 4], z: f32, rgba: u32) -> [Vertex; 4] {
let depth = view_proj.project_point3(Vec3::new(0.0, 0.0, -z)).z;
let inverse = view_proj.inverse();
let at = |x: f32, y: f32| {
let ndc = Vec3::new(
x / SIZE as f32 * 2.0 - 1.0,
1.0 - y / SIZE as f32 * 2.0,
depth,
);
inverse.project_point3(ndc).to_array()
};
[at(x0, y0), at(x0, y1), at(x1, y1), at(x1, y0)].map(|pos| Vertex {
pos,
uv: [0.5, 0.5],
layer: 0,
color: rgba,
light: 0,
normal: [0; 4],
glint_uv: [0.0, 0.0],
})
}
/// A horizontal line, 4 pixels wide, over the pixels `x0..x1` of row `y`, `z` blocks ahead.
fn line(view_proj: Mat4, [x0, x1]: [f32; 2], y: f32, z: f32, argb: u32) -> [f32; 8] {
let q = quad(view_proj, [x0, y, x1, y], z, 0);
let [a, b] = [q[0].pos, q[3].pos];
[
a[0],
a[1],
a[2],
b[0],
b[1],
b[2],
f32::from_bits(argb),
4.0,
]
}
/// A wall 5 blocks ahead fills the depth (reversed: nearer is greater). Behind it, a
/// depth-tested quad and line stay hidden while the same seen through walls show; a
/// depth-tested quad in front of the wall shows.
#[test]
fn depth_tested_primitives_hide_behind_walls_and_see_through_ones_do_not() {
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
pollster::block_on(instance.request_adapter(&Default::default()))
.ok()
.and_then(|adapter| {
pollster::block_on(adapter.request_device(&Default::default())).ok()
})
else {
eprintln!("no GPU adapter: skipped");
return;
};
let view_proj = super::super::world_projection(70.0, 1.0, 512.0);
let wall = view_proj.project_point3(Vec3::new(0.0, 0.0, -5.0)).z;
// Reversed depth: 10 blocks away is less than the wall, 2 blocks away more.
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -10.0)).z < wall);
assert!(view_proj.project_point3(Vec3::new(0.0, 0.0, -2.0)).z > wall);
let format = wgpu::TextureFormat::Rgba8Unorm;
let texture = |size: u32, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
SIZE,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let depth = texture(
SIZE,
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT,
);
// The atlas: one white texel, what plain colours sample.
let atlas = texture(
1,
format,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
);
queue.write_texture(
atlas.as_image_copy(),
&[255; 4],
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4),
rows_per_image: None,
},
atlas.size(),
);
let (half, full) = (SIZE as f32 / 2.0, SIZE as f32);
// Top left: behind the wall, depth tested. Top right: behind it, through walls.
// Bottom left: in front of it, depth tested. Bottom right: lines behind it, the
// depth-tested one along row 40, the one through walls along row 56.
let quads = [
quad(view_proj, [0.0, 0.0, half, half], 10.0, RED),
quad(view_proj, [0.0, half, half, full], 2.0, BLUE),
]
.concat();
let quads_through = quad(view_proj, [half, 0.0, full, half], 10.0, GREEN);
let lines = [line(view_proj, [half, full], 40.0, 10.0, MAGENTA_ARGB)];
let lines_through = [line(view_proj, [half, full], 56.0, 10.0, MAGENTA_ARGB)];
let batch = Batch {
quads: &quads,
quads_through: &quads_through,
lines: &lines,
lines_through: &lines_through,
};
let mut primitives = WorldPrimitives::new(&device, format);
let atlas_view = atlas.create_view(&Default::default());
assert!(primitives.prepare(
&device,
&mut |buffer, bytes| queue.write_buffer(buffer, 0, bytes),
&batch,
view_proj,
[SIZE, SIZE],
&atlas_view,
));
let mut encoder = device.create_command_encoder(&Default::default());
{
let color_view = color.create_view(&Default::default());
let depth_view = depth.create_view(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color_view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(wall),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
primitives.draw(&mut pass);
}
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(SIZE * SIZE * 4),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
color.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: None,
},
},
color.size(),
);
queue.submit([encoder.finish()]);
readback.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let texels = readback.slice(..).get_mapped_range().unwrap().to_vec();
let at = |x: u32, y: u32| {
let i = ((y * SIZE + x) * 4) as usize;
[texels[i], texels[i + 1], texels[i + 2], texels[i + 3]]
};
let black = [0, 0, 0, 255];
assert_eq!(
at(16, 16),
black,
"a depth-tested quad behind the wall is hidden"
);
assert_eq!(
at(48, 16),
[0, 255, 0, 255],
"a quad through walls shows behind it"
);
assert_eq!(
at(16, 48),
[0, 0, 255, 255],
"a depth-tested quad before it shows"
);
assert_eq!(
at(48, 40),
black,
"a depth-tested line behind the wall is hidden"
);
assert_eq!(
at(48, 56),
[255, 0, 255, 255],
"a line through walls shows behind it"
);
// The line is its width (4 pixels) thick, not more.
assert_eq!(at(48, 52), black);
}
}
@@ -0,0 +1,77 @@
// Native mods' world primitives (gfx/world_primitives.rs): quads from the shared atlas and lines
// with a width in pixels, camera-relative, at the end of the world pass.
struct Globals {
// Camera-relative world to clip space, with TAA's jitter (what the world was drawn with).
view_proj: mat4x4<f32>,
// The frame's size in pixels (line widths are in its pixels).
viewport: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
struct QuadOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
// A beam's sprite v range (start, length), else 0.
@location(2) @interpolate(flat) wrap: vec2<f32>,
}
// `Vertex` (mesher.rs): position, atlas UV, layer, RGBA colour, light.
@vertex fn vs_quad(@location(0) pos: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: u32) -> QuadOut {
var out: QuadOut;
out.clip = globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
out.color = color;
// Layer 6 is a beacon beam's: its v repeats inside the sprite, whose range rides in the
// light bytes (block_entity_render::beam).
if (layer & 0xffffu) == 6u {
out.wrap = vec2(f32(light & 0xffffu), f32(light >> 16u)) / 65535.0;
} else {
out.wrap = vec2(0.0);
}
return out;
}
@fragment fn fs_quad(in: QuadOut) -> @location(0) vec4<f32> {
var uv = in.uv;
if in.wrap.y > 0.0 { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let color = textureSample(atlas, atlas_sampler, uv) * in.color;
if color.a <= 0.0 { discard; }
return color;
}
struct LineOut {
@builtin(position) clip: vec4<f32>,
@location(0) color: vec4<f32>,
}
// One instance per line (gizmos::camera_relative): its ends, ARGB colour and width in pixels,
// a strip of 4 vertices across it, as terrain.wgsl's `vs_debug_line`.
@vertex fn vs_line(@builtin(vertex_index) vertex: u32,
@location(0) start: vec3<f32>, @location(1) end: vec3<f32>,
@location(2) argb: vec4<f32>, @location(3) width: f32) -> LineOut {
let position = select(start, end, vertex >= 2u);
let view_shrink = 1.0 - 1.0 / 256.0;
let line_start = globals.view_proj * vec4(position * view_shrink, 1.0);
let line_end = globals.view_proj * vec4((position + (end - start)) * view_shrink, 1.0);
let ndc1 = line_start.xyz / line_start.w;
let ndc2 = line_end.xyz / line_end.w;
let screen = globals.viewport.xy;
let direction = normalize((ndc2.xy - ndc1.xy) * screen);
var offset = vec2(-direction.y, direction.x) * width / screen;
if offset.x < 0.0 { offset = -offset; }
if vertex % 2u != 0u { offset = -offset; }
var out: LineOut;
out.clip = vec4((ndc1 + vec3(offset, 0.0)) * line_start.w, line_start.w);
// ARGB in a little-endian u32 reads as B, G, R, A.
out.color = argb.zyxw;
return out;
}
@fragment fn fs_line(in: LineOut) -> @location(0) vec4<f32> {
return in.color;
}
+64 -13
View File
@@ -598,15 +598,16 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
let path = texture.split_once(':').map_or(texture.as_str(), |(_, p)| p);
let file = atlas_sources::texture_path(root, texture, "textures");
decode(&file).ok().map(|image| {
// Animated strips keep their first frame and animate,
// like the packed files.
if path.starts_with("block/") || path.starts_with("item/") {
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
// Animated strips keep their first frame and animate, like the
// packed files, under any folder: vanilla animates every sprite of
// these atlases (a server pack's `wynn/...` as well as `item/...`).
let mcmeta = horizon_core::prepared_files::read_to_string(
&file.with_extension("png.mcmeta"),
)
.ok();
let animation = animation_of(mcmeta.as_deref());
animated = animation.is_some();
if animated || path.starts_with("block/") || path.starts_with("item/") {
first_frame(image, animation.as_ref())
} else {
image
@@ -1115,7 +1116,7 @@ impl HudAtlas {
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
!s.contains("title/background") && !s.contains("presets")
});
// OptiFine random entity variants, under the names their scan gives them (those
// outside `textures/` are `<ns>:optifine/random/...`).
@@ -1515,6 +1516,8 @@ pub struct HudBatch {
pub end_portal: EndPortalFill,
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
pub glint_alpha: f32,
/// PvP & HUD's glint colour (`pvp::Tweaks::glint_texel`), 0 for vanilla's.
pub glint_colour: u32,
/// A resource pack text shader's `GameTime`: the day's fraction
/// (`gameTime % 24000` and the partial tick, over 24000).
pub game_time: f32,
@@ -1575,6 +1578,7 @@ impl HudBatch {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
@@ -2255,13 +2259,19 @@ struct HudUniform {
end_portal: [[f32; 4]; 3],
}
impl HudUniform {
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
fn new(size: [f32; 4], fill: EndPortalFill, glint_colour: u32) -> Self {
let [r, g, b, _] = glint_colour.to_le_bytes().map(|c| f32::from(c) / 255.0);
let tint = if glint_colour == 0 {
[-1.0, 0.0, 0.0]
} else {
[r, g, b]
};
Self {
size,
end_portal: [
fill.sprites[0],
fill.sprites[1],
[fill.game_time, 0.0, 0.0, 0.0],
[fill.game_time, tint[0], tint[1], tint[2]],
],
}
}
@@ -2318,7 +2328,7 @@ impl HudRenderer {
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default(), 0)),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -2691,6 +2701,7 @@ impl HudRenderer {
batch.glint_alpha,
],
batch.end_portal,
batch.glint_colour,
)),
);
if let Some(pack_text) = &self.pack_text {
@@ -3356,6 +3367,45 @@ mod tests {
.expect("the namespaced sprite animates");
assert_eq!(animation.frame_count(), 2);
}
#[test]
fn an_animated_texture_outside_item_folders_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-dir-anim-{}", std::process::id()));
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("items.json"),
r#"{"sources": [{"type": "directory", "source": "custom", "prefix": "custom/"}]}"#,
)
.unwrap();
// A server pack's item under its own folder: two 16x16 frames, blue then yellow, and
// a still 16x32 texture beside it.
write_strip(
&custom.join("gem.png"),
16,
32,
&[[0, 0, 255, 255], [255, 255, 0, 255]],
Some(r#"{"animation": {"frametime": 20}}"#),
);
write_strip(&custom.join("banner.png"), 16, 32, &[[9, 9, 9, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["custom/gem"];
// One frame, not the whole strip squeezed into a slot.
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 15), [0, 0, 255, 255]);
let (_, animation) = atlas
.sprite_animations
.iter()
.find(|(r, _)| r.u0 == rect.u0 && r.v0 == rect.v0)
.expect("the sprite animates");
assert_eq!(animation.frame_count(), 2);
// A still texture there stays whole, as vanilla stitches it.
let banner = atlas.sprites["custom/banner"];
assert_eq!(banner.texels((atlas.width, atlas.height)), (16, 32));
}
/// A resource root with one plain item texture and these CIT files (relative path, contents).
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
let root =
@@ -4131,6 +4181,7 @@ texture=wide",
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
glint_colour: 0,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
+7 -2
View File
@@ -1,7 +1,8 @@
struct Hud {
size: vec4<f32>,
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
// end_portal.png, then vanilla's GameTime in x.
// end_portal.png, then vanilla's GameTime in x and PvP & HUD's glint colour in yzw
// (y below 0: vanilla's).
end_portal: array<vec4<f32>, 3>,
}
@group(0) @binding(0) var<uniform> hud: Hud;
@@ -35,7 +36,11 @@ struct Out {
if in.uv.x >= 2.0 {
let mask = textureSample(atlas, atlas_sampler, in.uv - vec2(2.0, 0.0)).a;
if mask < 0.1 { discard; }
let glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
var glint = hud.size.w * textureSample(glint_tex, glint_sampler, in.glint_uv);
let tint = hud.end_portal[2].yzw;
if tint.x >= 0.0 {
glint = vec4(max(glint.r, max(glint.g, glint.b)) * tint, glint.a);
}
return vec4(glint.rgb * glint.rgb, mask);
}
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
+1
View File
@@ -84,6 +84,7 @@ pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod pvp;
pub mod pvp_hud;
pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
+268 -21
View File
@@ -202,6 +202,17 @@ const DETAIL_LEVEL: u8 = 2;
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
/// How long one frame spends applying far detail and visited summaries
/// (`Lod::receive`).
///
/// Cached chunks answer a `DETAIL_REQUEST` all at once, and applying one
/// copies the columns of every tile over it (`Arc::make_mut` on a snapshot a
/// mesh job shares) and reduces the chunk to that tile's cell width. A whole
/// burst in one frame cost 273 ms of it on the render thread, flying over far
/// terrain at `lodDistance` 256: 18 FPS average, 482 ms at the 99th
/// percentile, where the median frame was 6 ms. What the budget leaves waits
/// in `Lod::detail_backlog` for the frames after it.
const DETAIL_BUDGET: std::time::Duration = std::time::Duration::from_millis(3);
/// Top solid face and optional fluid face, in absolute block coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
@@ -1036,21 +1047,40 @@ impl ReducedColumns {
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
}
}
/// Builds the reduction levels whose bit is set in `levels`
/// (`Shared::detail_widths`), off the render thread.
fn warm(self, levels: u8) -> Self {
for level in 0..self.levels.len() {
if levels & (1 << level) != 0 {
self.level(level);
}
}
self
}
/// The chunk reduced to cells `2 << level` blocks wide, built once.
fn level(&self, level: usize) -> &[Column] {
let width = 2 << level;
self.levels[level].get_or_init(|| {
// Timed on whichever thread first asks for this width, so the
// frame profile tells a reduction apart from the column copying
// around it (`Lod::apply_detail` does both).
crate::spans::time("lod_reduce", || {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
})
})
}
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
if width == 1 {
return &self.columns[z * 16 + x];
}
let level = width.trailing_zeros() as usize - 1;
let reduced = self.levels[level].get_or_init(|| {
let columns = &self.columns;
(0..16 / width)
.flat_map(|z| {
(0..16 / width)
.map(move |x| Column::reduced(columns, x * width, z * width, width))
})
.collect()
});
&reduced[(z / width) * (16 / width) + x / width]
&self.level(level)[(z / width) * (16 / width) + x / width]
}
}
enum Job {
@@ -1065,6 +1095,14 @@ enum Job {
refreshes: u64,
},
}
/// A chunk's columns on their way to the tiles over it: what `Lod::receive`
/// defers when a frame's [`DETAIL_BUDGET`] is spent.
enum Summary {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
/// A visited chunk's summary.
Observed((i32, i32), ReducedColumns),
}
enum Done {
/// A chunk's detail, and the `refreshes` of the request it answers.
Detail((i32, i32), ReducedColumns, u64),
@@ -1149,6 +1187,19 @@ struct Shared {
stop: AtomicBool,
/// Reduced far detail: samplers approximate wide cells.
approximate: AtomicBool,
/// The reduction levels `Lod::apply_detail` reads, one bit each (level
/// `i` is a cell `2 << i` blocks wide), so the far terrain thread builds
/// them before it sends a chunk's columns on.
///
/// Learned rather than derived from the plan: a chunk's ancestors are
/// read only where they were sampled, which the plan alone does not say.
/// `apply_detail` sets a bit the first time it reads that width, so the
/// first chunk of a width still reduces on the render thread and every
/// chunk after it finds the work done. Bits are never cleared: a width
/// the view has stopped using costs the worker a reduction nothing
/// reads, and holding all four is exactly the eager behaviour this
/// replaces.
detail_widths: std::sync::atomic::AtomicU8,
/// On small machines, hold queued far meshing while near terrain loads.
mesh_held: AtomicBool,
/// The near terrain is loading: the far threads step behind it
@@ -1183,6 +1234,11 @@ pub struct Lod {
pending: FxHashSet<TileKey>,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
detail_request_pending: bool,
/// Detail and visited summaries past a frame's [`DETAIL_BUDGET`], applied
/// on the frames after it. Bounded by what the worker sends: a detail
/// request is one per 250 ms and only for chunks whose detail is missing,
/// so this drains between bursts rather than growing with the view.
detail_backlog: std::collections::VecDeque<Summary>,
/// `refresh`es so far (`Job::RequestDetail`), and the areas of the last
/// ones, each with its number.
refreshes: u64,
@@ -1290,6 +1346,7 @@ impl Lod {
mesh_wake: Condvar::new(),
stop: AtomicBool::new(false),
approximate: AtomicBool::new(false),
detail_widths: std::sync::atomic::AtomicU8::new(0),
mesh_held: AtomicBool::new(false),
near_loading: AtomicBool::new(false),
});
@@ -1468,7 +1525,12 @@ impl Lod {
'work: loop {
for (key, columns) in sampler.drain_details() {
if tx
.send(Done::Detail(key, ReducedColumns::new(columns), asked))
.send(Done::Detail(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
asked,
))
.is_err()
{
break 'work;
@@ -1514,7 +1576,11 @@ impl Lod {
};
let columns = summarize(&chunk, kinds);
sampler.observed(key, &columns);
Done::Observed(key, ReducedColumns::new(columns))
Done::Observed(
key,
ReducedColumns::new(columns)
.warm(worker.detail_widths.load(Ordering::Relaxed)),
)
}
Job::RequestDetail {
chunks,
@@ -1570,6 +1636,7 @@ impl Lod {
pending: FxHashSet::default(),
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_backlog: std::collections::VecDeque::new(),
refreshes: 0,
refreshed: VecDeque::new(),
detail_request_at: std::time::Instant::now(),
@@ -2098,6 +2165,12 @@ impl Lod {
};
let [ox, _, oz] = key.origin();
let stride = key.size() / 16;
// What the far terrain thread should have ready next time.
if stride > 1 {
self.shared
.detail_widths
.fetch_or(1 << (stride.trailing_zeros() - 1), Ordering::Relaxed);
}
let x0 = ((cx * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let x1 = (((cx + 1) * 16 - ox + stride - 1).div_euclid(stride)).clamp(0, 16);
let z0 = ((cz * 16 - oz + stride - 1).div_euclid(stride)).clamp(0, 16);
@@ -2269,6 +2342,12 @@ impl Lod {
fn request_detail(&mut self, camera: [i32; 2]) {
if self.held()
|| self.detail_request_pending
// Nothing new while summaries wait: a chunk whose detail is still
// in the backlog has its mask clear, so it would be asked for
// again, and answered from the cache at once. Without this the
// backlog grew by a request every 250 ms and never drained --
// 0.5 FPS, with `update` averaging 9.7 s a frame.
|| !self.detail_backlog.is_empty()
|| self.detail_request_at.elapsed() < std::time::Duration::from_millis(250)
{
return;
@@ -2495,17 +2574,25 @@ impl Lod {
self.wanted_stale = true;
}
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
let started = std::time::Instant::now();
// What an earlier frame left, before anything new: a chunk's detail
// must not overtake the detail of a chunk that arrived before it.
while started.elapsed() < DETAIL_BUDGET {
let Some(summary) = self.detail_backlog.pop_front() else {
break;
};
self.apply_summary(summary);
}
while let Ok(done) = self.results.try_recv() {
match done {
// Read before a `refresh` over it since: asked again (its
// bits stay clear), never kept over the newer data.
Done::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Done::Detail(key, columns, _) => {
crate::spans::time("lod_detail", || self.finish_detail(key, columns))
}
Done::Observed(key, columns) => {
crate::spans::time("lod_detail", || self.finish_observed(key, columns))
}
Done::Detail(key, columns, asked) => self.take_summary(
Summary::Detail(key, columns, asked),
started.elapsed() < DETAIL_BUDGET,
),
Done::Observed(key, columns) => self.take_summary(
Summary::Observed(key, columns),
started.elapsed() < DETAIL_BUDGET,
),
Done::DetailRequested => self.detail_request_pending = false,
Done::Sample {
key,
@@ -2530,6 +2617,28 @@ impl Lod {
}
}
}
/// Applies a summary while the frame has budget and nothing is already
/// waiting, else leaves it for a later frame.
fn take_summary(&mut self, summary: Summary, within_budget: bool) {
if within_budget && self.detail_backlog.is_empty() {
self.apply_summary(summary);
} else {
self.detail_backlog.push_back(summary);
}
}
/// A chunk's columns applied to the tiles over it.
///
/// `refreshed_since` is tested here and not when the result arrives, so a
/// refresh over the chunk while the summary waited still drops it: it was
/// read before that refresh, and the tiles ask again with their bits
/// clear.
fn apply_summary(&mut self, summary: Summary) {
crate::spans::time("lod_detail", || match summary {
Summary::Detail(key, _, asked) if self.refreshed_since(key, asked) => {}
Summary::Detail(key, columns, _) => self.finish_detail(key, columns),
Summary::Observed(key, columns) => self.finish_observed(key, columns),
});
}
/// A finished mesh: shown when it is newer than what the tile shows now.
/// A result newer than the frame's current one is still useful while a
/// newer job is pending — re-marking a tile re-dirties its neighbours and
@@ -3640,6 +3749,144 @@ mod tests {
reduced.get(0, 0, 16);
assert_eq!(built(&reduced), [4, 16]);
}
/// A summary past a frame's budget waits for a later frame rather than
/// being dropped, and no new detail is asked for while any waits.
///
/// Without the second part the backlog grew by a `DETAIL_REQUEST` every
/// 250 ms and never drained: a chunk whose summary is still waiting has
/// its detail mask clear, so it was asked for again and the cache
/// answered at once. That measured 0.5 FPS, `update` averaging 9.7 s.
#[test]
fn a_summary_past_the_budget_waits_and_holds_back_the_next_request() {
let mut lod = Lod::scheduler_for_test();
let chunk = (3, 5);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Within budget and with nothing waiting, a summary applies at once.
lod.take_summary(Summary::Observed(chunk, columns()), true);
assert!(lod.detail_backlog.is_empty(), "applied, not deferred");
assert!(lod.saved.contains_key(&chunk), "and it reached `saved`");
// Past the budget it waits, and anything after it waits behind it so
// a chunk's detail cannot overtake a chunk that arrived earlier.
lod.take_summary(Summary::Observed((9, 9), columns()), false);
lod.take_summary(Summary::Observed((10, 9), columns()), true);
assert_eq!(
lod.detail_backlog.len(),
2,
"the second waits behind the first"
);
assert!(!lod.saved.contains_key(&(9, 9)));
// Nothing new is asked for while summaries wait, whatever the
// request interval allows. Asserted on `detail_request_at`, which
// `request_detail` records once it is past its guards: a test
// scheduler has no active tile wanting detail, so `pending` would
// stay false either way and prove nothing.
let long_ago = std::time::Instant::now() - std::time::Duration::from_secs(10);
lod.detail_request_at = long_ago;
lod.detail_request_pending = false;
lod.request_detail([0, 0]);
assert_eq!(lod.detail_request_at, long_ago, "held back by the backlog");
// A later frame applies what waited, in the order it arrived.
while let Some(summary) = lod.detail_backlog.pop_front() {
lod.apply_summary(summary);
}
assert!(lod.saved.contains_key(&(9, 9)), "nothing was dropped");
assert!(lod.saved.contains_key(&(10, 9)));
// Drained, requests resume.
lod.detail_request_at = long_ago;
lod.request_detail([0, 0]);
assert!(
lod.detail_request_at > long_ago,
"asked once the backlog drained"
);
}
/// `warm` builds exactly the levels asked for, off the render thread, and
/// `get` then finds them built.
#[test]
fn warming_builds_the_levels_asked_for_and_no_others() {
let built = |reduced: &ReducedColumns| {
reduced
.levels
.iter()
.enumerate()
.filter(|(_, level)| level.get().is_some())
.map(|(level, _)| 2usize << level)
.collect::<Vec<_>>()
};
let chunk = || {
Arc::new(std::array::from_fn::<Column, 256, _>(|i| Column {
surface: None,
spans: vec![Span {
min: 0,
max: 4 + (i % 3) as i16,
state: BlockStateId(1),
biome: 0,
light: 240,
}]
.into(),
}))
};
// No bits: nothing is built, as before.
assert!(built(&ReducedColumns::new(chunk()).warm(0)).is_empty());
// Widths 2 and 8 are levels 0 and 2.
let warmed = ReducedColumns::new(chunk()).warm(0b101);
assert_eq!(built(&warmed), [2, 8]);
// Reading a warmed width builds nothing more; an unwarmed one does.
warmed.get(0, 0, 2);
warmed.get(6, 6, 8);
assert_eq!(built(&warmed), [2, 8], "already built");
warmed.get(0, 0, 4);
assert_eq!(built(&warmed), [2, 4, 8]);
// Warming is the same reduction as reading would have made.
let lazy = ReducedColumns::new(chunk());
for (x, z, width) in [(0, 0, 2), (6, 6, 8), (4, 8, 4), (0, 0, 16)] {
assert_eq!(
warmed.get(x, z, width),
lazy.get(x, z, width),
"width {width} at {x},{z}"
);
}
}
/// Applying a chunk's detail records the cell widths it read, so the far
/// terrain thread reduces them before it sends the next chunk.
#[test]
fn applying_detail_records_the_widths_it_read() {
let mut lod = Lod::scheduler_for_test();
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0);
let chunk = (0, 0);
let columns = || ReducedColumns::new(Arc::new(std::array::from_fn(|_| Column::default())));
// Only tiles in `ready` are read. A level-0 tile has cells one block
// wide, which is the chunk's own columns: no reduction, no bit.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 0,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(
lod.shared.detail_widths.load(Ordering::Relaxed),
0,
"full-detail cells read the chunk itself"
);
// A level-2 tile has cells 4 blocks wide: reduction level 1.
lod.ready.insert(
TileKey {
x: 0,
z: 0,
level: 2,
},
Arc::new(vec![Column::default(); CELLS * CELLS]),
);
lod.apply_detail(chunk, &columns());
assert_eq!(lod.shared.detail_widths.load(Ordering::Relaxed), 0b10);
}
/// Detail read before a refresh over its chunk is dropped (asked
/// again); elsewhere, or read after, it is kept.
#[test]
@@ -131,6 +131,16 @@ impl ModList {
/// Turns installed mod `i` on or off (a dev mod stays on). A native mod's
/// line says `false` when off, so that the launcher's next build leaves
/// it out, and `true` when on again.
/// Turn the installed mod `id` on (nothing when it isn't installed).
pub fn turn_on(&mut self, id: &str) {
let Some(m) = self.installed.iter().find(|m| m.id == id && !m.dev) else {
return;
};
if m.native.is_some() {
self.disabled.remove(&m.id);
}
self.enabled.insert(m.id.clone());
}
pub fn toggle(&mut self, i: usize) {
let Some(m) = self.installed.get(i).filter(|m| !m.dev) else {
return;
+30 -1
View File
@@ -1309,6 +1309,8 @@ enum Act {
ResetPadKey(&'static str),
ResetPadKeys,
ToggleMod(usize),
/// PvP & HUD's PvP preset (`pvp::apply_preset`).
PvpPreset,
/// Pick `s.shader_packs[i]`, or turn it off when it is the pick.
PickShaderPack(usize),
OpenShaderPackFolder,
@@ -1566,6 +1568,9 @@ pub struct OptionsUi {
pub selected_pad_action: Option<&'static str>,
/// The glyphs the binds list shows (the setting, else the pad's family).
pub pad_style: GlyphStyle,
/// The pad goes by its letters (`PadSettings::nintendo_letters`) and the glyphs are
/// Nintendo's: each part shows the letter of the button playing it.
pub pad_by_letter: bool,
pub languages: Vec<LanguageInfo>,
language_choice: Option<String>,
language_search: ui::EditBox,
@@ -1647,6 +1652,7 @@ impl Default for OptionsUi {
selected_native_key: None,
selected_pad_action: None,
pad_style: GlyphStyle::Xbox,
pad_by_letter: false,
languages: Vec::new(),
language_choice: None,
language_search: search,
@@ -1939,6 +1945,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
Act::Cycle(opt) => Some(opt.tooltip_key(s)),
Act::Push(Page::Language) => Some("options.language.tooltip".into()),
Act::Push(Page::Accessibility) => Some("options.accessibility.tooltip".into()),
Act::PvpPreset => Some("horizon.pvp.preset.tooltip".into()),
_ => None,
};
if let Some(key) = key.filter(|key| lang::has(key)) {
@@ -2469,7 +2476,20 @@ impl OptionsUi {
widget
}
};
let mut rows = Vec::new();
// The PvP preset first; on the Extras page beside the way to the PvP & HUD page.
let preset = button(0.0, 0.0, 150.0, tr("horizon.pvp.preset"), Act::PvpPreset);
let mut rows = vec![Row::Buttons(
preset,
(self.page() == Page::Extras).then(|| {
button(
0.0,
0.0,
150.0,
tr("horizon.pages.open.pvp"),
Act::Push(Page::Pvp),
)
}),
)];
let mut section = Vec::new();
let flush = |rows: &mut Vec<Row>, section: &mut Vec<crate::extras::Item>| {
rows.extend(
@@ -3244,6 +3264,7 @@ impl OptionsUi {
});
let mut message = match pad.binding(action) {
Some(b) => {
let b = if self.pad_by_letter { b.by_letter() } else { b };
let [r, g, bl] = b.color(self.pad_style);
Text::literal(b.label(self.pad_style))
.with_color(u32::from(r) << 16 | u32::from(g) << 8 | u32::from(bl))
@@ -4299,6 +4320,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
@@ -5240,6 +5265,10 @@ impl OptionsUi {
}
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::PvpPreset => {
crate::pvp::apply_preset(s);
events.push(Event::Changed);
}
Act::AccessibilityHelp => events.push(Event::AccessibilityHelp),
Act::PickShaderPack(i) => {
if let Some(pack) = s.shader_packs.get(i) {
+841 -7
View File
@@ -1,4 +1,5 @@
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair. Kept in
//! PvP and HUD options (`docs/pvp.md`): a custom crosshair, block outline, hit colour and HUD
//! widgets (`pvp_hud`). Kept in
//! `horizon-extras.txt` with the other extras ([`crate::extras::Extras::pvp`]) and shown on
//! Horizon Settings > PvP & HUD. Every option is off by default, so the game looks like
//! vanilla until the player changes one, and none of them shows anything vanilla doesn't
@@ -71,7 +72,7 @@ impl Colour {
pub fn argb(self) -> u32 {
match self {
Colour::Inverted | Colour::White => 0xFFFF_FFFF,
Colour::Red => 0xFFFF_3030,
Colour::Red => 0xFFFF_0000,
Colour::Orange => 0xFFFF_A020,
Colour::Yellow => 0xFFFF_FF40,
Colour::Green => 0xFF40_FF40,
@@ -87,6 +88,18 @@ impl Colour {
fn step(self, back: bool) -> Colour {
step(&Self::ALL, self, back)
}
/// The next solid colour (`Inverted` left out).
fn step_solid(self, back: bool) -> Colour {
step(&Self::ALL[1..], self, back)
}
/// The colour at `percent` opacity, as ARGB.
pub fn with_opacity(self, percent: u8) -> u32 {
self.argb() & 0x00FF_FFFF | alpha(percent) << 24
}
}
/// `percent` of 255, rounded.
fn alpha(percent: u8) -> u32 {
(u32::from(percent.min(100)) * 255 + 50) / 100
}
/// The next (or previous) value of `all` after `v`, wrapping.
@@ -97,11 +110,15 @@ fn step<T: Copy + PartialEq>(all: &[T], v: T, back: bool) -> T {
}
/// `v` one step up or down within `lo..=hi`, wrapping.
fn step_num(v: u8, lo: u8, hi: u8, back: bool) -> u8 {
step_by(v, (lo, hi), 1, back)
}
/// `v` `by` up or down within `lo..=hi`, wrapping.
fn step_by(v: u8, (lo, hi): (u8, u8), by: u8, back: bool) -> u8 {
match (back, v) {
(false, v) if v >= hi => lo,
(false, v) => v + 1,
(false, v) => (v + by).min(hi),
(true, v) if v <= lo => hi,
(true, v) => v - 1,
(true, v) => v.saturating_sub(by).max(lo),
}
}
@@ -158,6 +175,103 @@ impl Shape {
}
}
/// A time of day this player's view can stay at (`ViewTime::day_time` in vanilla's ticks).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewTime {
Morning,
Noon,
Sunset,
Midnight,
}
impl ViewTime {
pub const ALL: [ViewTime; 4] = [
ViewTime::Morning,
ViewTime::Noon,
ViewTime::Sunset,
ViewTime::Midnight,
];
pub fn name(self) -> &'static str {
match self {
ViewTime::Morning => "morning",
ViewTime::Noon => "noon",
ViewTime::Sunset => "sunset",
ViewTime::Midnight => "midnight",
}
}
fn english(self) -> &'static str {
match self {
ViewTime::Morning => "Morning",
ViewTime::Noon => "Noon",
ViewTime::Sunset => "Sunset",
ViewTime::Midnight => "Midnight",
}
}
/// The time of day it shows, as `/time set` takes it.
pub fn day_time(self) -> i64 {
match self {
ViewTime::Morning => 1000,
ViewTime::Noon => 6000,
ViewTime::Sunset => 12500,
ViewTime::Midnight => 18000,
}
}
pub fn label(self) -> String {
crate::lang::get_or(&format!("horizon.pvp.time.{}", self.name()), self.english())
}
fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The weather this player's view can show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ViewWeather {
Clear,
Rain,
Thunder,
}
impl ViewWeather {
pub const ALL: [ViewWeather; 3] = [ViewWeather::Clear, ViewWeather::Rain, ViewWeather::Thunder];
pub fn name(self) -> &'static str {
match self {
ViewWeather::Clear => "clear",
ViewWeather::Rain => "rain",
ViewWeather::Thunder => "thunder",
}
}
fn english(self) -> &'static str {
match self {
ViewWeather::Clear => "Clear",
ViewWeather::Rain => "Rain",
ViewWeather::Thunder => "Thunder",
}
}
/// `Level.getRainLevel` and `getThunderLevel` as `/weather` leaves them.
pub fn levels(self) -> (f32, f32) {
match self {
ViewWeather::Clear => (0.0, 0.0),
ViewWeather::Rain => (1.0, 0.0),
ViewWeather::Thunder => (1.0, 1.0),
}
}
pub fn label(self) -> String {
crate::lang::get_or(
&format!("horizon.pvp.weather.{}", self.name()),
self.english(),
)
}
fn parse(s: &str) -> Option<Self> {
Self::ALL.into_iter().find(|c| c.name() == s)
}
}
/// The day time this player sees with `fixed` set: the same day (the moon's phase follows the
/// world's), at that time.
pub fn view_day_time(world: i64, fixed: Option<ViewTime>) -> i64 {
match fixed {
Some(t) => world.div_euclid(24000) * 24000 + t.day_time(),
None => world,
}
}
/// The crosshair's look. Its defaults draw vanilla's: a 15-pixel cross, one pixel thick,
/// inverting what is under it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -339,9 +453,243 @@ impl CrosshairCache {
}
}
/// The block outline (`LevelRenderer.submitBlockOutline`). Off, vanilla's: black at 40%, or the
/// accessibility option's high contrast one.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Outline {
pub custom: bool,
/// A solid colour, at `opacity` percent.
pub colour: Colour,
pub opacity: u8,
/// Times vanilla's width (`Window.getAppropriateLineWidth`).
pub thickness: u8,
/// The block's faces tinted with this colour, lightly.
pub fill: Option<Colour>,
}
pub const OPACITIES: (u8, u8) = (10, 100);
pub const OUTLINE_THICKNESSES: (u8, u8) = (1, 4);
/// How much the fill covers the block's faces, in percent.
pub const FILL_OPACITY: u8 = 25;
impl Default for Outline {
fn default() -> Self {
Self {
custom: false,
colour: Colour::Black,
opacity: 40,
thickness: 1,
fill: None,
}
}
}
impl Outline {
/// The stroke's ARGB and its width as a multiple of vanilla's; none for vanilla's.
pub fn stroke(&self) -> Option<(u32, f32)> {
self.custom.then(|| {
(
self.colour.with_opacity(self.opacity),
f32::from(self.thickness),
)
})
}
/// The fill's ARGB, when there is one.
pub fn fill(&self) -> Option<u32> {
self.fill
.filter(|_| self.custom)
.map(|c| c.with_opacity(FILL_OPACITY))
}
}
/// The tint a hurt entity flashes (`OverlayTexture`'s red rows). Off, vanilla's: red over 30%.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Hit {
pub custom: bool,
pub colour: Colour,
/// How much of the colour covers the entity, in percent.
pub opacity: u8,
}
pub const HIT_OPACITIES: (u8, u8) = (0, 100);
impl Default for Hit {
fn default() -> Self {
Self {
custom: false,
colour: Colour::Red,
opacity: 30,
}
}
}
impl Hit {
/// The overlay texel as RGBA bytes in a little-endian u32 (WGSL's `unpack4x8unorm`), its alpha
/// what of the entity's own colour stays (`entity.fsh` mixes by it); 0 for vanilla's texel.
pub fn texel(&self) -> u32 {
if !self.custom {
return 0;
}
let [_, r, g, b] = self.colour.argb().to_be_bytes();
u32::from_le_bytes([r, g, b, (255 - alpha(self.opacity)) as u8])
}
}
/// What the renderer reads of these options each frame (`FrameInputs::pvp`); the default is
/// vanilla's look.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Frame {
/// The block outline's stroke ([`Outline::stroke`]).
pub outline: Option<(u32, f32)>,
/// The hurt overlay texel ([`Hit::texel`]), 0 for vanilla's.
pub hurt_texel: u32,
/// The glint's colour ([`Tweaks::glint_texel`]), 0 for vanilla's.
pub glint: u32,
}
impl Pvp {
pub fn frame(&self) -> Frame {
Frame {
outline: self.outline.stroke(),
hurt_texel: self.hit.texel(),
glint: self.tweaks.glint_texel(),
}
}
}
/// The HUD widgets (`pvp_hud`), each in a corner of its own.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Hud {
/// The armour worn, head first, with each piece's durability.
pub armour: bool,
pub armour_corner: Corner,
pub armour_percent: bool,
/// The active effects: icon, name and level, time left. They replace vanilla's icons.
pub effects: bool,
pub effects_corner: Corner,
/// How many of the held item the inventory holds (arrows with a bow or crossbow).
pub held: bool,
pub held_corner: Corner,
/// The latency the server reports for this player (the tab list's bars), in multiplayer.
pub ping: bool,
pub ping_corner: Corner,
/// The block position and the direction faced, as F3 shows them; hidden when F3 is
/// reduced (`reducedDebugInfo`).
pub coords: bool,
pub coords_corner: Corner,
/// The server's address, in multiplayer.
pub server: bool,
pub server_corner: Corner,
/// Hits in a row the server confirmed, until this player is hurt or 3 seconds pass.
pub combo: bool,
pub combo_corner: Corner,
/// A dark box behind each widget.
pub background: bool,
}
impl Default for Hud {
fn default() -> Self {
Self {
armour: false,
armour_corner: Corner::BottomRight,
armour_percent: false,
effects: false,
effects_corner: Corner::TopRight,
held: false,
held_corner: Corner::BottomRight,
ping: false,
ping_corner: Corner::TopLeft,
coords: false,
coords_corner: Corner::TopLeft,
server: false,
server_corner: Corner::TopLeft,
combo: false,
combo_corner: Corner::TopLeft,
background: true,
}
}
}
pub use crate::extras::Corner;
/// The value of the view options while they follow the world.
fn world_label() -> String {
crate::lang::get_or("horizon.pvp.view.world", "World's")
}
fn corner_label(c: Corner) -> String {
crate::lang::get_or(&format!("horizon.pvp.corner.{}", c.name()), c.label())
}
/// Smaller changes to what vanilla shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Tweaks {
/// Enchantment levels as numbers in tooltips ("Sharpness 5").
pub numeric_enchants: bool,
/// How far down the first-person fire overlay moves, in percent of its height (0: vanilla).
pub fire_lower: u8,
/// The first-person shield's size, in percent (100: vanilla).
pub shield_size: u8,
/// The totem animation's size, in percent (100: vanilla).
pub totem_size: u8,
/// The enchantment glint's colour; `None` is vanilla's purple.
pub glint: Option<Colour>,
/// Water fog as after 30 seconds under water (vanilla's clearest), from the first moment.
pub clear_water: bool,
/// Lava fog as with Fire Resistance (vanilla's clearest), without the effect.
pub clear_lava: bool,
/// The time of day this player sees, `None` for the world's. Never sent to the server.
pub time: Option<ViewTime>,
/// The weather this player sees, `None` for the world's. Never sent to the server.
pub weather: Option<ViewWeather>,
}
impl Default for Tweaks {
fn default() -> Self {
Self {
numeric_enchants: false,
fire_lower: 0,
shield_size: 100,
totem_size: 100,
glint: None,
clear_water: false,
clear_lava: false,
time: None,
weather: None,
}
}
}
pub const FIRE_LOWER: (u8, u8) = (0, 80);
pub const SHIELD_SIZES: (u8, u8) = (50, 100);
pub const TOTEM_SIZES: (u8, u8) = (25, 100);
impl Tweaks {
/// `colour` as the glint shaders read it: `0` for vanilla's, else `0xFF` alpha over RGB in
/// little-endian bytes (`unpack4x8unorm`'s order).
pub fn glint_texel(&self) -> u32 {
self.glint.map_or(0, |c| {
let [_, r, g, b] = c.argb().to_be_bytes();
u32::from_le_bytes([r, g, b, 255])
})
}
}
/// Chat options.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ChatOptions {
/// The local time before each message.
pub timestamps: bool,
/// A message repeated in a row shows once with its count.
pub compact: bool,
/// A click on a message (where it has no click action) copies it.
pub copy: bool,
/// Ctrl+F in the chat searches the chat's messages.
pub search: bool,
}
impl ChatOptions {
/// What `chat::Chat` lays its lines out with.
pub fn qol(&self) -> crate::chat::ChatQol {
crate::chat::ChatQol {
timestamps: self.timestamps,
compact: self.compact,
}
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Pvp {
pub crosshair: Crosshair,
pub outline: Outline,
pub hit: Hit,
pub hud: Hud,
pub tweaks: Tweaks,
pub chat: ChatOptions,
}
fn flag(value: &str, d: bool) -> bool {
match value {
@@ -357,6 +705,8 @@ impl Pvp {
/// One `key:value` line of `horizon-extras.txt`; false when the key isn't a PvP one.
pub fn parse_line(&mut self, key: &str, value: &str) -> bool {
let c = &mut self.crosshair;
let (o, h, u) = (&mut self.outline, &mut self.hit, &mut self.hud);
let t = &mut self.tweaks;
match key {
"crosshair" => c.custom = flag(value, c.custom),
"crosshairShape" => c.shape = Shape::parse(value).unwrap_or(c.shape),
@@ -372,6 +722,63 @@ impl Pvp {
v => Colour::parse(v).or(c.hover),
}
}
"outline" => o.custom = flag(value, o.custom),
"outlineColour" => o.colour = Colour::parse(value).unwrap_or(o.colour),
"outlineOpacity" => o.opacity = num(value, OPACITIES, o.opacity),
"outlineThickness" => o.thickness = num(value, OUTLINE_THICKNESSES, o.thickness),
"outlineFill" => {
o.fill = match value {
"off" => None,
v => Colour::parse(v).or(o.fill),
}
}
"hit" => h.custom = flag(value, h.custom),
"hitColour" => h.colour = Colour::parse(value).unwrap_or(h.colour),
"hitOpacity" => h.opacity = num(value, HIT_OPACITIES, h.opacity),
"hudArmour" => u.armour = flag(value, u.armour),
"hudArmourCorner" => u.armour_corner = Corner::parse(value, u.armour_corner),
"hudArmourPercent" => u.armour_percent = flag(value, u.armour_percent),
"hudEffects" => u.effects = flag(value, u.effects),
"hudEffectsCorner" => u.effects_corner = Corner::parse(value, u.effects_corner),
"hudHeld" => u.held = flag(value, u.held),
"hudHeldCorner" => u.held_corner = Corner::parse(value, u.held_corner),
"hudBackground" => u.background = flag(value, u.background),
"hudPing" => u.ping = flag(value, u.ping),
"hudPingCorner" => u.ping_corner = Corner::parse(value, u.ping_corner),
"hudCoords" => u.coords = flag(value, u.coords),
"hudCoordsCorner" => u.coords_corner = Corner::parse(value, u.coords_corner),
"hudServer" => u.server = flag(value, u.server),
"hudServerCorner" => u.server_corner = Corner::parse(value, u.server_corner),
"hudCombo" => u.combo = flag(value, u.combo),
"hudComboCorner" => u.combo_corner = Corner::parse(value, u.combo_corner),
"numericEnchants" => t.numeric_enchants = flag(value, t.numeric_enchants),
"lowFire" => t.fire_lower = num(value, FIRE_LOWER, t.fire_lower),
"shieldSize" => t.shield_size = num(value, SHIELD_SIZES, t.shield_size),
"totemSize" => t.totem_size = num(value, TOTEM_SIZES, t.totem_size),
"glintColour" => {
t.glint = match value {
"off" => None,
v => Colour::parse(v).or(t.glint),
}
}
"clearWater" => t.clear_water = flag(value, t.clear_water),
"clearLava" => t.clear_lava = flag(value, t.clear_lava),
"chatTimestamps" => self.chat.timestamps = flag(value, self.chat.timestamps),
"chatCompact" => self.chat.compact = flag(value, self.chat.compact),
"chatCopy" => self.chat.copy = flag(value, self.chat.copy),
"chatSearch" => self.chat.search = flag(value, self.chat.search),
"viewTime" => {
t.time = match value {
"off" => None,
v => ViewTime::parse(v).or(t.time),
}
}
"viewWeather" => {
t.weather = match value {
"off" => None,
v => ViewWeather::parse(v).or(t.weather),
}
}
_ => return false,
}
true
@@ -393,6 +800,91 @@ impl Pvp {
c.dot,
c.hover.map_or("off", Colour::name),
);
let (o, h) = (&self.outline, &self.hit);
let _ = write!(
out,
"outline:{}\noutlineColour:{}\noutlineOpacity:{}\noutlineThickness:{}\noutlineFill:{}\nhit:{}\nhitColour:{}\nhitOpacity:{}\n",
o.custom,
o.colour.name(),
o.opacity,
o.thickness,
o.fill.map_or("off", Colour::name),
h.custom,
h.colour.name(),
h.opacity,
);
let u = &self.hud;
let _ = write!(
out,
"hudArmour:{}\nhudArmourCorner:{}\nhudArmourPercent:{}\nhudEffects:{}\nhudEffectsCorner:{}\nhudHeld:{}\nhudHeldCorner:{}\nhudBackground:{}\n",
u.armour,
u.armour_corner.name(),
u.armour_percent,
u.effects,
u.effects_corner.name(),
u.held,
u.held_corner.name(),
u.background,
);
let _ = write!(
out,
"hudPing:{}\nhudPingCorner:{}\nhudCoords:{}\nhudCoordsCorner:{}\nhudServer:{}\nhudServerCorner:{}\nhudCombo:{}\nhudComboCorner:{}\n",
u.ping,
u.ping_corner.name(),
u.coords,
u.coords_corner.name(),
u.server,
u.server_corner.name(),
u.combo,
u.combo_corner.name(),
);
let t = &self.tweaks;
let _ = write!(
out,
"numericEnchants:{}\nlowFire:{}\nshieldSize:{}\ntotemSize:{}\nglintColour:{}\nclearWater:{}\nclearLava:{}\n",
t.numeric_enchants,
t.fire_lower,
t.shield_size,
t.totem_size,
t.glint.map_or("off", Colour::name),
t.clear_water,
t.clear_lava,
);
let _ = write!(
out,
"viewTime:{}\nviewWeather:{}\n",
t.time.map_or("off", ViewTime::name),
t.weather.map_or("off", ViewWeather::name),
);
let c = &self.chat;
let _ = write!(
out,
"chatTimestamps:{}\nchatCompact:{}\nchatCopy:{}\nchatSearch:{}\n",
c.timestamps, c.compact, c.copy, c.search,
);
}
}
/// The official mods the PvP preset turns on when they are installed: the FPS counter and the
/// keystrokes with clicks per second.
pub const PRESET_MODS: [&str; 2] = ["fps_overlay", "keystrokes"];
/// Fire overlay's place under the PvP preset (percent of its height down).
const PRESET_FIRE_LOWER: u8 = 40;
/// The PvP preset: the custom crosshair, the armour and effects widgets, vanilla's toggle sprint
/// and toggle sneak, lower fire, and the FPS and keystrokes mods when installed. It only turns
/// things on; each keeps its own settings, so it can be undone option by option.
pub fn apply_preset(s: &mut crate::settings::Settings) {
let p = &mut s.extras.pvp;
p.crosshair.custom = true;
p.hud.armour = true;
p.hud.effects = true;
if p.tweaks.fire_lower == 0 {
p.tweaks.fire_lower = PRESET_FIRE_LOWER;
}
s.toggle_sprint = true;
s.toggle_crouch = true;
for id in PRESET_MODS {
s.mods.turn_on(id);
}
}
@@ -433,6 +925,61 @@ opts! {
CrosshairDot, "crosshair_dot", "Centre dot", "A dot in the middle of the crosshair.";
CrosshairHover, "crosshair_hover", "Colour on entities",
"Another colour while the crosshair is over a mob or a player you could hit, as vanilla's attack indicator knows it.";
Outline, "outline", "Custom block outline", "Changes the outline around the block you look at.";
OutlineColour, "outline_colour", "Colour", "The outline's colour. Black at 40% is vanilla's.";
OutlineOpacity, "outline_opacity", "Opacity", "How solid the outline is.";
OutlineThickness, "outline_thickness", "Thickness", "The outline's width, in times vanilla's.";
OutlineFill, "outline_fill", "Fill", "Tints the faces of the block you look at, lightly.";
Hit, "hit", "Custom hit colour",
"Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.";
HitColour, "hit_colour", "Colour", "The colour hurt mobs and players flash.";
HitOpacity, "hit_opacity", "Opacity", "How much of the colour covers them.";
HudArmour, "hud_armour", "Armour",
"The armour you wear, head first, with how many uses each piece has left.";
HudArmourCorner, "hud_armour_corner", "Armour corner", "Where the armour shows.";
HudArmourPercent, "hud_armour_percent", "Durability in %",
"Uses left as a percent of the piece's maximum instead of a number.";
HudEffects, "hud_effects", "Effects",
"Your effects with their names, levels and time left, in place of the icons at the top right.";
HudEffectsCorner, "hud_effects_corner", "Effects corner", "Where the effects show.";
HudHeld, "hud_held", "Held item count",
"How many of the item in your hand your inventory holds; arrows with a bow or crossbow.";
HudHeldCorner, "hud_held_corner", "Held count corner", "Where the count shows.";
HudPing, "hud_ping", "Ping", "The latency the server reports for you, in milliseconds. Multiplayer only.";
HudPingCorner, "hud_ping_corner", "Ping corner", "Where the ping shows.";
HudCoords, "hud_coords", "Coordinates",
"Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.";
HudCoordsCorner, "hud_coords_corner", "Coordinates corner", "Where the coordinates show.";
HudServer, "hud_server", "Server address", "The address of the server you play on. Multiplayer only.";
HudServerCorner, "hud_server_corner", "Server corner", "Where the address shows.";
HudCombo, "hud_combo", "Combo counter",
"Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.";
HudComboCorner, "hud_combo_corner", "Combo corner", "Where the combo shows.";
HudBackground, "hud_background", "Widget backgrounds", "A dark box behind each widget.";
NumericEnchants, "numeric_enchants", "Enchantment levels as numbers",
"Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.";
FireLower, "fire_lower", "Lower fire",
"Moves the flames you see while burning down, so they cover less of the screen.";
ShieldSize, "shield_size", "Shield size", "The size of the shield in your hand, in first person.";
TotemSize, "totem_size", "Totem animation size",
"The size of the totem that fills the screen when it saves you.";
GlintColour, "glint_colour", "Enchantment glint colour",
"The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.";
ClearWater, "clear_water", "Clear water",
"Under water, see as far as vanilla lets you after 30 seconds there, from the start.";
ClearLava, "clear_lava", "Clear lava",
"In lava, see as far as vanilla's Fire Resistance lets you, without the effect.";
ViewTime, "view_time", "Time of day",
"The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.";
ViewWeather, "view_weather", "Weather",
"The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.";
ChatTimestamps, "chat_timestamps", "Timestamps", "The time each message came in, before it.";
ChatCompact, "chat_compact", "Compact chat",
"A message repeated in a row shows once, with how many times it came: (x3).";
ChatCopy, "chat_copy", "Copy on click",
"Clicking a message in the open chat copies its text, unless the click does something else.";
ChatSearch, "chat_search", "Chat search",
"Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.";
}
/// The PvP & HUD page: headers (language keys) and options.
pub fn rows() -> Vec<Result<Opt, &'static str>> {
@@ -452,14 +999,73 @@ pub fn rows() -> Vec<Result<Opt, &'static str>> {
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.outline"));
rows.extend(
[
Outline,
OutlineColour,
OutlineOpacity,
OutlineThickness,
OutlineFill,
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.hit"));
rows.extend([Hit, HitColour, HitOpacity].map(Ok));
rows.push(Err("horizon.pvp.header.hud"));
rows.extend(
[
HudArmour,
HudArmourCorner,
HudArmourPercent,
HudEffects,
HudEffectsCorner,
HudHeld,
HudHeldCorner,
HudPing,
HudPingCorner,
HudCoords,
HudCoordsCorner,
HudServer,
HudServerCorner,
HudCombo,
HudComboCorner,
HudBackground,
]
.map(Ok),
);
rows.push(Err("horizon.pvp.header.items"));
rows.push(Ok(NumericEnchants));
rows.push(Ok(GlintColour));
rows.push(Err("horizon.pvp.header.visual"));
rows.extend([FireLower, ShieldSize, TotemSize, ClearWater, ClearLava].map(Ok));
rows.push(Err("horizon.pvp.header.view"));
rows.extend([ViewTime, ViewWeather].map(Ok));
rows.push(Err("horizon.pvp.header.chat"));
rows.extend([ChatTimestamps, ChatCompact, ChatCopy, ChatSearch].map(Ok));
rows
}
impl Opt {
/// The switch this option belongs to.
pub fn parent(self) -> Option<Opt> {
use Opt::*;
match self {
Opt::Crosshair => None,
_ => Some(Opt::Crosshair),
Crosshair | Outline | Hit => None,
OutlineColour | OutlineOpacity | OutlineThickness | OutlineFill => Some(Outline),
HitColour | HitOpacity => Some(Hit),
HudArmour | HudEffects | HudHeld | HudPing | HudCoords | HudServer | HudCombo
| HudBackground | NumericEnchants => None,
FireLower | ShieldSize | TotemSize | GlintColour | ClearWater | ClearLava => None,
ViewTime | ViewWeather => None,
ChatTimestamps | ChatCompact | ChatCopy | ChatSearch => None,
HudPingCorner => Some(HudPing),
HudCoordsCorner => Some(HudCoords),
HudServerCorner => Some(HudServer),
HudComboCorner => Some(HudCombo),
HudArmourCorner | HudArmourPercent => Some(HudArmour),
HudEffectsCorner => Some(HudEffects),
HudHeldCorner => Some(HudHeld),
_ => Some(Crosshair),
}
}
pub fn switch(self, p: &Pvp) -> Option<bool> {
@@ -468,6 +1074,24 @@ impl Opt {
Opt::Crosshair => c.custom,
Opt::CrosshairOutline => c.outline,
Opt::CrosshairDot => c.dot,
Opt::Outline => p.outline.custom,
Opt::Hit => p.hit.custom,
Opt::HudArmour => p.hud.armour,
Opt::HudArmourPercent => p.hud.armour_percent,
Opt::HudEffects => p.hud.effects,
Opt::HudHeld => p.hud.held,
Opt::HudBackground => p.hud.background,
Opt::HudPing => p.hud.ping,
Opt::HudCoords => p.hud.coords,
Opt::HudServer => p.hud.server,
Opt::HudCombo => p.hud.combo,
Opt::NumericEnchants => p.tweaks.numeric_enchants,
Opt::ClearWater => p.tweaks.clear_water,
Opt::ClearLava => p.tweaks.clear_lava,
Opt::ChatTimestamps => p.chat.timestamps,
Opt::ChatCompact => p.chat.compact,
Opt::ChatCopy => p.chat.copy,
Opt::ChatSearch => p.chat.search,
_ => return None,
})
}
@@ -484,12 +1108,53 @@ impl Opt {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::OutlineColour => p.outline.colour.label(),
Opt::OutlineOpacity => format!("{}%", p.outline.opacity),
Opt::OutlineThickness => format!("{}x", p.outline.thickness),
Opt::OutlineFill => match p.outline.fill {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::HitColour => p.hit.colour.label(),
Opt::HitOpacity => format!("{}%", p.hit.opacity),
Opt::HudArmourCorner => corner_label(p.hud.armour_corner),
Opt::HudEffectsCorner => corner_label(p.hud.effects_corner),
Opt::HudHeldCorner => corner_label(p.hud.held_corner),
Opt::HudPingCorner => corner_label(p.hud.ping_corner),
Opt::HudCoordsCorner => corner_label(p.hud.coords_corner),
Opt::HudServerCorner => corner_label(p.hud.server_corner),
Opt::HudComboCorner => corner_label(p.hud.combo_corner),
Opt::FireLower => match p.tweaks.fire_lower {
0 => crate::lang::get_or("Off", "Off"),
v => format!("{v}%"),
},
Opt::ShieldSize => format!("{}%", p.tweaks.shield_size),
Opt::TotemSize => format!("{}%", p.tweaks.totem_size),
Opt::GlintColour => match p.tweaks.glint {
Some(colour) => colour.label(),
None => crate::lang::get_or("Off", "Off"),
},
Opt::ViewTime => p.tweaks.time.map_or_else(world_label, ViewTime::label),
Opt::ViewWeather => p
.tweaks
.weather
.map_or_else(world_label, ViewWeather::label),
_ => return None,
})
}
/// A click; `back` (right button, wheel up) goes the other way.
pub fn cycle(self, p: &mut Pvp, back: bool) {
let c = &mut p.crosshair;
let (o, h, u) = (&mut p.outline, &mut p.hit, &mut p.hud);
let t = &mut p.tweaks;
let corner = |c: Corner| step(&Corner::ALL, c, back);
// Off, then every solid colour.
let solid_or_off = |v: Option<Colour>| {
let all = [None]
.into_iter()
.chain(Colour::ALL[1..].iter().copied().map(Some));
step(&all.collect::<Vec<_>>(), v, back)
};
match self {
Opt::Crosshair => c.custom = !c.custom,
Opt::CrosshairShape => c.shape = step(&Shape::ALL, c.shape, back),
@@ -508,6 +1173,55 @@ impl Opt {
.collect();
c.hover = step(&all, c.hover, back);
}
Opt::Outline => o.custom = !o.custom,
Opt::OutlineColour => o.colour = o.colour.step_solid(back),
Opt::OutlineOpacity => o.opacity = step_by(o.opacity, OPACITIES, 10, back),
Opt::OutlineThickness => {
o.thickness = step_by(o.thickness, OUTLINE_THICKNESSES, 1, back)
}
Opt::OutlineFill => o.fill = solid_or_off(o.fill),
Opt::Hit => h.custom = !h.custom,
Opt::HitColour => h.colour = h.colour.step_solid(back),
Opt::HitOpacity => h.opacity = step_by(h.opacity, HIT_OPACITIES, 10, back),
Opt::HudArmour => u.armour = !u.armour,
Opt::HudArmourCorner => u.armour_corner = corner(u.armour_corner),
Opt::HudArmourPercent => u.armour_percent = !u.armour_percent,
Opt::HudEffects => u.effects = !u.effects,
Opt::HudEffectsCorner => u.effects_corner = corner(u.effects_corner),
Opt::HudHeld => u.held = !u.held,
Opt::HudHeldCorner => u.held_corner = corner(u.held_corner),
Opt::HudBackground => u.background = !u.background,
Opt::HudPing => u.ping = !u.ping,
Opt::HudPingCorner => u.ping_corner = corner(u.ping_corner),
Opt::HudCoords => u.coords = !u.coords,
Opt::HudCoordsCorner => u.coords_corner = corner(u.coords_corner),
Opt::HudServer => u.server = !u.server,
Opt::HudServerCorner => u.server_corner = corner(u.server_corner),
Opt::HudCombo => u.combo = !u.combo,
Opt::HudComboCorner => u.combo_corner = corner(u.combo_corner),
Opt::NumericEnchants => p.tweaks.numeric_enchants = !p.tweaks.numeric_enchants,
Opt::FireLower => t.fire_lower = step_by(t.fire_lower, FIRE_LOWER, 10, back),
Opt::ShieldSize => t.shield_size = step_by(t.shield_size, SHIELD_SIZES, 10, back),
Opt::TotemSize => t.totem_size = step_by(t.totem_size, TOTEM_SIZES, 25, back),
Opt::GlintColour => t.glint = solid_or_off(t.glint),
Opt::ClearWater => t.clear_water = !t.clear_water,
Opt::ClearLava => t.clear_lava = !t.clear_lava,
Opt::ViewTime => {
let all: Vec<_> = std::iter::once(None)
.chain(ViewTime::ALL.map(Some))
.collect();
t.time = step(&all, t.time, back);
}
Opt::ChatTimestamps => p.chat.timestamps = !p.chat.timestamps,
Opt::ChatCompact => p.chat.compact = !p.chat.compact,
Opt::ChatCopy => p.chat.copy = !p.chat.copy,
Opt::ChatSearch => p.chat.search = !p.chat.search,
Opt::ViewWeather => {
let all: Vec<_> = std::iter::once(None)
.chain(ViewWeather::ALL.map(Some))
.collect();
t.weather = step(&all, t.weather, back);
}
}
}
}
@@ -645,6 +1359,17 @@ mod tests {
assert_eq!(p.crosshair.shape, Shape::T);
assert_eq!(p.crosshair.size, 8);
assert_eq!(p.crosshair.hover, Some(Colour::Inverted));
// From vanilla's values, one click: the overlays' sizes wrap to their smallest.
let t = p.tweaks;
assert_eq!((t.fire_lower, t.shield_size, t.totem_size), (10, 50, 25));
assert_eq!(t.glint, Some(Colour::White));
assert!(t.clear_water && t.clear_lava);
assert_eq!(
(t.time, t.weather),
(Some(ViewTime::Morning), Some(ViewWeather::Clear))
);
let c = p.chat;
assert!(c.timestamps && c.compact && c.copy && c.search);
let mut text = String::new();
p.serialize(&mut text);
let mut read = Pvp::default();
@@ -662,6 +1387,107 @@ mod tests {
assert!(p.parse_line("crosshairSize", "99") && p.crosshair.size == SIZES.1);
assert!(p.parse_line("crosshairColour", "plaid") && p.crosshair.colour == Colour::White);
assert!(!p.parse_line("fullbright", "true"));
assert!(p.parse_line("totemSize", "5") && p.tweaks.totem_size == TOTEM_SIZES.0);
}
#[test]
fn the_pvp_preset_turns_its_options_on_and_keeps_their_settings() {
use crate::mod_list::{Installed, NativePage};
let mut s = crate::settings::Settings::default();
s.extras.pvp.crosshair.colour = Colour::Green;
s.extras.pvp.tweaks.fire_lower = 60;
let installed = |id: &str| Installed {
id: id.into(),
title: id.into(),
description: String::new(),
problem: None,
dev: false,
native: Some(NativePage::default()),
};
s.mods.installed = vec![installed("fps_overlay"), installed("zoom")];
s.mods.disabled.insert("fps_overlay".into());
apply_preset(&mut s);
let p = &s.extras.pvp;
assert!(p.crosshair.custom && p.hud.armour && p.hud.effects);
assert!(s.toggle_sprint && s.toggle_crouch);
// What the player had chosen stays: the crosshair's colour, a lower fire already set.
assert_eq!(p.crosshair.colour, Colour::Green);
assert_eq!(p.tweaks.fire_lower, 60);
// The FPS counter the player had turned off is on; keystrokes isn't installed: nothing.
assert!(!s.mods.disabled.contains("fps_overlay"));
assert!(s.mods.enabled.contains("fps_overlay") && !s.mods.enabled.contains("keystrokes"));
let mut fresh = crate::settings::Settings::default();
apply_preset(&mut fresh);
assert_eq!(fresh.extras.pvp.tweaks.fire_lower, PRESET_FIRE_LOWER);
}
#[test]
fn a_fixed_time_keeps_the_worlds_day() {
// Day 9 at 5000: the moon's phase (the day number) stays, the time of day is the chosen.
let world = 9 * 24000 + 5000;
assert_eq!(view_day_time(world, None), world);
assert_eq!(
view_day_time(world, Some(ViewTime::Midnight)),
9 * 24000 + 18000
);
assert_eq!(view_day_time(-100, Some(ViewTime::Noon)), -24000 + 6000);
}
#[test]
fn the_glint_colour_reaches_the_shaders_as_rgb_bytes() {
let mut t = Tweaks::default();
assert_eq!(t.glint_texel(), 0, "vanilla's glint");
t.glint = Some(Colour::Red);
// unpack4x8unorm reads the low byte first: r = 1, g = 0, b = 0, a = 1.
assert_eq!(t.glint_texel().to_le_bytes(), [255, 0, 0, 255]);
}
#[test]
fn outline_and_hit_colours_default_to_vanillas() {
// Turned on with their first settings, they give vanilla's colours: the outline's
// 0x66000000 (LevelRenderer) and OverlayTexture's hurt texel 0xB2FF0000.
let o = Outline {
custom: true,
..Default::default()
};
assert_eq!(o.stroke(), Some((0x6600_0000, 1.0)));
assert_eq!(o.fill(), None);
let [r, g, b, a] = crate::overlay::HURT_TEXEL;
let hit = Hit {
custom: true,
..Default::default()
};
assert_eq!(hit.texel(), u32::from_le_bytes([r, g, b, a]));
assert_eq!(
(Outline::default().stroke(), Hit::default().texel()),
(None, 0)
);
// Fully opaque cyan covers the entity entirely: none of its own colour stays.
let cyan = Hit {
custom: true,
colour: Colour::Cyan,
opacity: 100,
};
assert_eq!(cyan.texel().to_le_bytes(), [0x40, 0xFF, 0xFF, 0]);
let mut p = Pvp::default();
Opt::Outline.cycle(&mut p, false);
Opt::OutlineFill.cycle(&mut p, false);
assert_eq!(p.outline.fill(), Some(0x40FF_FFFF), "white at 25%");
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
Opt::OutlineOpacity.cycle(&mut p, true);
assert_eq!(p.outline.opacity, 100, "down from 10% wraps to 100%");
Opt::OutlineColour.cycle(&mut p, true);
assert_eq!(p.outline.colour, Colour::Magenta, "never Inverted");
let mut text = String::new();
p.serialize(&mut text);
let mut read = Pvp::default();
for line in text.lines() {
let (k, v) = line.split_once(':').unwrap();
assert!(read.parse_line(k, v), "{k}");
}
assert_eq!(read, p);
}
#[test]
@@ -678,9 +1504,17 @@ mod tests {
for header in rows().into_iter().filter_map(Result::err) {
assert!(en.get(header).is_some(), "{header}");
}
for c in Corner::ALL {
let key = format!("horizon.pvp.corner.{}", c.name());
assert_eq!(en.get(&key), Some(c.label()), "{key}");
}
let names = Colour::ALL.map(|c| (format!("horizon.pvp.colour.{}", c.name()), c.english()));
let shapes = Shape::ALL.map(|c| (format!("horizon.pvp.shape.{}", c.name()), c.english()));
for (key, english) in names.iter().chain(&shapes) {
let times = ViewTime::ALL.map(|c| (format!("horizon.pvp.time.{}", c.name()), c.english()));
let weathers =
ViewWeather::ALL.map(|c| (format!("horizon.pvp.weather.{}", c.name()), c.english()));
assert_eq!(en.get("horizon.pvp.view.world"), Some("World's"));
for (key, english) in names.iter().chain(&shapes).chain(&times).chain(&weathers) {
assert_eq!(en.get(key), Some(*english), "{key}");
}
}
+341
View File
@@ -0,0 +1,341 @@
//! The PvP & HUD page's HUD widgets (`docs/pvp.md`): boxes of lines in the screen corners,
//! placed by `extras_hud::Corners` beside mods' boxes. Each widget keeps its lines, their
//! text and widths, and rebuilds them only when what it shows changes, so a frame that
//! changes nothing allocates nothing.
use crate::text::Text;
/// The icon at a line's left: an item the game draws, or an atlas sprite.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Icon {
None,
/// The armour slot (`Game::armor`: 0 head to 3 feet).
Armour(u8),
/// An inventory slot's item (`OFFHAND_SLOT` for the off hand), drawn without its count.
Slot(usize),
/// A sprite by its atlas name (`mob_effect/speed`), 18 pixels square.
Sprite(String),
}
impl Icon {
pub fn size(&self) -> f32 {
match self {
Icon::None => 0.0,
Icon::Armour(_) | Icon::Slot(_) => 16.0,
Icon::Sprite(_) => 18.0,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Line {
pub icon: Icon,
pub text: Text,
/// A second, grey text under the first (an effect's time left).
pub detail: Option<Text>,
/// The widest of its texts, in GUI pixels.
pub text_width: f32,
pub colour: u32,
}
/// Padding inside a widget's box.
pub const PAD: f32 = 2.0;
const TEXT_HEIGHT: f32 = 8.0;
/// Space between an icon and its text.
const ICON_GAP: f32 = 2.0;
impl Line {
pub fn new(icon: Icon, text: String, width: &dyn Fn(&str) -> f32) -> Self {
Self {
icon,
text_width: width(&text),
text: Text::literal(text),
detail: None,
colour: 0xFFFF_FFFF,
}
}
pub fn with_detail(mut self, detail: String, width: &dyn Fn(&str) -> f32) -> Self {
self.text_width = self.text_width.max(width(&detail));
self.detail = Some(Text::literal(detail));
self
}
pub fn height(&self) -> f32 {
let text = if self.detail.is_some() {
2.0 * TEXT_HEIGHT + 2.0
} else {
TEXT_HEIGHT
};
text.max(self.icon.size()) + 2.0
}
/// Where the text starts, from the line's left.
pub fn text_x(&self, icon_column: f32) -> f32 {
if icon_column > 0.0 {
icon_column + ICON_GAP
} else {
0.0
}
}
}
/// One widget's lines, rebuilt from `K` (what they show) only when it changes. The caller
/// fills [`Widget::next`] each frame (reusing its storage), then calls [`Widget::refresh`].
#[derive(Default)]
pub struct Widget<K> {
shown: Option<K>,
/// What the widget shows this frame, filled by the caller.
pub next: K,
pub lines: Vec<Line>,
/// The box's size, padding included.
pub size: (f32, f32),
}
impl<K: PartialEq + Default> Widget<K> {
/// Rebuilds the lines with `build` when `next` differs from what they show; true if so.
pub fn refresh(&mut self, build: impl FnOnce(&K, &mut Vec<Line>)) -> bool {
if self.shown.as_ref() == Some(&self.next) {
return false;
}
self.lines.clear();
build(&self.next, &mut self.lines);
self.size = size(&self.lines);
let next = std::mem::take(&mut self.next);
// The old key's storage comes back as `next` for the following frames.
self.next = self.shown.replace(next).unwrap_or_default();
true
}
/// Forget the lines (the widget was turned off).
pub fn clear(&mut self) {
self.shown = None;
self.lines.clear();
self.size = (0.0, 0.0);
}
}
/// The width of the icons' column: the widest icon of the lines.
pub fn icon_column(lines: &[Line]) -> f32 {
lines.iter().map(|l| l.icon.size()).fold(0.0, f32::max)
}
/// A box around `lines`, one under the other.
pub fn size(lines: &[Line]) -> (f32, f32) {
if lines.is_empty() {
return (0.0, 0.0);
}
let icons = icon_column(lines);
let width = lines
.iter()
.map(|l| l.text_x(icons) + l.text_width)
.fold(0.0, f32::max);
let height = lines.iter().map(Line::height).sum::<f32>();
(width + 2.0 * PAD, height + 2.0 * PAD - 2.0)
}
/// How armour durability shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Durability {
/// Uses left.
Number,
/// Uses left in percent of the item's maximum, rounded.
Percent,
}
/// An item's durability text: none for an item without durability.
pub fn durability(damage: u32, max: u32, mode: Durability) -> Option<String> {
if max == 0 {
return None;
}
let left = max.saturating_sub(damage);
Some(match mode {
Durability::Number => left.to_string(),
Durability::Percent => format!("{}%", (left * 100 + max / 2) / max),
})
}
/// The colour of a durability text: `ItemStack.getBarColor`'s hue, green full to red empty.
pub fn durability_colour(damage: u32, max: u32) -> u32 {
if max == 0 {
return 0xFFFF_FFFF;
}
let left = max.saturating_sub(damage) as f32 / max as f32;
// Mth.hsvToRgb(f / 3, 1, 1): from red (0) through yellow to green (1/3).
let (r, g) = if left >= 0.5 {
((2.0 - 2.0 * left) * 255.0, 255.0)
} else {
(255.0, 2.0 * left * 255.0)
};
0xFF00_0000 | (r as u32) << 16 | (g as u32) << 8
}
/// `Direction.fromYRot`: the horizontal direction a yaw faces, as F3's "Facing" names it.
pub fn facing(yaw: f32) -> &'static str {
match (f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3 {
0 => "south",
1 => "west",
2 => "north",
_ => "east",
}
}
/// The combo counter: hits in a row on what this player attacked, each counted when the
/// server reports that entity hurt (`ClientboundDamageEventPacket`) soon after the attack;
/// back to 0 when this player is hurt or no hit lands for [`Combo::EXPIRE`] seconds.
#[derive(Default)]
pub struct Combo {
count: u32,
/// The entity attacked last and when.
target: Option<(i32, f64)>,
last_hit: f64,
}
impl Combo {
/// How long after an attack the server's hurt report still counts it.
pub const CONFIRM: f64 = 1.0;
pub const EXPIRE: f64 = 3.0;
pub fn attacked(&mut self, id: i32, now: f64) {
self.target = Some((id, now));
}
/// The server reports entity `id` hurt. `by_me`: whether this player is
/// the damage's direct entity, when the report names one (melee and
/// spear hits do; Horizon's LAN hub names none for player victims). A
/// report that names nobody counts only as the answer to the last swing.
pub fn hurt(&mut self, id: i32, by_me: Option<bool>, now: f64) {
let confirmed = match by_me {
Some(mine) => mine,
None => self
.target
.is_some_and(|(t, at)| t == id && now - at <= Self::CONFIRM),
};
if confirmed {
self.target = None;
self.count += 1;
self.last_hit = now;
}
}
/// This player was hurt.
pub fn broken(&mut self) {
self.count = 0;
self.target = None;
}
pub fn count(&mut self, now: f64) -> u32 {
if now - self.last_hit > Self::EXPIRE {
self.count = 0;
}
self.count
}
}
#[cfg(test)]
mod tests {
use super::*;
fn width(s: &str) -> f32 {
6.0 * s.chars().count() as f32
}
#[test]
fn durability_shows_uses_left_as_a_number_or_a_percent() {
// A diamond chestplate (528 uses) after 100 hits.
assert_eq!(
durability(100, 528, Durability::Number).as_deref(),
Some("428")
);
assert_eq!(
durability(100, 528, Durability::Percent).as_deref(),
Some("81%")
);
assert_eq!(
durability(0, 528, Durability::Percent).as_deref(),
Some("100%")
);
assert_eq!(
durability(528, 528, Durability::Number).as_deref(),
Some("0")
);
assert_eq!(
durability(0, 0, Durability::Number),
None,
"a pumpkin on the head"
);
assert_eq!(durability_colour(0, 100), 0xFF00_FF00);
assert_eq!(durability_colour(100, 100), 0xFFFF_0000);
assert_eq!(durability_colour(50, 100), 0xFFFF_FF00);
}
#[test]
fn facing_follows_direction_from_y_rot() {
// Yaw 0 looks toward +Z (south); turning right (yaw up) goes west, north, east.
let names: Vec<_> = [0.0, 44.9, 45.0, 90.0, 180.0, -90.0, 270.0, -180.0, 720.0]
.map(facing)
.to_vec();
assert_eq!(
names,
[
"south", "south", "west", "west", "north", "east", "east", "north", "south"
]
);
}
#[test]
fn combos_count_confirmed_hits_until_this_player_is_hurt() {
let mut c = Combo::default();
c.attacked(7, 0.0);
c.hurt(7, None, 0.1);
c.attacked(7, 0.6);
c.hurt(7, None, 0.7);
assert_eq!(c.count(0.8), 2);
// Another entity hurt, or a report too late after the swing, adds nothing.
c.attacked(7, 1.0);
c.hurt(9, None, 1.1);
c.hurt(7, None, 2.5);
assert_eq!(c.count(2.5), 2);
// One report per swing: a second one (thorns, fire) for the same attack doesn't count.
c.attacked(7, 3.0);
c.hurt(7, None, 3.1);
c.hurt(7, None, 3.2);
assert_eq!(c.count(3.2), 3);
c.broken();
assert_eq!(c.count(3.3), 0);
c.attacked(7, 4.0);
c.hurt(7, None, 4.1);
assert_eq!(c.count(7.0), 1);
assert_eq!(c.count(7.2), 0, "more than 3 s since the last hit");
// A report that names who hit decides alone: a spear jab (no swing
// remembered) counts, a teammate's hit right after a swing doesn't.
c.hurt(7, Some(true), 8.0);
c.attacked(7, 8.5);
c.hurt(7, Some(false), 8.6);
assert_eq!(c.count(8.7), 1);
}
#[test]
fn a_widget_rebuilds_only_when_what_it_shows_changes() {
let mut w: Widget<Vec<u32>> = Widget::default();
let mut builds = 0;
for frame in 0..10 {
w.next.clear();
w.next.push(if frame < 5 { 7 } else { 8 });
w.refresh(|k, lines| {
builds += 1;
lines.push(Line::new(Icon::None, k[0].to_string(), &width));
});
}
assert_eq!(builds, 2);
assert_eq!(w.lines[0].text.plain(), "8");
w.clear();
w.next.clear();
w.next.push(8);
assert!(w.refresh(|_, lines| lines.push(Line::new(Icon::None, "x".into(), &width))));
}
#[test]
fn the_box_fits_its_widest_line_and_icons_line_up() {
let lines = [
Line::new(Icon::Armour(3), "428".into(), &width),
Line::new(Icon::None, "12345".into(), &width),
Line::new(
Icon::Sprite("mob_effect/speed".into()),
"Speed II".into(),
&width,
)
.with_detail("01:30".into(), &width),
];
assert_eq!(icon_column(&lines), 18.0);
// Texts start after the 18-pixel column and a 2-pixel gap: the widest is "Speed II"
// (48 pixels).
let (w, h) = size(&lines);
assert_eq!(w, 2.0 + 18.0 + 2.0 + 48.0 + 2.0);
// 18 + 2 for the icon lines (the effect's two texts take 18), 8 + 2 for the text one.
assert_eq!(h, 18.0 + 10.0 + 20.0 + 2.0 * PAD - 2.0);
assert!(size(&[]) == (0.0, 0.0));
}
}
@@ -61,6 +61,9 @@ pub struct ScreenInputs<'a> {
/// Eye in water: (lightmap brightness at the eye, yaw, pitch).
pub water: Option<(f32, f32, f32)>,
pub on_fire: bool,
/// How far the fire overlay moves down, in its own heights (PvP & HUD's lower fire; 0 is
/// vanilla's place).
pub fire_lower: f32,
/// `Hud.extractCameraOverlays` draws: the full-screen blits become
/// screen quads (the spyglass, its fills and the nausea swirl do not).
pub gui_overlays: &'a [GuiDraw],
@@ -201,7 +204,7 @@ impl ScreenEffects {
for (dx, angle) in [(0.24f32, -10.0f32), (-0.24, 10.0)] {
let (s, c) = angle.to_radians().sin_cos();
// rotateY about the quad then translate; z = -0.5 feeds into x.
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3];
let corner = |x: f32, y: f32| [x * c + -0.5 * s + dx, y - 0.3 - input.fire_lower];
let corners = [
corner(-0.5, -0.5),
corner(0.5, -0.5),
@@ -347,6 +350,7 @@ mod tests {
block_overlay: None,
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
};
let quads = e.build(&input);
@@ -401,6 +405,7 @@ mod tests {
block_overlay: Some("block/stone".to_owned()),
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
fire_lower: 0.0,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
@@ -11,7 +11,10 @@
//! backend through `stopped`.
use crate::sound_events::{INTENTIONALLY_EMPTY, Sentinel, Sound, SoundRegistry};
use horizon_core::random::LegacyRandom;
use std::collections::HashMap;
use std::{
collections::HashMap,
sync::{Arc, RwLock},
};
/// `SoundSource` indices (vanilla order).
pub const MASTER: usize = 0;
@@ -41,11 +44,45 @@ pub enum Attenuation {
None,
Linear,
}
/// The object an entity-bound sound follows. Clones retain the same object,
/// even after the world replaces another entity with the same numeric id.
#[derive(Clone, Debug)]
pub struct SoundEntity {
pub id: u32,
state: Arc<RwLock<SoundEntityState>>,
}
#[derive(Clone, Copy, Debug)]
pub struct SoundEntityState {
pub position: [f64; 3],
pub silent: bool,
pub removed: bool,
}
impl SoundEntity {
pub fn new(id: u32, position: [f64; 3], silent: bool) -> Self {
Self {
id,
state: Arc::new(RwLock::new(SoundEntityState {
position,
silent,
removed: false,
})),
}
}
pub fn state(&self) -> SoundEntityState {
*self.state.read().unwrap()
}
pub fn update(&self, state: SoundEntityState) {
*self.state.write().unwrap() = state;
}
}
/// The tickable instance kinds Horizon plays (`TickableSoundInstance`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ticking {
/// Not tickable.
No,
/// `EntityBoundSoundInstance`.
EntityBound,
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
BiomeLoop { fade: i32, direction: i32 },
/// `UnderLiquidAmbientSoundInstance`.
@@ -145,6 +182,8 @@ pub struct SoundInstance {
pub random: LegacyRandom,
pub can_start_silent: bool,
pub ticking: Ticking,
/// A stable entity reference, separate from its reusable network id.
pub bound_entity: Option<SoundEntity>,
/// `AbstractTickableSoundInstance.stopped`.
pub stopped: bool,
/// `sound` after `getOrResolve`, or the sound a caller chose for the
@@ -184,6 +223,7 @@ impl SoundInstance {
random,
can_start_silent: false,
ticking: Ticking::No,
bound_entity: None,
stopped: false,
sound: None,
event: None,
@@ -212,6 +252,27 @@ impl SoundInstance {
false,
)
}
/// `EntityBoundSoundInstance`: initial coordinates are widened floats too.
pub fn entity_bound(
id: &str,
source: usize,
volume: f32,
pitch: f32,
random: LegacyRandom,
entity: SoundEntity,
) -> Self {
let position = entity.state().position.map(|v| f64::from(v as f32));
let mut instance = Self::at(id, source, volume, pitch, random, position);
instance.ticking = Ticking::EntityBound;
instance.bound_entity = Some(entity);
instance
}
/// `EntityBoundSoundInstance.canPlaySound`, also checked on replay.
pub fn can_play_sound(&self) -> bool {
self.bound_entity
.as_ref()
.is_none_or(|entity| !entity.state().silent)
}
/// `SimpleSoundInstance.forUI(sound, pitch, volume)`.
pub fn for_ui(id: &str, pitch: f32, volume: f32, random: LegacyRandom) -> Self {
Self::new(
@@ -551,6 +612,18 @@ impl SoundInstance {
pub fn tick(&mut self, ctx: &TickContext) {
match &mut self.ticking {
Ticking::No | Ticking::MusicLayer => {}
Ticking::EntityBound => {
let entity = self
.bound_entity
.as_ref()
.expect("entity-bound sound target")
.state();
if entity.removed {
self.stop();
} else {
self.position = entity.position.map(|v| f64::from(v as f32));
}
}
Ticking::BiomeLoop { fade, direction } => {
let stop = *fade < 0;
*fade += *direction;
@@ -966,6 +1039,13 @@ impl SoundEngine {
pub fn category_gain(&self, source: usize) -> f32 {
self.gain_by_source[source]
}
/// Live object references used by entity-bound instances, including queued ones.
pub fn bound_entities(&self) -> Vec<SoundEntity> {
self.instances
.values()
.filter_map(|s| s.bound_entity.clone())
.collect()
}
/// `stop(SoundInstance)`.
pub fn stop(&mut self, id: InstanceId, backend: &mut impl Backend) {
if !self.loaded {
@@ -1111,6 +1191,9 @@ impl SoundEngine {
if !self.loaded || !self.instances.contains_key(&id) {
return PlayResult::NotStarted;
}
if !self.instances[&id].can_play_sound() {
return PlayResult::NotStarted;
}
self.resolve(id);
let instance = &self.instances[&id];
let Some(subtitle) = instance.event.clone() else {
@@ -1250,9 +1333,14 @@ impl SoundEngine {
self.play(id, backend);
}
for id in self.ticking.clone() {
let Some(instance) = self.instances.get_mut(&id) else {
let Some(instance) = self.instances.get(&id) else {
continue;
};
if !instance.can_play_sound() {
// SoundEngine still ticks the instance after stopping its channel.
self.stop(id, backend);
}
let instance = self.instances.get_mut(&id).expect("present");
instance.tick(ctx);
if instance.stopped {
self.stop(id, backend);
@@ -1764,3 +1852,66 @@ mod tests {
assert!(underwater.stopped);
}
}
#[cfg(test)]
mod entity_bound_tests {
use super::*;
fn instance(entity: SoundEntity) -> SoundInstance {
SoundInstance::entity_bound(
"minecraft:entity.allay.item_given",
NEUTRAL,
0.7,
1.2,
LegacyRandom::new(42),
entity,
)
}
#[test]
fn an_entity_bound_sound_tracks_float_positions_and_stops_before_the_removed_position() {
let entity = SoundEntity::new(7, [0.123456789, 64.123456789, -3.123456789], false);
let mut sound = instance(entity.clone());
assert_eq!(sound.position[0].to_bits(), 0x3fbf9add40000000);
entity.update(SoundEntityState {
position: [0.248456789, 65.25, -3.623456789],
silent: false,
removed: false,
});
sound.tick(&TickContext::default());
assert_eq!(sound.position[0].to_bits(), 0x3fcfcd6ea0000000);
let last = sound.position;
entity.update(SoundEntityState {
position: [100.0; 3],
silent: false,
removed: true,
});
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_eq!(sound.position, last);
}
#[test]
fn silence_is_read_from_the_entity_including_before_a_replay() {
let entity = SoundEntity::new(7, [0.0; 3], true);
let sound = instance(entity.clone());
assert!(!sound.can_play_sound());
entity.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: false,
});
assert!(sound.can_play_sound());
}
#[test]
fn reusing_a_network_id_does_not_retarget_an_old_sound() {
let old = SoundEntity::new(7, [0.0; 3], false);
let mut sound = instance(old.clone());
old.update(SoundEntityState {
position: [0.0; 3],
silent: false,
removed: true,
});
let replacement = SoundEntity::new(7, [10.0; 3], false);
sound.tick(&TickContext::default());
assert!(sound.stopped);
assert_ne!(sound.position, replacement.state().position);
}
}
+20 -4
View File
@@ -144,8 +144,15 @@ pub fn set_labpbr_maps(on: bool) {
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Budget for either complete normal or specular image set.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Budget for either complete normal or specular image set: a quarter of the block textures'
/// budget, so raising `textureBudgetMiB` sharpens a pack's LabPBR maps along with its colours.
fn map_bytes(texture_budget: u64) -> u64 {
texture_budget / 4
}
/// [`map_bytes`] for the budget the next resource load keeps to.
fn max_map_bytes() -> u64 {
map_bytes(TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed))
}
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
@@ -275,7 +282,7 @@ impl Sprites {
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
side_within(l.min(size), layers, max_map_bytes())
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
@@ -783,7 +790,7 @@ fn pbr_map(
return None;
}
let side = w.max(h);
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
let output_side = side_within(side, LAYERS_AT_LEAST, max_map_bytes()).min(side);
let frames = (0..columns * rows)
.map(|index| {
let (x, y) = (index % columns * w, index / columns * h);
@@ -856,6 +863,15 @@ mod tests {
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn labpbr_maps_follow_the_texture_budget() {
// 1300 layers of 1024 px maps: 64 px at the default budget, 256 px at 1536 MiB.
assert_eq!(
side_within(1024, 1300, map_bytes(DEFAULT_TEXTURE_BUDGET)),
64
);
assert_eq!(side_within(1024, 1300, map_bytes(1536 << 20)), 256);
}
#[test]
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
// Selecting a texel preserves encoded channels, including categorical values.
let frame = [
@@ -429,10 +429,10 @@ fn packed_corner(lo: vec3<u32>, hi: vec3<u32>, corner: u32) -> ArenaCorner {
let step = (start + select(corner, 4u - corner, dir == 1u)) & 3u;
let du = select(0u, 1u, step == 1u || step == 2u);
let dw = select(0u, 1u, step >= 2u);
var pos = block;
pos[axis] = block[axis] + side;
pos[(axis + 1u) % 3u] = block[(axis + 1u) % 3u] + du;
pos[(axis + 2u) % 3u] = block[(axis + 2u) % 3u] + dw;
// No dynamic component stores: FXC (DirectX 12) rejects them (X3500). A component's offset
// from the axis (0: the axis, 1: the next, 2: the one after) picks what it gets.
let rel = (vec3(0u, 1u, 2u) + vec3(3u - axis)) % vec3(3u);
let pos = block + select(select(vec3(dw), vec3(du), rel == vec3(1u)), vec3(side), rel == vec3(0u));
let sel = lo.z >> 24u;
let us = (sel >> (2u * corner)) & 1u;
let vs = (sel >> (2u * corner + 1u)) & 1u;
@@ -512,7 +512,8 @@ fn corner_vertex(c: ArenaCorner, origin: vec4<f32>) -> TerrainOut {
}
@vertex fn vs_section_flat(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
return corner_vertex(s.c, slot_origins[block_slots[s.slot >> 8u]]);
let origin = slot_origins[block_slots[s.slot >> 8u]];
return corner_vertex(s.c, origin);
}
@vertex fn vs_section_flat_opaque(@builtin(vertex_index) vertex: u32) -> TerrainOut {
let s = section_corner(vertex);
+4
View File
@@ -142,6 +142,10 @@ path = "tests/launch_api.rs"
name = "mod_api_hooks"
path = "tests/mod_api_hooks.rs"
[[example]]
name = "native_gui_overlay_probe"
path = "examples/native_gui_overlay_probe.rs"
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
@@ -358,7 +358,7 @@
"horizon.moderation.remaining": "für weitere %s",
"horizon.moderation.mutelist.entry": "%1$s wurde von %2$s %3$s stummgeschaltet",
"horizon.moderation.invalid_duration": "%s ist keine Zeitdauer: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <Spieler> [Grund]",
"horizon.moderation.usage.mute": "/mute <Spieler> [Dauer] [Grund]",
"horizon.moderation.usage.tempmute": "/tempmute <Spieler> <Dauer> [Grund]",
"horizon.moderation.usage.unmute": "/unmute <Spieler>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -369,5 +369,8 @@
"horizon.tempban.usage": "/tempban <Spieler> <Dauer> [Grund]",
"horizon.tempban.invalid_duration": "%s ist keine Zeitdauer: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporäre Sperren benötigen einen dedizierten Server",
"horizon.tempban.success": "%1$s für %2$s, bis %3$s"
"horizon.tempban.success": "%1$s für %2$s, bis %3$s",
"horizon.tempban.list.empty": "Es gibt keine befristeten Sperren",
"horizon.tempban.list.count": "Es gibt %s befristete Sperre(n):",
"horizon.tempban.list.entry": "%1$s wurde von %2$s gesperrt, %3$s (bis %4$s)"
}
@@ -222,6 +222,7 @@
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.realms.not_signed_in": "Sign in with your Microsoft account in the Horizon launcher to play on Realms.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
@@ -536,7 +537,7 @@
"horizon.moderation.remaining": "for another %s",
"horizon.moderation.mutelist.entry": "%1$s was muted by %2$s %3$s",
"horizon.moderation.invalid_duration": "%s isn't a duration: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <player> [reason]",
"horizon.moderation.usage.mute": "/mute <player> [duration] [reason]",
"horizon.moderation.usage.tempmute": "/tempmute <player> <duration> [reason]",
"horizon.moderation.usage.unmute": "/unmute <player>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -548,6 +549,9 @@
"horizon.tempban.invalid_duration": "%s isn't a duration: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Temporary bans need a dedicated server",
"horizon.tempban.success": "%1$s for %2$s, until %3$s",
"horizon.tempban.list.empty": "There are no temporary bans",
"horizon.tempban.list.count": "There are %s temporary ban(s):",
"horizon.tempban.list.entry": "%1$s was banned by %2$s %3$s (until %4$s)",
"horizon.options.pointer_speed": "Menu Pointer Speed",
"horizon.options.scroll_speed": "Menu Scroll Speed",
"horizon.options.repeat_delay": "Navigation Repeat Delay",
@@ -590,5 +594,112 @@
"horizon.pvp.shape.x": "X",
"horizon.pvp.shape.circle": "Circle",
"horizon.pvp.shape.square": "Square",
"horizon.pvp.shape.dot": "Dot"
"horizon.pvp.shape.dot": "Dot",
"horizon.pvp.outline": "Custom block outline",
"horizon.pvp.outline.tooltip": "Changes the outline around the block you look at.",
"horizon.pvp.outline_colour": "Colour",
"horizon.pvp.outline_colour.tooltip": "The outline's colour. Black at 40% is vanilla's.",
"horizon.pvp.outline_opacity": "Opacity",
"horizon.pvp.outline_opacity.tooltip": "How solid the outline is.",
"horizon.pvp.outline_thickness": "Thickness",
"horizon.pvp.outline_thickness.tooltip": "The outline's width, in times vanilla's.",
"horizon.pvp.outline_fill": "Fill",
"horizon.pvp.outline_fill.tooltip": "Tints the faces of the block you look at, lightly.",
"horizon.pvp.hit": "Custom hit colour",
"horizon.pvp.hit.tooltip": "Changes the colour mobs and players flash when they are hurt. Red at 30% is vanilla's.",
"horizon.pvp.hit_colour": "Colour",
"horizon.pvp.hit_colour.tooltip": "The colour hurt mobs and players flash.",
"horizon.pvp.hit_opacity": "Opacity",
"horizon.pvp.hit_opacity.tooltip": "How much of the colour covers them.",
"horizon.pvp.header.outline": "Block outline",
"horizon.pvp.header.hit": "Hit colour",
"horizon.pvp.hud_armour": "Armour",
"horizon.pvp.hud_armour.tooltip": "The armour you wear, head first, with how many uses each piece has left.",
"horizon.pvp.hud_armour_corner": "Armour corner",
"horizon.pvp.hud_armour_corner.tooltip": "Where the armour shows.",
"horizon.pvp.hud_armour_percent": "Durability in %",
"horizon.pvp.hud_armour_percent.tooltip": "Uses left as a percent of the piece's maximum instead of a number.",
"horizon.pvp.hud_effects": "Effects",
"horizon.pvp.hud_effects.tooltip": "Your effects with their names, levels and time left, in place of the icons at the top right.",
"horizon.pvp.hud_effects_corner": "Effects corner",
"horizon.pvp.hud_effects_corner.tooltip": "Where the effects show.",
"horizon.pvp.hud_held": "Held item count",
"horizon.pvp.hud_held.tooltip": "How many of the item in your hand your inventory holds; arrows with a bow or crossbow.",
"horizon.pvp.hud_held_corner": "Held count corner",
"horizon.pvp.hud_held_corner.tooltip": "Where the count shows.",
"horizon.pvp.hud_background": "Widget backgrounds",
"horizon.pvp.hud_background.tooltip": "A dark box behind each widget.",
"horizon.pvp.header.hud": "HUD widgets",
"horizon.pvp.corner.top_left": "Top left",
"horizon.pvp.corner.top_right": "Top right",
"horizon.pvp.corner.bottom_left": "Bottom left",
"horizon.pvp.corner.bottom_right": "Bottom right",
"horizon.pvp.hud_ping": "Ping",
"horizon.pvp.hud_ping.tooltip": "The latency the server reports for you, in milliseconds. Multiplayer only.",
"horizon.pvp.hud_ping_corner": "Ping corner",
"horizon.pvp.hud_ping_corner.tooltip": "Where the ping shows.",
"horizon.pvp.hud_coords": "Coordinates",
"horizon.pvp.hud_coords.tooltip": "Your block position and the direction you face, as F3 shows them. Hidden when the world or server reduces F3.",
"horizon.pvp.hud_coords_corner": "Coordinates corner",
"horizon.pvp.hud_coords_corner.tooltip": "Where the coordinates show.",
"horizon.pvp.hud_server": "Server address",
"horizon.pvp.hud_server.tooltip": "The address of the server you play on. Multiplayer only.",
"horizon.pvp.hud_server_corner": "Server corner",
"horizon.pvp.hud_server_corner.tooltip": "Where the address shows.",
"horizon.pvp.hud_combo": "Combo counter",
"horizon.pvp.hud_combo.tooltip": "Hits in a row the server confirmed, until you are hurt or 3 seconds pass without one.",
"horizon.pvp.hud_combo_corner": "Combo corner",
"horizon.pvp.hud_combo_corner.tooltip": "Where the combo shows.",
"horizon.pvp.hud.ping": "Ping: %s ms",
"horizon.pvp.hud.coords": "XYZ: %s %s %s",
"horizon.pvp.hud.facing": "Facing: %s",
"horizon.pvp.hud.combo": "Combo: %s",
"horizon.pvp.hud.direction.north": "North",
"horizon.pvp.hud.direction.south": "South",
"horizon.pvp.hud.direction.east": "East",
"horizon.pvp.hud.direction.west": "West",
"horizon.pvp.numeric_enchants": "Enchantment levels as numbers",
"horizon.pvp.numeric_enchants.tooltip": "Tooltips and the enchanting table show Sharpness 5 instead of Sharpness V.",
"horizon.pvp.header.items": "Items",
"horizon.pvp.fire_lower": "Lower fire",
"horizon.pvp.fire_lower.tooltip": "Moves the flames you see while burning down, so they cover less of the screen.",
"horizon.pvp.shield_size": "Shield size",
"horizon.pvp.shield_size.tooltip": "The size of the shield in your hand, in first person.",
"horizon.pvp.totem_size": "Totem animation size",
"horizon.pvp.totem_size.tooltip": "The size of the totem that fills the screen when it saves you.",
"horizon.pvp.glint_colour": "Enchantment glint colour",
"horizon.pvp.glint_colour.tooltip": "The colour of the shimmer on enchanted items and armour. Off is vanilla's purple.",
"horizon.pvp.clear_water": "Clear water",
"horizon.pvp.clear_water.tooltip": "Under water, see as far as vanilla lets you after 30 seconds there, from the start.",
"horizon.pvp.clear_lava": "Clear lava",
"horizon.pvp.clear_lava.tooltip": "In lava, see as far as vanilla's Fire Resistance lets you, without the effect.",
"horizon.pvp.header.visual": "Visual tweaks",
"horizon.pvp.view_time": "Time of day",
"horizon.pvp.view_time.tooltip": "The time of day you see: the sky and its light only. The world's time goes on and nothing is sent to the server.",
"horizon.pvp.view_weather": "Weather",
"horizon.pvp.view_weather.tooltip": "The weather you see: rain, its light and fog only. The world's weather goes on and nothing is sent to the server.",
"horizon.pvp.header.view": "Your view",
"horizon.pvp.view.world": "World's",
"horizon.pvp.time.morning": "Morning",
"horizon.pvp.time.noon": "Noon",
"horizon.pvp.time.sunset": "Sunset",
"horizon.pvp.time.midnight": "Midnight",
"horizon.pvp.weather.clear": "Clear",
"horizon.pvp.weather.rain": "Rain",
"horizon.pvp.weather.thunder": "Thunder",
"horizon.chat.timestamp": "[%s] ",
"horizon.chat.repeats": " (x%s)",
"horizon.pvp.header.chat": "Chat",
"horizon.chat.copied": "Message copied",
"horizon.chat.search": "Search chat:",
"horizon.pvp.chat_timestamps": "Timestamps",
"horizon.pvp.chat_timestamps.tooltip": "The time each message came in, before it.",
"horizon.pvp.chat_compact": "Compact chat",
"horizon.pvp.chat_compact.tooltip": "A message repeated in a row shows once, with how many times it came: (x3).",
"horizon.pvp.chat_copy": "Copy on click",
"horizon.pvp.chat_copy.tooltip": "Clicking a message in the open chat copies its text, unless the click does something else.",
"horizon.pvp.chat_search": "Chat search",
"horizon.pvp.chat_search.tooltip": "Ctrl+F in the open chat: what you type shows only the messages holding it. Enter or Ctrl+F again stops.",
"horizon.pvp.preset": "PvP preset",
"horizon.pvp.preset.tooltip": "Turns on the custom crosshair, the armour and effects widgets, toggle sprint and sneak, lower fire, and the FPS counter and keystrokes mods if installed. Your settings for each stay."
}
@@ -240,7 +240,7 @@
"horizon.moderation.remaining": "durante %s más",
"horizon.moderation.mutelist.entry": "%1$s fue silenciado por %2$s %3$s",
"horizon.moderation.invalid_duration": "%s no es una duración: 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <jugador> [motivo]",
"horizon.moderation.usage.mute": "/mute <jugador> [duración] [motivo]",
"horizon.moderation.usage.tempmute": "/tempmute <jugador> <duración> [motivo]",
"horizon.moderation.usage.unmute": "/unmute <jugador>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -251,5 +251,8 @@
"horizon.tempban.usage": "/tempban <jugador> <duración> [motivo]",
"horizon.tempban.invalid_duration": "%s no es una duración: 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Los bloqueos temporales necesitan un servidor dedicado",
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s"
"horizon.tempban.success": "%1$s durante %2$s, hasta %3$s",
"horizon.tempban.list.empty": "No hay baneos temporales",
"horizon.tempban.list.count": "Hay %s baneo(s) temporal(es):",
"horizon.tempban.list.entry": "%1$s fue baneado por %2$s %3$s (hasta %4$s)"
}
@@ -456,7 +456,7 @@
"horizon.moderation.remaining": "pour encore %s",
"horizon.moderation.mutelist.entry": "%1$s a été réduit au silence par %2$s %3$s",
"horizon.moderation.invalid_duration": "%s n'est pas une durée : 10m, 2h, 1d",
"horizon.moderation.usage.mute": "/mute <joueur> [raison]",
"horizon.moderation.usage.mute": "/mute <joueur> [durée] [raison]",
"horizon.moderation.usage.tempmute": "/tempmute <joueur> <durée> [raison]",
"horizon.moderation.usage.unmute": "/unmute <joueur>",
"horizon.moderation.usage.mutelist": "/mutelist",
@@ -468,6 +468,9 @@
"horizon.tempban.invalid_duration": "%s n'est pas une durée : 30m, 12h, 7d",
"horizon.tempban.dedicated_required": "Les bannissements temporaires nécessitent un serveur dédié",
"horizon.tempban.success": "%1$s pour %2$s, jusqu'au %3$s",
"horizon.tempban.list.empty": "Il n'y a aucun bannissement temporaire",
"horizon.tempban.list.count": "Il y a %s bannissement(s) temporaire(s) :",
"horizon.tempban.list.entry": "%1$s a été banni par %2$s %3$s (jusqu'au %4$s)",
"horizon.options.pointer_speed": "Vitesse du pointeur des menus",
"horizon.options.scroll_speed": "Vitesse de défilement des menus",
"horizon.options.repeat_delay": "Délai de répétition de navigation",
@@ -186,6 +186,7 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.realms.not_signed_in": "Войдите в лаунчере Horizon с учётной записью Microsoft, чтобы играть в Realms.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…",
@@ -0,0 +1,81 @@
//! A real-renderer consumer of the public GUI API. Run in a disposable game home:
//! `cargo run -p horizon-client --example native_gui_overlay_probe --
//! --resources PATH --world OverlayProbe --creative --world-frames 120`.
//! Every world frame turns the camera 90 degrees; GUI callbacks assert that
//! Host::camera already sees that frame. A magenta square stays above menus/F1.
use horizon_client::mod_api::{
CameraPoseEvent, GuiRenderPostEvent, Hooks, Host, HudEvent, ScreenRenderPostEvent,
};
use std::sync::atomic::{AtomicU32, Ordering};
struct Probe {
frame: AtomicU32,
yaw: AtomicU32,
}
impl Probe {
fn observe(&self, host: &dyn Host, phase: &str, flags: Option<(bool, bool)>) {
let camera = host.camera();
if let Some(camera) = &camera {
assert_eq!(
camera.yaw.to_bits(),
self.yaw.load(Ordering::SeqCst),
"previous frame's camera in {phase}"
);
assert!(
camera
.view
.iter()
.chain(camera.projection.iter())
.all(|v| v.is_finite())
);
}
eprintln!(
"native_gui_probe {}",
serde_json::json!({
"frame": self.frame.load(Ordering::SeqCst), "phase": phase,
"yaw": camera.map(|c| c.yaw), "flags": flags
})
);
}
}
impl Hooks for Probe {
fn camera_pose(&self, _: &dyn Host, event: &mut CameraPoseEvent<'_>) {
let frame = self.frame.fetch_add(1, Ordering::SeqCst);
event.yaw = [0.0_f32, 90.0, 180.0, -90.0][frame as usize % 4];
self.yaw.store(event.yaw.to_bits(), Ordering::SeqCst);
}
fn hud(&self, host: &mut dyn Host, _: &mut HudEvent<'_>) {
self.observe(host, "hud", None);
}
fn screen_render_post(&self, host: &dyn Host, event: &mut ScreenRenderPostEvent<'_>) {
self.observe(host, event.screen.id, None);
}
fn gui_render_post(&self, host: &dyn Host, event: &mut GuiRenderPostEvent<'_>) {
let (w, h) = (event.batch.width, event.batch.height);
host.fill(
event.batch,
[w * 0.5 - 8.0, h - 24.0, 16.0, 16.0],
0xffff00ff,
);
self.observe(
host,
"gui_render_post",
Some((event.gui_hidden, event.focused)),
);
}
}
fn main() {
horizon_client::entry::main_with(
vec![Box::new(Probe {
frame: AtomicU32::new(0),
yaw: AtomicU32::new(0),
}) as Box<dyn Hooks>]
.into(),
);
}
@@ -0,0 +1,141 @@
//! Client interaction state through the production Allay predictor; no prepared resources.
use horizon_client::{
Result,
items::ItemStack,
mobs::{
Anim, Brain, Flags, Mob, MobType, Part, Species,
allay::interaction::{Interaction, predict},
},
};
use horizon_core::geometry::Vec3;
use std::{io::Write, sync::Arc};
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:allay".into(),
species: Species::Allay,
width: 0.6,
height: 1.95,
eye_height: 0.36,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
item_stack_limits: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Allay,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
fn stack(s: Option<&ItemStack>) -> String {
s.filter(|s| s.count > 0)
.map_or_else(|| "empty".into(), |s| format!("{}x{}", s.item, s.count))
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
for tag in 0..3 {
for main in [true, false] {
for item in [
"minecraft:air",
"minecraft:stone",
"minecraft:amethyst_shard",
] {
for holding in [false, true] {
for dancing in [false, true] {
for ready in [false, true] {
for creative in [false, true] {
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
mob.extra.flag = dancing;
mob.extra.egg_time = i32::from(!ready);
if holding {
mob.extra.set_main_hand_stack(Some(ItemStack::new(
"minecraft:stone",
1,
)));
}
let mut offer =
(item != "minecraft:air").then(|| ItemStack::new(item, 1));
for step in 0..3 {
let duplicates = offer.as_ref().is_some_and(|s| {
(tag != 2 && &*s.item == "minecraft:amethyst_shard")
|| (tag == 1 && &*s.item == "minecraft:stone")
});
let result = predict(
&mut mob,
offer.as_ref(),
main,
duplicates,
[0, 0, 0, 11],
);
let sounds = result.as_ref().map_or_else(String::new, |r| {
format!("{}:6:2.0:1.0:true", r.sound())
});
let mut returned = String::new();
match result {
Some(Interaction::Take(s)) => returned = stack(Some(&s)),
Some(Interaction::Duplicate | Interaction::Give)
if !creative =>
{
if let Some(s) = offer.as_mut() {
s.count -= 1;
}
if offer.as_ref().is_some_and(|s| s.count == 0) {
offer = None;
}
}
_ => {}
}
let hand = if main { "MAIN_HAND" } else { "OFF_HAND" };
writeln!(
out,
"{tag} {hand} {item} {holding} {dancing} {ready} {creative} {step} => held={} offer={} can={} liked={} returned=[{returned}] sounds=[{sounds}]",
stack(mob.extra.main_hand_stack()),
stack(offer.as_ref()),
mob.extra.egg_time == 0,
mob.extra.owner.is_some()
)?;
}
}
}
}
}
}
}
}
Ok(())
}
@@ -119,6 +119,7 @@ pub fn main() -> Result<()> {
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
}
@@ -0,0 +1,48 @@
//! Captured level sounds through the production packet builder and protocol codec.
use horizon_client::{audio::CapturedSound, lan::containers::captured_sound_packet};
use horizon_protocol::{buf::Reader, packets::play::Clientbound as C};
use std::io::Write;
pub fn main() {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2]).unwrap());
for line in std::fs::read_to_string(&args[1]).unwrap().lines() {
let c: serde_json::Value = serde_json::from_str(line).unwrap();
let float =
|key: &str| f32::from_bits(u32::from_str_radix(c[key].as_str().unwrap(), 16).unwrap());
let position = std::array::from_fn(|i| {
f64::from_bits(u64::from_str_radix(c["position"][i].as_str().unwrap(), 16).unwrap())
});
let sound = CapturedSound {
event: "fixture:bound".into(),
position,
volume: float("volume"),
pitch: float("pitch"),
source: c["source"].as_u64().unwrap() as usize,
entity: c["bound"]
.as_bool()
.unwrap()
.then(|| c["entity"].as_i64().unwrap() as u32),
except_entity: None,
};
let (packet, range) = captured_sound_packet(
&sound,
c["sound_id"].as_i64().map(|n| n as i32),
c["seed"].as_i64().unwrap(),
);
let kind = match packet {
C::SoundEntity(_) => "entity",
C::Sound(_) => "position",
_ => unreachable!(),
};
let bytes = packet.encode();
let mut reader = Reader::new(&bytes);
let id = reader.varint().unwrap();
// The native StreamCodec writes the payload only; also require our own
// decoder to consume this packet fully before comparing payload bytes.
let prefix = bytes.len() - reader.remaining();
C::decode(id, &bytes[prefix..]).unwrap();
let payload: String = bytes[prefix..].iter().map(|b| format!("{b:02x}")).collect();
writeln!(out, "{kind} {:016x} {payload}", range.to_bits()).unwrap();
}
}
@@ -0,0 +1,86 @@
//! Late trident pairing and subsequent updates through the production tracker.
use horizon_client::{
Result,
lan::entity::{Entities, EntityState, Riding, Tracker, Viewer},
};
use horizon_protocol::{
buf::Reader,
entity_data::{Value, serializer},
packets::play::Clientbound as C,
};
use std::io::Write;
fn dump(out: &mut impl Write, index: usize, phase: &str, packets: &mut Vec<Vec<u8>>) -> Result<()> {
let mut encoded = Vec::new();
for bytes in packets.drain(..) {
let mut r = Reader::new(&bytes);
let id = r.varint()?;
let payload = r.rest();
let name = match C::decode(id, payload)? {
C::AddEntity(_) => "add",
C::SetEntityMotion(_) => "motion",
C::MoveEntityPosRot(_) => "posrot",
C::SetEntityData(_) => "data",
other => panic!("unexpected late-pair packet {other:?}"),
};
let hex: String = payload.iter().map(|b| format!("{b:02x}")).collect();
encoded.push(format!("{name}:{hex}"));
}
writeln!(out, "{index} {phase} {}", encoded.join(","))?;
Ok(())
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let kind: i32 = args[1].parse()?;
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[2])?);
writeln!(out, "type {kind}")?;
let mut index = 0;
for position in [[0.5, 65.5, 0.5], [-0.123456789, 64.125, -0.5]] {
for velocity in [[0.25, 0.0, 0.0], [0.0; 3], [0.25, -0.5, 0.125]] {
for landed in [false, true] {
let mut level = Entities::default();
let mut tracker = Tracker::default();
let mut packets = Vec::new();
let chunks = [(0, 0), (-1, -1), (0, -1), (-1, 0)].into_iter().collect();
let mut viewers = [Viewer {
entity_id: 1,
spectator: false,
permission: 0,
moved: false,
position: [0.5, 64.0, 0.5],
view_distance: 10,
chunks: &chunks,
tracker: &mut tracker,
out: &mut packets,
gone: None,
}];
let riding = Riding::default();
level.tick(&[], &riding, &mut viewers, |_| Some(kind));
let mut e = EntityState {
id: 37,
uuid: 37,
kind: "minecraft:trident".into(),
position,
velocity,
..EntityState::default()
};
level.pair_after_tick(std::slice::from_ref(&e), &riding, &mut viewers, |_| {
Some(kind)
});
dump(&mut out, index, "pair", viewers[0].out)?;
e.position[0] += 0.25;
e.velocity = [0.0; 3];
if landed {
e.meta.push((10, serializer::BOOLEAN, Value::Bool(true)));
}
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
dump(&mut out, index, "change", viewers[0].out)?;
level.tick(&[&e], &riding, &mut viewers, |_| Some(kind));
dump(&mut out, index, "again", viewers[0].out)?;
index += 1;
}
}
}
Ok(())
}
@@ -10,6 +10,7 @@ mod dispatch;
use dispatch::{args, args_os};
mod allay_interaction_fixture;
mod ambient_fixture;
mod arrow_probe;
mod audio_attributes_fixture;
@@ -41,6 +42,7 @@ mod entity_anim_fixture;
mod entity_components_probe;
mod entity_death_loot_fixture;
mod entity_particles_probe;
mod entity_sound_packet_fixture;
mod environment_fixture;
mod finalize_spawn_fixture;
mod fire_starter_sound_probe;
@@ -57,6 +59,7 @@ mod inline_object_fixture;
mod input_probe;
mod interaction_input_fixture;
mod lan_probe;
mod late_entity_pair_fixture;
mod light_parity;
mod lore_tooltip_fixture;
mod marker_particles_probe;
@@ -87,6 +90,7 @@ mod scoreboard_fixture;
mod screen_layout_fixture;
mod section_mesh_fixture;
mod server_files_probe;
mod sheep_dye_fixture;
mod situational_music_fixture;
mod skin_image_fixture;
mod sound_engine_fixture;
@@ -191,6 +195,10 @@ probes! {
server_files_probe,
situational_music_fixture,
skin_image_fixture,
late_entity_pair_fixture,
sheep_dye_fixture,
allay_interaction_fixture,
entity_sound_packet_fixture,
sound_engine_fixture,
sound_event_fixture,
spawn_rules_fixture,
@@ -1611,7 +1611,14 @@ pub fn main() -> horizon_client::Result<()> {
let _ = writeln!(out, "{t} leads {dropped_leads}");
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
if let MobEvent::Sound(name, _, volume, pitch)
| MobEvent::EntitySound {
name,
volume,
pitch,
..
} = e
{
let _ = writeln!(
out,
"{t} sound minecraft:{name} {} {}",
@@ -324,6 +324,7 @@ pub fn main() -> Result<()> {
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
};
match op["mode"].as_str().unwrap() {
"shift" => world.player.shift_key_down = op["value"].as_bool().unwrap(),
@@ -0,0 +1,115 @@
//! Actual production sheep-dye interaction without prepared resources.
use horizon_client::{
Result,
items::ItemStack,
mobs::{Anim, Brain, Flags, Mob, MobType, Part, Species, sheep_dye},
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
use std::{io::Write, sync::Arc};
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:sheep".into(),
species: Species::Sheep,
width: 0.6,
height: 1.95,
eye_height: 0.36,
category: "monster".into(),
max_health: 20.0,
speed: 0.23,
flying_speed: 0.4,
attack_damage: 3.0,
attack_knockback: 0.0,
follow_range: 35.0,
step_height: 0.6,
armor: 2.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
item_stack_limits: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags::default(),
model: Arc::new(Part {
name: "root".into(),
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: Vec::new(),
children: Vec::new(),
}),
layers: Default::default(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Quadruped,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[1])?);
for initial in [0, 7, 14, 15] {
for hand in ["MAIN_HAND", "OFF_HAND"] {
for item in [
"minecraft:white_dye",
"minecraft:red_dye",
"minecraft:black_dye",
] {
for patch in 0..3 {
for sheared in [false, true] {
for alive in [false, true] {
for client in [false, true] {
let mut mob = Mob::new(37, kind(), Vec3::ZERO, 0.0);
mob.extra.color = initial;
mob.extra.sheared = sheared;
if !alive {
mob.health = 0.0;
}
let mut stack = ItemStack::new(item, 2);
if patch == 1 {
stack.data_mut().set_other(
"minecraft:dye",
Some(Tag::String("blue".into())),
);
}
if patch == 2 {
stack.data_mut().set_other(
"!minecraft:dye",
Some(Tag::Compound(Default::default())),
);
}
for step in 0..2 {
let result = sheep_dye::interact(&mut mob, &mut stack, client);
let sound = if result {
"minecraft:item.dye.use:7:1.0:1.0:true"
} else {
""
};
writeln!(
out,
"{initial} {hand} {item} {patch} {sheared} {alive} {client} {step} => result={result} color={} count={} sounds=[{sound}]",
mob.extra.color, stack.count
)?;
}
}
}
}
}
}
}
}
Ok(())
}
@@ -4,7 +4,8 @@
use crate::audio_common as common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundInstance, TickContext,
Attenuation, Backend, CartSound, InstanceId, SoundEngine, SoundEntity, SoundEntityState,
SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
@@ -21,6 +22,7 @@ pub fn main() {
// Script cart names to entity ids (a removed cart leaves the level).
let mut cart_ids: BTreeMap<String, u32> = BTreeMap::new();
let mut removed: BTreeMap<u32, CartSound> = BTreeMap::new();
let mut bound_entities: BTreeMap<String, SoundEntity> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
@@ -51,6 +53,14 @@ pub fn main() {
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
"bound" => SoundInstance::entity_bound(
&event,
source(4),
f(a[5]),
f(a[6]),
random,
bound_entities[a[15]].clone(),
),
"cart" | "rcart" | "rcartu" => {
let id = cart_ids[a[3]];
let cart = ctx
@@ -109,7 +119,7 @@ pub fn main() {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"cart" => {
"cart" | "cartnew" => {
let next = cart_ids.len() as u32 + 1;
let id = *cart_ids.entry(a[1].to_owned()).or_insert(next);
ctx.carts.retain(|c| c.id != id);
@@ -119,6 +129,22 @@ pub fn main() {
// Vec3.horizontalDistance.
speed: (d(a[5]) * d(a[5]) + d(a[7]) * d(a[7])).sqrt(),
};
let silent = a.get(9) == Some(&"1");
if a[0] == "cartnew"
&& let Some(old) = bound_entities.remove(a[1])
{
let mut state = old.state();
state.removed = true;
old.update(state);
}
let entity = bound_entities
.entry(a[1].to_owned())
.or_insert_with(|| SoundEntity::new(id, cart.position, silent));
entity.update(SoundEntityState {
position: cart.position,
silent,
removed: a[8] == "1",
});
if a[8] == "1" {
// Out of the level, still there for a later play.
removed.insert(id, cart);
+858 -5
View File
@@ -65,6 +65,78 @@ pub struct Tree {
/// The triggers with a criterion that may test what the player looks at
/// (`index_looking_at`).
looking_at_triggers: BTreeSet<String>,
/// The triggers with a criterion that may test an entity's `nbt`
/// (`index_nbt`).
nbt_triggers: BTreeSet<String>,
/// What the criteria of each trigger may ask of the player's level,
/// statistics, advancements and recipes (`index_player_facts`).
player_fact_triggers: HashMap<String, FactsAsked>,
}
/// Which of `PlayerPredicate`'s level, `stats`, `advancements` and `recipes`
/// a trigger's criteria may test: only those are filled in its player view.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FactsAsked {
pub level: bool,
pub stats: bool,
pub advancements: bool,
pub recipes: bool,
}
impl FactsAsked {
pub const ALL: Self = Self {
level: true,
stats: true,
advancements: true,
recipes: true,
};
/// Asked by either.
pub fn or(self, other: Self) -> Self {
Self {
level: self.level || other.level,
stats: self.stats || other.stats,
advancements: self.advancements || other.advancements,
recipes: self.recipes || other.recipes,
}
}
}
/// What `v` (a predicate, a list of conditions or an item modifier) may ask
/// of the player's level, statistics, advancements and recipes: the fields
/// of its player predicates, and all four for a `minecraft:reference` to a
/// predicate file. Walked without reading anything else, for the commands
/// that test a predicate on the player.
pub fn facts_asked_in(v: &Value) -> FactsAsked {
let mut asked = FactsAsked::default();
let mut todo = vec![v];
while let Some(v) = todo.pop() {
match v {
Value::Array(list) => todo.extend(list),
Value::Object(o) => {
// A predicate file, or (`function`) an item modifier file.
let kind =
(o.get("condition").or_else(|| o.get("type"))).or_else(|| o.get("function"));
if kind
.and_then(Value::as_str)
.is_some_and(|k| full(k) == "minecraft:reference")
{
return FactsAsked::ALL;
}
for (key, value) in o {
if full(key) == "minecraft:type_specific/player"
&& let Some(p) = value.as_object()
{
asked = asked.or(FactsAsked {
level: p.contains_key("level"),
stats: p.contains_key("stats"),
advancements: p.contains_key("advancements"),
recipes: p.contains_key("recipes"),
});
}
todo.push(value);
}
}
_ => {}
}
}
asked
}
fn frame(v: &Value) -> Frame {
match v.as_str() {
@@ -146,6 +218,8 @@ impl Tree {
}
tree.index_recipes();
tree.index_looking_at();
tree.index_nbt();
tree.index_player_facts();
tree
}
/// Index the `recipe_unlocked` criteria by recipe: a grant that unlocks
@@ -188,6 +262,69 @@ impl Tree {
pub fn may_look(&self, trigger: &str) -> bool {
self.looking_at_triggers.contains(trigger)
}
/// Index the triggers whose criteria name `nbt`, or a
/// `minecraft:reference` to a predicate file that might: the text is
/// searched, nothing is read.
pub fn index_nbt(&mut self) {
let keys = [
"\"nbt\"",
"\"minecraft:nbt\"",
"\"minecraft:reference\"",
"\"reference\"",
];
self.nbt_triggers = (self.list.iter().flat_map(|a| &a.criteria))
.filter(|c| {
let json = c.conditions.json();
keys.iter().any(|k| json.contains(k))
})
.map(|c| c.trigger.clone())
.collect();
}
/// Whether a criterion of `trigger` may test an entity's `nbt`.
pub fn may_test_nbt(&self, trigger: &str) -> bool {
self.nbt_triggers.contains(trigger)
}
/// Whether any criterion may test an entity's `nbt`.
pub fn tests_nbt(&self) -> bool {
!self.nbt_triggers.is_empty()
}
/// Index what each trigger's criteria may ask of the player's level,
/// statistics, advancements and recipes: a player predicate naming the
/// field, or a `minecraft:reference` to a predicate file that might (all
/// four). The text is searched, nothing is read.
pub fn index_player_facts(&mut self) {
let reference = ["\"minecraft:reference\"", "\"reference\""];
let mut out: HashMap<String, FactsAsked> = HashMap::new();
for c in self.list.iter().flat_map(|a| &a.criteria) {
let json = c.conditions.json();
let asked = if reference.iter().any(|r| json.contains(r)) {
FactsAsked::ALL
} else if json.contains("type_specific/player") {
FactsAsked {
level: json.contains("\"level\""),
stats: json.contains("\"stats\""),
advancements: json.contains("\"advancements\""),
recipes: json.contains("\"recipes\""),
}
} else {
continue;
};
if asked == FactsAsked::default() {
continue;
}
let e = out.entry(c.trigger.clone()).or_default();
*e = e.or(asked);
}
self.player_fact_triggers = out;
}
/// What a criterion of `trigger` may ask of the player's level,
/// statistics, advancements and recipes.
pub fn player_facts_asked(&self, trigger: &str) -> FactsAsked {
self.player_fact_triggers
.get(trigger)
.copied()
.unwrap_or_default()
}
/// The `recipe_unlocked` criteria that could pass for `recipe`, in tree
/// order (the others name another recipe).
pub fn recipe_criteria(&self, recipe: &str) -> Vec<(usize, usize)> {
@@ -504,6 +641,9 @@ pub struct EntityView {
/// Player: the sneak input is held (`Input.shift`, what `input.sneak`
/// asks; not the sneaking pose).
pub input_sneak: bool,
/// Player: its level, statistics, advancements and recipes, as far as
/// they were filled.
pub player_facts: PlayerFacts,
/// Lightning: entities it struck.
pub struck: Vec<EntityView>,
/// Lightning: blocks it set on fire (`LightningBolt.blocksSetOnFire`).
@@ -512,6 +652,37 @@ pub struct EntityView {
pub variant: Option<String>,
/// The data components the entity reports (`Entity.get`): id and value.
pub components: Vec<(String, String)>,
/// The entity as saved (`NbtPredicate.getEntityTagToCompare`), for
/// `minecraft:nbt`; built only when a criterion may ask (`Tree::tests_nbt`).
pub nbt: Option<horizon_world::nbt::Tag>,
}
/// What `PlayerPredicate` reads of a player beyond its entity. Each part is
/// filled only when a criterion may ask for it (`Tree::player_facts_asked`);
/// a predicate that asks for a part left `None` fails.
#[derive(Clone, Default)]
pub struct PlayerFacts {
/// `Player.experienceLevel`.
pub level: Option<i32>,
/// `ServerStatsCounter`: stat type -> stat id -> value, both namespaced.
pub stats: Option<BTreeMap<String, BTreeMap<String, i32>>>,
/// `PlayerAdvancements`: the loaded advancements and the player's
/// progress in them.
pub advancements: Option<(Arc<Tree>, Progress)>,
/// `ServerRecipeBook`: the known recipe ids.
pub recipes: Option<BTreeSet<String>>,
}
impl std::fmt::Debug for PlayerFacts {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PlayerFacts")
.field("level", &self.level)
.field("stats", &self.stats)
.field(
"advancements",
&self.advancements.as_ref().map(|(_, p)| p.criteria.len()),
)
.field("recipes", &self.recipes.as_ref().map(BTreeSet::len))
.finish()
}
}
fn range_f(v: &Value, x: f64) -> bool {
match v {
@@ -535,6 +706,14 @@ pub fn full(id: &str) -> std::borrow::Cow<'_, str> {
format!("minecraft:{id}").into()
}
}
/// The entry of a map keyed by registry ids (`Identifier.parse` on the key),
/// such as an item predicate's `components` or `predicates`.
fn keyed<'a>(map: &'a Value, id: &str) -> Option<&'a Value> {
map.as_object()?
.iter()
.find(|(k, _)| full(k) == id)
.map(|(_, v)| v)
}
/// A registry id, `#tag`, or list matched against one id.
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
match v {
@@ -607,18 +786,18 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
}
// Data components: exact matches on the few the data uses.
let comps = &v["components"];
if let Some(p) = comps["minecraft:potion_contents"].as_str()
&& it.potion.as_deref() != Some(p)
if let Some(p) = keyed(comps, "minecraft:potion_contents").and_then(Value::as_str)
&& !it.potion.as_deref().is_some_and(|h| full(h) == full(p))
{
return false;
}
if let Some(d) = comps["minecraft:damage"].as_u64()
if let Some(d) = keyed(comps, "minecraft:damage").and_then(Value::as_u64)
&& u64::from(it.damage) != d
{
return false;
}
let preds = &v["predicates"];
if let Some(list) = preds["minecraft:enchantments"].as_array() {
if let Some(list) = keyed(preds, "minecraft:enchantments").and_then(Value::as_array) {
for e in list {
let ok = it.enchantments.iter().any(|(id, lvl)| {
id_matches(&e["enchantments"], "enchantment", id, w)
@@ -629,7 +808,7 @@ pub fn item(v: &Value, it: &ItemView, w: &dyn World) -> bool {
}
}
}
if let Some(d) = preds["minecraft:damage"].as_object() {
if let Some(d) = keyed(preds, "minecraft:damage").and_then(Value::as_object) {
let damage = Value::Object(d.clone());
if !range_i(&damage["damage"], i64::from(it.damage)) {
return false;
@@ -814,6 +993,13 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
})
})
}),
// `EntityNbtPredicate.matches`: the entity's compound holds the
// pattern (`NbtUtils.compareNbt`); no compound known fails.
"minecraft:nbt" => p.as_str().is_some_and(|s| {
horizon_world::nbt::parse_snbt(s).is_ok_and(|(pattern, _)| {
crate::nbt_path::compare_nbt(&pattern, e.nbt.as_ref())
})
}),
"minecraft:type_specific/player" => {
// `PlayerPredicate.matches`: `instanceof ServerPlayer` first,
// then the game mode, what it looks at and its `input`.
@@ -828,6 +1014,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.as_ref()
.is_some_and(|t| entity(&p["looking_at"], t, w, origin)))
&& (p["input"].is_null() || input(&p["input"], e))
&& player_facts(p, &e.player_facts)
}
"minecraft:type_specific/lightning" => {
range_i(&p["blocks_set_on_fire"], i64::from(e.blocks_set_on_fire))
@@ -849,6 +1036,89 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
}
true
}
/// The rest of `PlayerPredicate.matches` (expected from it): the experience
/// level in the `level` bounds; each `stats` entry's value (0 when never
/// counted) in its bounds; each listed recipe known or not as asked; each
/// listed advancement loaded, and done or not, or each named criterion of it
/// granted or not. A field asked of facts that weren't filled fails.
fn player_facts(p: &Value, f: &PlayerFacts) -> bool {
let level = &p["level"];
if !level.is_null() && !f.level.is_some_and(|l| range_i(level, i64::from(l))) {
return false;
}
let stats = &p["stats"];
if !stats.is_null() {
let Some(list) = stats.as_array() else {
return false;
};
// `StatMatcher.matches`: the counter's value for the type's stat.
let ok = list.is_empty()
|| f.stats.as_ref().is_some_and(|counts| {
list.iter().all(|m| {
let (Some(ty), Some(stat)) = (m["type"].as_str(), m["stat"].as_str()) else {
return false;
};
let value = counts
.get(&*full(ty))
.and_then(|of| of.get(&*full(stat)))
.copied()
.unwrap_or(0);
range_i(&m["value"], i64::from(value))
})
});
if !ok {
return false;
}
}
let recipes = &p["recipes"];
if !recipes.is_null() {
let Some(map) = recipes.as_object() else {
return false;
};
let ok = map.is_empty()
|| f.recipes.as_ref().is_some_and(|known| {
map.iter().all(|(id, want)| {
want.as_bool()
.is_some_and(|want| known.contains(&*full(id)) == want)
})
});
if !ok {
return false;
}
}
let advancements = &p["advancements"];
if !advancements.is_null() {
let Some(map) = advancements.as_object() else {
return false;
};
let ok = map.is_empty()
|| f.advancements.as_ref().is_some_and(|(tree, progress)| {
map.iter().all(|(id, want)| {
// An advancement that isn't loaded matches nothing.
let Some(a) = tree.get(&full(id)) else {
return false;
};
match want {
// `AdvancementDonePredicate`.
Value::Bool(want) => progress.done(a) == *want,
// `AdvancementCriterionsPredicate`: a criterion the
// advancement doesn't have matches nothing.
Value::Object(criteria) => criteria.iter().all(|(name, want)| {
want.as_bool().is_some_and(|want| {
a.criteria.iter().any(|c| c.name == *name)
&& progress.has(&a.id, name) == want
})
}),
_ => false,
}
})
});
if !ok {
return false;
}
}
true
}
/// `InputPredicate.matches`: the player's held inputs. The sneak input is
/// what the game holds; the other inputs have no source here yet and count
/// as unconstrained.
@@ -892,6 +1162,56 @@ pub fn killed_by_arrow(
let kinds: BTreeSet<&str> = victims.iter().map(|e| e.kind.as_str()).collect();
range_i(&c["unique_entity_types"], kinds.len() as i64)
}
/// `KilledTrigger.TriggerInstance.matches`: the player, the other entity
/// (the one killed, or the killer for `entity_killed_player`) and the
/// killing blow (none: any `killing_blow` fails).
pub fn killed(
c: &Value,
me: &EntityView,
target: &EntityView,
blow: Option<&DamageView>,
w: &dyn World,
) -> bool {
let pctx = Entities {
this: Some(me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = Entities {
this: Some(target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow.is_some_and(|d| damage_source(&c["killing_blow"], d, w, me.position));
conditions(&c["player"], &pctx, w) && conditions(&c["entity"], &ectx, w) && blow_ok
}
/// A blow recorded by its damage type and causing entity, as `killing_blow`
/// sees it: the projectile those types name is the direct entity, at `at`;
/// otherwise the causing entity dealt it itself (`DamageSource.isDirect`).
pub fn recorded_blow(kind: String, source: Option<EntityView>, at: [f64; 3]) -> DamageView {
let projectile = match kind.as_str() {
"minecraft:arrow" => Some("minecraft:arrow"),
"minecraft:fireball" | "minecraft:small_fireball" => Some("minecraft:fireball"),
"minecraft:wither_skull" => Some("minecraft:wither_skull"),
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
_ => None,
};
DamageView {
kind,
is_direct: projectile.is_none(),
direct: match projectile {
Some(kind) => Some(EntityView {
kind: kind.into(),
position: at,
..Default::default()
}),
None => source.clone(),
},
source,
..Default::default()
}
}
/// A damage event as `DamagePredicate` sees it.
#[derive(Clone, Debug, Default)]
pub struct DamageView {
@@ -1357,6 +1677,7 @@ impl Tree {
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn advancement_reward_function_is_optional_and_namespaced() {
@@ -1579,6 +1900,58 @@ mod tests {
let none = serde_json::json!({});
assert!(can_unlock(&none, false, None, &Flat));
}
/// `ItemPredicate`'s `components` and `predicates` are keyed by registry
/// ids, so a key without a namespace is a `minecraft:` one. An unread key
/// used to be skipped, which let every stack pass.
#[test]
fn item_predicate_reads_bare_component_keys() {
let potion = ItemView {
id: "minecraft:potion".into(),
count: 1,
potion: Some("minecraft:swiftness".into()),
..Default::default()
};
let sword = ItemView {
id: "minecraft:iron_sword".into(),
count: 1,
damage: 3,
enchantments: vec![("minecraft:sharpness".into(), 2)],
..Default::default()
};
let cases = [
(
serde_json::json!({"components": {"potion_contents": "minecraft:swiftness"}}),
serde_json::json!({"components": {"potion_contents": "minecraft:healing"}}),
&potion,
),
(
serde_json::json!({"components": {"damage": 3}}),
serde_json::json!({"components": {"damage": 4}}),
&sword,
),
(
serde_json::json!({"predicates": {"enchantments": [
{"enchantments": "sharpness", "levels": 2}
]}}),
serde_json::json!({"predicates": {"enchantments": [
{"enchantments": "sharpness", "levels": {"min": 3}}
]}}),
&sword,
),
(
serde_json::json!({"predicates": {"damage": {"damage": {"max": 3}}}}),
serde_json::json!({"predicates": {"damage": {"damage": {"min": 4}}}}),
&sword,
),
];
for (yes, no, stack) in cases {
assert!(item(&yes, stack, &Flat), "{yes} should match");
assert!(!item(&no, stack, &Flat), "{no} should not match");
}
// The potion is a registry id too.
let bare = serde_json::json!({"components": {"minecraft:potion_contents": "swiftness"}});
assert!(item(&bare, &potion, &Flat));
}
/// `PassengerPredicate.matches`: ride_a_boat_with_a_goat's vehicle holds a goat.
#[test]
fn passenger_predicate_needs_a_matching_passenger() {
@@ -1597,6 +1970,72 @@ mod tests {
boat.passengers.push(entity("minecraft:goat"));
assert!(super::entity(&want, &boat, &Flat, [0.0; 3]));
}
/// `EntityNbtPredicate.matches`: the entity's saved compound holds the
/// pattern (`NbtUtils.compareNbt(…, true)`); without one it fails.
#[test]
fn an_entity_predicates_nbt_tests_the_saved_compound() {
let snbt = |s: &str| horizon_world::nbt::parse_snbt(s).unwrap().0;
let view = |kind: &str, nbt: &str| EntityView {
kind: kind.into(),
nbt: Some(snbt(nbt)),
..Default::default()
};
let check = |v: serde_json::Value, e: &EntityView| super::entity(&v, e, &Flat, [0.0; 3]);
let nbt = |s: &str| serde_json::json!({"minecraft:nbt": s});
let axolotl = view("minecraft:axolotl", "{Variant:1,FromBucket:1b}");
assert!(check(nbt("{Variant:1}"), &axolotl));
assert!(check(nbt("{}"), &axolotl));
assert!(!check(nbt("{Variant:0}"), &axolotl));
assert!(
!check(nbt("{FromBucket:1}"), &axolotl),
"a byte, not an int"
);
let pig = serde_json::json!({"minecraft:entity_type": "minecraft:pig",
"minecraft:nbt": "{Variant:1}"});
assert!(
!check(pig, &axolotl),
"the other sub-predicates still apply"
);
let villager = view(
"minecraft:villager",
r#"{VillagerData:{level:2,profession:"minecraft:farmer",type:"minecraft:taiga"}}"#,
);
assert!(check(
nbt(r#"{VillagerData:{profession:"minecraft:farmer"}}"#),
&villager
));
assert!(!check(
nbt(r#"{VillagerData:{profession:"minecraft:librarian"}}"#),
&villager
));
let player = view(
"minecraft:player",
r#"{Inventory:[{Slot:0b,id:"minecraft:spyglass",count:1},{Slot:3b,id:"minecraft:stick",count:2}],SelectedItem:{id:"minecraft:spyglass",count:1}}"#,
);
assert!(check(
nbt(r#"{Inventory:[{id:"minecraft:stick"}]}"#),
&player
));
assert!(!check(
nbt(r#"{Inventory:[{id:"minecraft:stick",count:3}]}"#),
&player
));
assert!(
!check(nbt("{Inventory:[]}"), &player),
"an empty list wants an empty one"
);
assert!(check(
nbt(r#"{SelectedItem:{id:"minecraft:spyglass"}}"#),
&player
));
let bare = EntityView {
kind: "minecraft:axolotl".into(),
..Default::default()
};
assert!(!check(nbt("{}"), &bare), "no compound known");
assert!(!check(nbt("{Variant:"), &axolotl), "unparsable");
assert!(!check(serde_json::json!({"minecraft:nbt": 3}), &axolotl));
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
@@ -1634,6 +2073,81 @@ mod tests {
assert!(!damage_source(&blow, &arrow, &Flat, [0.0; 3]));
assert!(damage_source(&Value::Null, &sword, &Flat, [0.0; 3]));
}
fn mob(kind: &str) -> EntityView {
EntityView {
kind: kind.into(),
..Default::default()
}
}
/// `entity_killed_player` by a mob's melee hit: the mob is the killer and
/// the blow's causing and direct entity.
#[test]
fn entity_killed_player_by_a_mob_names_the_mob() {
let c = serde_json::json!({
"entity": {"minecraft:entity_type": "minecraft:polar_bear"},
"killing_blow": {"source_entity": {"minecraft:entity_type": "minecraft:polar_bear"}}
});
let me = mob("minecraft:player");
let bear = mob("minecraft:polar_bear");
let blow = recorded_blow("minecraft:mob_attack".into(), Some(bear.clone()), [0.0; 3]);
assert!(blow.is_direct);
assert_eq!(
blow.direct.as_ref().map(|e| e.kind.as_str()),
Some("minecraft:polar_bear")
);
assert!(killed(&c, &me, &bear, Some(&blow), &Flat));
let panda = mob("minecraft:panda");
let blow = recorded_blow("minecraft:mob_attack".into(), Some(panda.clone()), [0.0; 3]);
assert!(!killed(&c, &me, &panda, Some(&blow), &Flat));
}
/// `entity_killed_player` by a skeleton's arrow: the shooter is the
/// killer and the blow's causing entity, the arrow its direct one.
#[test]
fn entity_killed_player_by_an_arrow_names_the_shooter() {
let c = serde_json::json!({
"entity": {"minecraft:entity_type": "minecraft:skeleton"},
"killing_blow": {
"direct_entity": {"minecraft:entity_type": "minecraft:arrow"},
"source_entity": {"minecraft:entity_type": "minecraft:skeleton"},
"is_direct": false
}
});
let me = mob("minecraft:player");
let skeleton = mob("minecraft:skeleton");
let blow = recorded_blow(
"minecraft:arrow".into(),
Some(skeleton.clone()),
[1.0, 2.0, 3.0],
);
assert!(!blow.is_direct);
assert_eq!(
blow.direct.as_ref().map(|e| e.position),
Some([1.0, 2.0, 3.0])
);
assert!(killed(&c, &me, &skeleton, Some(&blow), &Flat));
// Shot point-blank, not with an arrow: the direct entity is the skeleton.
let melee = recorded_blow(
"minecraft:mob_attack".into(),
Some(skeleton.clone()),
[0.0; 3],
);
assert!(!killed(&c, &me, &skeleton, Some(&melee), &Flat));
}
/// A blow with no causing entity fails a `source_entity` test, and with
/// no blow at all any `killing_blow` fails.
#[test]
fn a_blow_with_no_killer_fails_its_entity_tests() {
let c = serde_json::json!({
"killing_blow": {"source_entity": {"minecraft:entity_type": "minecraft:zombie"}}
});
let me = mob("minecraft:player");
let zombie = mob("minecraft:zombie");
let fall = recorded_blow("minecraft:fall".into(), None, [0.0; 3]);
assert!(fall.direct.is_none() && fall.source.is_none());
assert!(!killed(&c, &me, &zombie, Some(&fall), &Flat));
assert!(!killed(&c, &me, &zombie, None, &Flat));
assert!(killed(&serde_json::json!({}), &me, &zombie, None, &Flat));
}
#[test]
fn uh_oh_accepts_either_tnt_event_only_for_adult_sulfur_cubes() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(
@@ -2060,6 +2574,345 @@ mod tests {
assert_eq!(l[4], Some((1, 2.0)));
assert_eq!(l[1], Some((2, 2.0)));
}
/// A tree of the given advancements, indexed as `load_with` does.
fn tree_of(list: &[(&str, Value)]) -> Tree {
let mut tree = Tree::default();
for (id, v) in list {
let a = parse(id, v).expect("an advancement");
for (ci, c) in a.criteria.iter().enumerate() {
(tree.by_trigger.entry(c.trigger.clone()).or_default()).push((tree.list.len(), ci));
}
tree.by_id.insert((*id).into(), tree.list.len());
tree.list.push(a);
}
tree.index_player_facts();
tree
}
/// Whether the player `facts` match `{"type_specific/player": p}`.
fn player_matches(p: Value, facts: &PlayerFacts) -> bool {
let me = EntityView {
kind: "minecraft:player".into(),
player_facts: facts.clone(),
..EntityView::default()
};
entity(&json!({"type_specific/player": p}), &me, &Flat, [0.0; 3])
}
#[test]
fn player_level_is_tested_in_its_bounds() {
let five = PlayerFacts {
level: Some(5),
..PlayerFacts::default()
};
for (level, expected) in [
(json!({"min": 5}), true),
(json!({"min": 6}), false),
(json!({"max": 4}), false),
(json!({"min": 1, "max": 5}), true),
(json!(5), true),
(json!(6), false),
] {
assert_eq!(
player_matches(json!({"level": level}), &five),
expected,
"{level}"
);
}
// Not filled: a level asked fails, none asked passes.
assert!(!player_matches(
json!({"level": {"min": 0}}),
&PlayerFacts::default()
));
assert!(player_matches(json!({}), &PlayerFacts::default()));
}
#[test]
fn player_stats_match_each_counter_by_type_and_id() {
let mut counts: BTreeMap<String, BTreeMap<String, i32>> = BTreeMap::new();
(counts.entry("minecraft:custom".into()).or_default())
.insert("minecraft:walk_one_cm".into(), 1000);
(counts.entry("minecraft:mined".into()).or_default()).insert("minecraft:stone".into(), 3);
let facts = PlayerFacts {
stats: Some(counts),
..PlayerFacts::default()
};
let stat =
|ty: &str, id: &str, value: Value| json!({"type": ty, "stat": id, "value": value});
for (stats, expected) in [
(
vec![stat(
"minecraft:custom",
"minecraft:walk_one_cm",
json!({"min": 1000}),
)],
true,
),
(
vec![stat(
"minecraft:custom",
"minecraft:walk_one_cm",
json!({"min": 1001}),
)],
false,
),
(
vec![stat(
"minecraft:custom",
"minecraft:walk_one_cm",
json!({"max": 999}),
)],
false,
),
// Bare ids are `minecraft:` ones.
(
vec![stat("custom", "walk_one_cm", json!({"min": 1000}))],
true,
),
// The same id under another type is another counter: never counted, 0.
(
vec![stat(
"minecraft:mined",
"minecraft:walk_one_cm",
json!({"min": 1}),
)],
false,
),
(
vec![stat(
"minecraft:mined",
"minecraft:walk_one_cm",
json!({"max": 0}),
)],
true,
),
(
vec![stat("minecraft:killed", "minecraft:zombie", json!(0))],
true,
),
// Every entry must match.
(
vec![
stat("minecraft:mined", "minecraft:stone", json!({"min": 3})),
stat(
"minecraft:custom",
"minecraft:walk_one_cm",
json!({"min": 5000}),
),
],
false,
),
(
vec![
stat("minecraft:mined", "minecraft:stone", json!({"min": 3})),
stat(
"minecraft:custom",
"minecraft:walk_one_cm",
json!({"min": 500}),
),
],
true,
),
] {
let p = json!({"stats": stats});
assert_eq!(player_matches(p.clone(), &facts), expected, "{p}");
}
let walked = json!({"stats": [stat("custom", "walk_one_cm", json!({"min": 0}))]});
assert!(!player_matches(walked, &PlayerFacts::default()));
}
#[test]
fn player_recipes_are_known_or_not_as_asked() {
let facts = PlayerFacts {
recipes: Some(["minecraft:iron_pickaxe".to_owned()].into()),
..PlayerFacts::default()
};
for (recipes, expected) in [
(json!({"minecraft:iron_pickaxe": true}), true),
(json!({"iron_pickaxe": true}), true),
(json!({"minecraft:iron_pickaxe": false}), false),
(json!({"minecraft:diamond_pickaxe": false}), true),
(json!({"minecraft:diamond_pickaxe": true}), false),
(
json!({"minecraft:iron_pickaxe": true, "minecraft:diamond_pickaxe": true}),
false,
),
] {
assert_eq!(
player_matches(json!({"recipes": recipes}), &facts),
expected,
"{recipes}"
);
}
let unknown = json!({"recipes": {"minecraft:diamond_pickaxe": false}});
assert!(!player_matches(unknown, &PlayerFacts::default()));
}
#[test]
fn player_advancements_are_done_or_have_their_criteria_as_asked() {
let tick = json!({"trigger": "minecraft:tick"});
let tree = Arc::new(tree_of(&[(
"pack:a",
json!({"criteria": {"x": tick, "y": tick}}),
)]));
let mut progress = Progress::default();
progress.grant("pack:a", "x", "2026-11-04 12:00:00 +0000");
let half = PlayerFacts {
advancements: Some((tree.clone(), progress.clone())),
..PlayerFacts::default()
};
for (advancements, expected) in [
(json!({"pack:a": true}), false),
(json!({"pack:a": false}), true),
(json!({"pack:a": {"x": true}}), true),
(json!({"pack:a": {"x": false}}), false),
(json!({"pack:a": {"y": true}}), false),
(json!({"pack:a": {"y": false}}), true),
(json!({"pack:a": {"x": true, "y": true}}), false),
// A criterion the advancement doesn't have, or an advancement
// that isn't loaded, matches nothing.
(json!({"pack:a": {"z": false}}), false),
(json!({"pack:missing": false}), false),
] {
assert_eq!(
player_matches(json!({"advancements": advancements}), &half),
expected,
"{advancements}"
);
}
progress.grant("pack:a", "y", "2026-11-04 12:00:01 +0000");
let done = PlayerFacts {
advancements: Some((tree, progress)),
..PlayerFacts::default()
};
assert!(player_matches(
json!({"advancements": {"pack:a": true}}),
&done
));
assert!(!player_matches(
json!({"advancements": {"pack:a": false}}),
&done
));
let asked = json!({"advancements": {"pack:a": false}});
assert!(!player_matches(asked, &PlayerFacts::default()));
}
#[test]
fn player_facts_are_asked_by_the_triggers_that_name_them() {
let player = |p: Value| {
json!({"player": [{"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"minecraft:type_specific/player": p}}]})
};
let tree = tree_of(&[
(
"pack:walk",
json!({"criteria": {"c": {"trigger": "minecraft:tick",
"conditions": player(json!({"stats": [{"type": "minecraft:custom",
"stat": "minecraft:walk_one_cm", "value": {"min": 1}}]}))}}}),
),
(
"pack:milestone",
json!({"criteria": {"c": {"trigger": "minecraft:location",
"conditions": player(json!({"advancements": {"pack:walk": true},
"level": {"min": 30}}))}}}),
),
(
"pack:ref",
json!({"criteria": {"c": {"trigger": "minecraft:inventory_changed",
"conditions": {"player": [{"condition": "minecraft:reference",
"name": "pack:p"}]}}}}),
),
(
"pack:recipe",
json!({"criteria": {"c": {"trigger": "minecraft:recipe_unlocked",
"conditions": {"recipe": "minecraft:iron_pickaxe"}},
"d": {"trigger": "minecraft:using_item",
"conditions": player(json!({"gamemode": ["survival"]}))}}}),
),
]);
let none = FactsAsked::default();
assert_eq!(
tree.player_facts_asked("minecraft:tick"),
FactsAsked {
stats: true,
..none
}
);
assert_eq!(
tree.player_facts_asked("minecraft:location"),
FactsAsked {
advancements: true,
level: true,
..none
}
);
assert_eq!(
tree.player_facts_asked("minecraft:inventory_changed"),
FactsAsked::ALL
);
assert_eq!(tree.player_facts_asked("minecraft:recipe_unlocked"), none);
assert_eq!(tree.player_facts_asked("minecraft:using_item"), none);
}
#[test]
fn a_command_predicate_asks_only_the_player_facts_it_names() {
let player = |p: Value| {
json!({"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"type_specific/player": p}})
};
let none = FactsAsked::default();
for (v, expected) in [
// Nothing of the player's records: nothing copied.
(
json!({"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"flags": {"is_sneaking": true}}}),
none,
),
(player(json!({"gamemode": ["survival"]})), none),
(
json!({"condition": "minecraft:random_chance", "chance": 0.5}),
none,
),
// A field named elsewhere than in a player predicate.
(
json!({"function": "minecraft:enchant_with_levels", "levels": 30,
"conditions": [{"condition": "minecraft:inverted",
"term": {"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"minecraft:effects": {"minecraft:speed": {}}}}}]}),
none,
),
(
player(json!({"level": {"min": 3}})),
FactsAsked {
level: true,
..none
},
),
// Nested in a list, under `inverted`, in an item modifier.
(
json!([{"function": "minecraft:set_count", "count": 2, "conditions": [
{"condition": "minecraft:inverted", "term": player(json!({
"stats": [], "recipes": {"minecraft:bread": true}}))}]}]),
FactsAsked {
stats: true,
recipes: true,
..none
},
),
(
json!({"condition": "minecraft:all_of", "terms": [player(json!(
{"advancements": {"minecraft:story/root": true}}))]}),
FactsAsked {
advancements: true,
..none
},
),
// A predicate file could ask anything.
(
json!({"condition": "reference", "name": "pack:p"}),
FactsAsked::ALL,
),
(
json!([{"function": "minecraft:reference", "name": "pack:m"}]),
FactsAsked::ALL,
),
] {
assert_eq!(facts_asked_in(&v), expected, "{v}");
}
}
#[test]
fn saved_dates_read_back_to_their_instant() {
for secs in [0, 951_782_400, 1_792_216_800, 4_107_542_399] {
+9 -5
View File
@@ -18,11 +18,12 @@ pub use horizon_client_render::{
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list,
model_library, models, multiline_edit, native_emissive, options_file, options_fixes,
options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -111,6 +112,7 @@ pub mod pregen;
pub mod projectiles;
pub mod raid;
pub mod random_tick_sections;
pub mod realms;
pub mod recipe_book;
pub mod recipe_book_screen;
pub mod reconnect;
@@ -128,6 +130,7 @@ pub mod sprint;
pub mod stations;
pub mod stopwatches;
pub mod structure_blocks;
pub mod studio;
pub mod text_display;
pub mod tick_chunk_membership;
pub mod ticket_levels;
@@ -136,6 +139,7 @@ pub mod toasts;
pub mod ui_theme;
pub mod vanillagen;
pub mod win_screen;
pub mod world_primitives;
pub mod world_spawn;
pub use horizon_client_core::Result;
pub use horizon_client_core::block_entity_nbt;
+26 -1
View File
@@ -227,7 +227,10 @@ impl Click {
pub fn parse(t: &Tag, kind: &str) -> Option<Self> {
let s = |key| string(t.get(key));
Some(match path(kind) {
"open_url" => Self::OpenUrl(s("url")?),
// ClickEvent.OpenUrl's url is an ExtraCodecs.UNTRUSTED_URI.
"open_url" => {
Self::OpenUrl(s("url").filter(|url| crate::component::untrusted_http_url(url))?)
}
"run_command" => Self::RunCommand(s("command")?),
"suggest_command" => Self::SuggestCommand(s("command")?),
"show_dialog" => Self::ShowDialog(t.get("dialog")?.clone()),
@@ -829,6 +832,28 @@ mod tests {
assert_eq!(key, "password");
}
#[test]
fn open_url_actions_take_only_web_links() {
let open = |url: &str| {
let action = Tag::compound([("type", s("minecraft:open_url")), ("url", s(url))]);
Action::parse(&action).and_then(|a| a.click(&[], &[]))
};
assert_eq!(
open("https://example.com/a"),
Some(Click::OpenUrl("https://example.com/a".into()))
);
assert!(open("HTTP://example.com").is_some());
for url in [
"file:///C:/Windows/System32/cmd.exe",
"\\\\attacker\\share\\x.exe",
"search-ms:query=x&crumb=location:\\\\attacker\\share",
"javascript:alert(1)",
"/usr/bin/xterm",
] {
assert_eq!(open(url), None, "{url}");
}
}
#[test]
fn actions_carry_the_input_values() {
let d = Dialog::parse(&register()).unwrap();
+330 -11
View File
@@ -90,7 +90,7 @@ fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
}
mod fluids;
pub use fluids::update_entity_fluids;
pub use fluids::{fluid_flow, update_entity_fluids};
#[cfg(test)]
mod drop_motion_tests {
@@ -1038,10 +1038,13 @@ pub fn merge_with_neighbours(
(j, i)
};
// ItemEntity.merge(toStack, fromStack, 64).
let room = max_stack(&items[to].stack).min(64) - items[to].stack.count;
let moved = room.min(items[from].stack.count);
items[to].stack.count += moved;
items[from].stack.count -= moved;
// A component limit above 64 permits an overstacked target. Vanilla's
// signed room then moves the excess back into the source stack.
let room =
i64::from(max_stack(&items[to].stack).min(64)) - i64::from(items[to].stack.count);
let moved = room.min(i64::from(items[from].stack.count));
items[to].stack.count = (i64::from(items[to].stack.count) + moved) as u32;
items[from].stack.count = (i64::from(items[from].stack.count) - moved) as u32;
let (delay, from_age, to_age) =
(items[from].pickup_delay, age(items, from), age(items, to));
let to_item = &mut items[to];
@@ -1187,6 +1190,15 @@ impl ExperienceOrb {
let sky = light >> 4;
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
}
/// `ExperienceOrb.setUnderwaterMovement`: buoyancy and drag before moving.
fn set_underwater_movement(&mut self) {
// Vanilla widens float constants before applying double arithmetic.
self.velocity = Vec3::new(
self.velocity.x * f64::from(0.99_f32),
(self.velocity.y + f64::from(5.0e-4_f32)).min(f64::from(0.06_f32)),
self.velocity.z * f64::from(0.99_f32),
);
}
pub fn tick(
&mut self,
world: &World,
@@ -1216,11 +1228,7 @@ impl ExperienceOrb {
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
self.velocity = Vec3::new(
self.velocity.x * 0.99,
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
self.set_underwater_movement();
} else if !colliding {
self.velocity.y -= self.get_gravity();
}
@@ -1285,6 +1293,8 @@ impl ExperienceOrb {
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// ExperienceOrb.tick applies block effects after moving, before drag.
let discarded = server && self.apply_lava_contact_damage(world, fluids, wanted);
// `float friction = getAirDrag()` (0.98F), the ground's block
// friction multiplied in as a float, widened once for the scale.
let mut friction = 0.98_f32;
@@ -1311,7 +1321,38 @@ impl ExperienceOrb {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
self.age < 6000
!discarded && self.age < 6000
}
/// `LavaFluid.entityInside` -> `Entity.lavaHurt`: four damage on the
/// server, through the orb's existing hurt/discard policy. This is separate
/// from the lava impulse at the start of the tick.
fn apply_lava_contact_damage(
&mut self,
world: &World,
fluids: &crate::motion::FluidResources,
wanted: Vec3,
) -> bool {
let contacts = orb_lava_contacts(self.previous, self.position, wanted, |p| {
let fluid = fluids.fluid(world.block(p.x, p.y, p.z));
if !fluid
.kind()
.same_family(horizon_world::fluid::FluidKind::Lava)
{
return None;
}
let above = fluids.fluid(world.block(p.x, p.y + 1, p.z));
Some(if fluid.kind().same_family(above.kind()) {
1.0
} else {
f64::from(fluid.own_height())
})
});
for _ in 0..contacts {
if self.hurt(4.0) {
return true;
}
}
false
}
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
/// towards the nearest side of its block (north, south, west, east or
@@ -2459,6 +2500,35 @@ mod merge_tests {
assert_eq!(items[0].age, 8, "age 7, plus the rest of this tick");
}
/// ItemEntity.merge uses signed room, including zero and negative transfers.
#[test]
fn overstacked_items_transfer_back_without_losing_merge_metadata() {
for (counts, expected) in [([80, 10], [64, 26]), ([64, 26], [64, 26])] {
let mut items = [
item("minecraft:stone", counts[0], 0.5, 41),
item("minecraft:stone", counts[1], 0.6, 7),
];
for item in &mut items {
item.stack.data_mut().set_other(
"minecraft:max_stack_size",
Some(horizon_world::nbt::Tag::Int(99)),
);
}
let before = [items[0].stack.clone(), items[1].stack.clone()];
items[1].pickup_delay = 30;
let mut removed = [false; 2];
let mut cells = ItemCells::new(&items);
merge_with_neighbours(&mut items, &mut removed, &mut cells, 0, &|_| 99);
assert_eq!([items[0].stack.count, items[1].stack.count], expected);
assert_eq!(removed, [false, false]);
assert_eq!(items[0].pickup_delay, 30);
assert_eq!(items[0].age, 8, "younger age 7, then this tick ends");
for (item, stack) in items.iter().zip(&before) {
assert!(item.stack.same_item_same_components(stack));
}
}
}
#[test]
fn items_merge_only_within_half_a_block_sideways() {
// Boxes 0.25 wide grown by 0.5: centres closer than 0.75 meet.
@@ -3832,6 +3902,72 @@ mod item_destroyed_tests {
);
}
}
#[cfg(test)]
mod orb_underwater_tests {
use super::*;
#[test]
fn matches_the_executed_java_private_method() {
// Actual 26.3 setUnderwaterMovement calls on server-level orbs,
// captured by test_orb_water_parity.py (orb-water-reference.json).
// Cover both horizontal signs, falling/rising, and the vertical cap.
let cases: [([f64; 3], [u64; 3]); 6] = [
(
[0.0, 0.0, 0.0],
[0x0000000000000000, 0x3f40624de0000000, 0x0000000000000000],
),
(
[0.1, 0.01, -0.2],
[0x3fb9581066666667, 0x3f85810625ae147b, 0xbfc9581066666667],
),
(
[-0.25, -0.03, 0.125],
[0xbfcfae1480000000, 0xbf9e353f7c851eb8, 0x3fbfae1480000000],
),
(
[0.0, 0.059, 0.0],
[0x0000000000000000, 0x3fae76c8b46d9168, 0x0000000000000000],
),
(
[0.5, 0.0595, -0.5],
[0x3fdfae1480000000, 0x3faeb851e0000000, 0xbfdfae1480000000],
),
(
[-1.0, 0.2, 1.0],
[0xbfefae1480000000, 0x3faeb851e0000000, 0x3fefae1480000000],
),
];
let actual: Vec<_> = cases
.iter()
.map(|(v, _)| {
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(v[0], v[1], v[2]),
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
orb.set_underwater_movement();
[
orb.velocity.x.to_bits(),
orb.velocity.y.to_bits(),
orb.velocity.z.to_bits(),
]
})
.collect();
let expected: Vec<_> = cases.into_iter().map(|(_, bits)| bits).collect();
assert_eq!(actual, expected);
}
}
#[cfg(test)]
mod render_light_tests {
use super::*;
@@ -3905,3 +4041,186 @@ mod render_light_tests {
}
}
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
#[cfg(test)]
mod orb_lava_tests {
use super::*;
#[test]
fn a_server_orb_takes_lava_damage_after_moving_and_discards_at_zero() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
eprintln!("orb lava fixture unavailable: prepared resources missing");
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = 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 lava = assets.registry.default_state("minecraft:lava").unwrap();
world.set(20, 4, 20, lava).unwrap();
let at = Vec3::new(20.5, 4.1, 20.5);
let mut orb = ExperienceOrb {
rotation: [0.0, 0.0],
join: Default::default(),
no_gravity: false,
id: 1,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
let mut client = orb.clone();
for expected_age in 1..=2 {
assert!(client.tick(
&world,
&facts,
&collision,
None,
false,
&mut || 0.0,
None,
&fluids
));
assert_eq!((client.health, client.age), (5, expected_age));
}
assert!(orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids
));
assert_eq!((orb.health, orb.age), (1, 1));
assert!(!orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids
));
assert_eq!((orb.health, orb.age), (-3, 2));
}
}
/// `Entity.checkInsideBlocks` and `collidedWithFluid`: fluid AABB contacts
/// along Entity.move's axis-ordered path, each block visited once.
fn orb_lava_contacts(
from: Vec3,
to: Vec3,
wanted: Vec3,
mut lava_height: impl FnMut(BlockPos) -> Option<f64>,
) -> usize {
let delta = to.subtract(from);
let mut visited = std::collections::HashSet::new();
let mut contacts = 0;
let mut remaining = 16;
let mut segment = |start: Vec3, end: Vec3, budget: i32| {
let mut iterations = 0;
let epsilon = f64::from(1.0e-5_f32);
let target = ExperienceOrb::box_at(end).deflate(Vec3::new(epsilon, epsilon, epsilon));
let _ = horizon_core::geometry::sweep_intersected_blocks(
start,
end,
target,
1_000_000,
|p, step| {
if step >= budget {
return false;
}
iterations = step;
if visited.contains(&p) {
return true;
}
if let Some(height) = lava_height(p) {
let min = Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z));
let fluid = Aabb::new(min, min.add_vector(Vec3::new(1.0, height, 1.0)));
if ExperienceOrb::box_at(start)
.collided_along_vector(end.subtract(start), &[fluid])
{
visited.insert(p);
contacts += 1;
}
}
true
},
);
iterations + 1
};
if delta.length_squared() > 0.0 {
// Direction.axisStepOrder uses the original, uncollided movement.
let order = if wanted.x.abs() < wanted.z.abs() {
[1, 2, 0]
} else {
[1, 0, 2]
};
let components = delta.components();
let mut start = from;
for axis in order {
if components[axis] != 0.0 {
let mut end = start.components();
end[axis] += components[axis];
let end = Vec3::new(end[0], end[1], end[2]);
remaining -= segment(start, end, remaining);
start = end;
}
}
} else {
remaining -= segment(from, to, remaining);
}
if remaining <= 0 {
segment(to, to, 1);
}
contacts
}
#[cfg(test)]
mod orb_lava_shape_tests {
use super::*;
fn contacts(from: Vec3, to: Vec3) -> usize {
orb_lava_contacts(from, to, to.subtract(from), |p| {
(p == BlockPos::new(0, 0, 0)).then_some(f64::from(8.0_f32 / 9.0))
})
}
#[test]
fn lava_uses_the_swept_box_and_fluid_height_not_the_feet_block() {
let side = Vec3::new(-0.2, 0.1, 0.5);
assert_eq!(contacts(side, side), 1);
let above = Vec3::new(0.5, 0.95, 0.5);
assert_eq!(contacts(above, above), 0);
let through = Vec3::new(-1.0, 0.1, 0.5);
let end = Vec3::new(2.0, 0.1, 0.5);
assert_eq!(contacts(through, end), 1);
let away = Vec3::new(-1.0, 0.1, -1.0);
assert_eq!(contacts(away, away), 0);
}
#[test]
fn axis_segments_do_not_damage_twice_for_the_same_lava_cell() {
let from = Vec3::new(0.1, 0.1, 0.1);
let to = Vec3::new(0.7, 0.2, 0.7);
assert_eq!(contacts(from, to), 1);
assert_eq!(
orb_lava_contacts(from, to, Vec3::new(0.1, 0.1, 0.9), |p| {
(p == BlockPos::new(0, 0, 0)).then_some(1.0)
}),
1
);
}
}
+64 -4
View File
@@ -26,6 +26,25 @@ struct EntityFluidWorld<'a> {
world: &'a World,
fluids: &'a FluidResources,
}
/// `FluidState.getFlow(level, pos)`: the current of the fluid at `pos`, the
/// one that pushes an item or a boat in it, normalised (zero where there is no
/// fluid or it doesn't move: a source with only sources and walls around it).
/// A falling fluid's current points down.
pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64; 3] {
let mut velocity = Vec3::ZERO;
let mut scene = EntityFluidWorld {
bounds: Aabb::new(Vec3::ZERO, Vec3::ZERO),
position: Vec3::ZERO,
eye_y: 0.0,
velocity: &mut velocity,
world,
fluids,
};
let at = BlockPos::new(pos[0], pos[1], pos[2]);
let state = fluids.fluid(world.block(pos[0], pos[1], pos[2]));
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
/// immersion heights, then the current of each fluid it sits in, added to
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
@@ -215,6 +234,45 @@ impl FluidFlowWorld for EntityFluidWorld<'_> {
#[cfg(test)]
mod tests {
use super::*;
/// `FluidState.getFlow`: water running out of a pool flows away from it,
/// while the pool itself, its edge on a solid floor and the air beside it
/// have no current.
#[test]
fn fluid_flow_runs_away_from_a_pool_and_is_zero_in_still_water() {
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 = FluidResources::new(&assets, &facts).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 16..=22 {
for z in 16..=20 {
world.set(x, 3, z, stone).unwrap();
}
}
for x in 17..=19 {
for z in 17..=19 {
world.set(x, 4, z, water).unwrap();
}
}
world.set(20, 4, 18, flowing).unwrap();
// A level-1 flow beside sources: the source is 1/9 higher to the west,
// the air to the east on stone gives nothing, so the current is +x.
assert_eq!(fluid_flow(&world, &fluids, [20, 4, 18]), [1.0, 0.0, 0.0]);
for still in [[18, 4, 18], [17, 4, 17], [21, 4, 18]] {
assert_eq!(fluid_flow(&world, &fluids, still), [0.0; 3], "{still:?}");
}
}
#[test]
fn items_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -385,10 +443,12 @@ mod tests {
None,
&fluids,
));
// Current pushes the orb, underwater drag damps before motion, then air
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
// Current pushes the orb, then underwater and air drag each widen
// their float constants before multiplying the double motion.
assert!((carried.position.x - at.x - 0.014 * f64::from(0.99_f32)).abs() < 1e-12);
assert!(
(carried.velocity.x - 0.014 * f64::from(0.99_f32) * f64::from(0.98_f32)).abs() < 1e-12
);
for _ in 0..20 {
carried.tick(
&world,
+240
View File
@@ -370,6 +370,18 @@ pub fn mob_to_nbt(
] {
put(&mut tag, key, value);
}
// Mob.addAdditionalSaveData: hasHome means radius != -1, including -2.
// Species writers below retain their own home_pos override.
let Tag::Compound(map) = &mut tag else {
unreachable!()
};
if let Some((pos, radius)) = m.ai.home.filter(|(_, radius)| *radius != -1) {
map.insert("home_radius".into(), Tag::Int(radius));
map.insert("home_pos".into(), Tag::IntArray(pos.to_vec()));
} else {
map.remove(&"home_radius".into());
map.remove(&"home_pos".into());
}
// AbstractFish/Axolotl.addAdditionalSaveData always write the flag.
// Tadpole.fromBucket is always true, and its setter ignores loaded input.
let from_bucket = match m.species() {
@@ -692,6 +704,14 @@ pub fn mob_to_nbt(
Tag::Byte(i8::from(m.extra.flag2)),
);
map.insert("TimeInOverworld".into(), Tag::Int(m.extra.counter2));
if id == "minecraft:hoglin" {
// Hoglin.addAdditionalSaveData writes it only when set.
if m.extra.cannot_be_hunted {
map.insert("CannotBeHunted".into(), Tag::Byte(1));
} else {
map.remove(&"CannotBeHunted".into());
}
}
if id == "minecraft:piglin" {
// InventoryCarrier.writeInventoryToTag.
map.insert(
@@ -1422,6 +1442,44 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
/// `Mob.readAdditionalSaveData`: negative radii retain the live homePosition.
/// Used after the command adapter restores runtime AI, as well as fresh load.
pub fn read_mob_home(m: &mut Mob, tag: &Tag) {
let radius = match tag.get("home_radius") {
Some(Tag::Float(v)) => Some(f32::from_bits(*v).floor() as i32),
Some(Tag::Double(v)) => Some(f64::from_bits(*v).floor() as i32),
other => number(other).map(|v| v as i32),
}
.unwrap_or(-1);
let pos = if radius >= 0 {
home_block_pos(tag.get("home_pos")).unwrap_or([0; 3])
} else {
m.ai.home_position()
};
m.ai.restore_home(pos, radius);
}
/// `BlockPos.CODEC` through NbtOps.getIntStream: numeric collection entries
/// use Java Number's int conversion (floating entries truncate, unlike getIntOr above).
fn home_block_pos(tag: Option<&Tag>) -> Option<[i32; 3]> {
match tag? {
Tag::IntArray(v) => v.as_slice().try_into().ok(),
Tag::ByteArray(v) if v.len() == 3 => Some([
i32::from(v[0] as i8),
i32::from(v[1] as i8),
i32::from(v[2] as i8),
]),
Tag::LongArray(v) if v.len() == 3 => Some([v[0] as i32, v[1] as i32, v[2] as i32]),
Tag::List(v) if v.len() == 3 => {
let int = |t: &Tag| match t {
Tag::Float(v) => Some(f32::from_bits(*v) as i32),
Tag::Double(v) => Some(f64::from_bits(*v) as i32),
other => number(Some(other)).map(|v| v as i32),
};
Some([int(&v[0])?, int(&v[1])?, int(&v[2])?])
}
_ => None,
}
}
/// The mob a saved compound describes. `registry` is what reads a field holding
/// a block state (an enderman's `carriedBlockState`); without one, such a field
/// is left out, as it was before it was modelled.
@@ -1437,6 +1495,7 @@ pub fn mob_from_nbt(
let mut m = Mob::new(id, kind, position, yaw);
m.velocity = motion(tag);
m.head_pitch = pitch;
read_mob_home(&mut m, tag);
m.on_ground = flag(tag, "OnGround");
m.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
// LivingEntity.readAdditionalSaveData: the saved attribute bases, then
@@ -1687,6 +1746,9 @@ pub fn mob_from_nbt(
) {
m.extra.flag2 = flag(tag, "IsImmuneToZombification");
m.extra.counter2 = number(tag.get("TimeInOverworld")).unwrap_or(0) as i32;
if m.species() == crate::mobs::Species::Hoglin {
m.extra.cannot_be_hunted = flag(tag, "CannotBeHunted");
}
if m.species() == crate::mobs::Species::Piglin
&& let Some(Tag::List(items)) = tag.get("Inventory")
{
@@ -5090,6 +5152,42 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Hoglin.readAdditionalSaveData` and `addAdditionalSaveData`:
/// `CannotBeHunted` (a bastion's hoglins) stays with the hoglin, and such a
/// hoglin is no piglin's prey (`Hoglin.canBeHunted`).
#[test]
fn a_hoglin_keeps_cannot_be_hunted_and_is_no_prey() {
use crate::mobs::Species;
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = Species::Hoglin;
ty.id = "minecraft:hoglin".into();
let types = HashMap::from([("minecraft:hoglin".to_owned(), kind)]);
let hoglin = |cannot: Option<i8>| {
let mut tag = Tag::compound([
("id", string("minecraft:hoglin")),
("Pos", doubles(&[0.0, 64.0, 0.0])),
]);
if let (Some(b), Tag::Compound(map)) = (cannot, &mut tag) {
map.insert("CannotBeHunted".into(), Tag::Byte(b));
}
tag
};
let raw = hoglin(Some(1));
let protected = mob_from_nbt(&raw, &types, 1, None).unwrap();
assert!(protected.extra.cannot_be_hunted);
assert!(!protected.seen(None).huntable);
let saved = mob_to_nbt(&protected, [0; 4], Some(&raw), None);
assert_eq!(saved.get("CannotBeHunted"), Some(&Tag::Byte(1)));
for raw in [hoglin(None), hoglin(Some(0))] {
let prey = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert!(!prey.extra.cannot_be_hunted);
assert!(prey.seen(None).huntable);
let saved = mob_to_nbt(&prey, [0; 4], Some(&raw), None);
assert_eq!(saved.get("CannotBeHunted"), None);
}
}
/// `Mob.addAdditionalSaveData` and `readAdditionalSaveData`:
/// `DeathLootTable` and a set `DeathLootTableSeed` stay with the mob.
#[test]
@@ -6602,3 +6700,145 @@ mod axolotl_memory_ttl_tests {
}
}
}
#[cfg(test)]
mod generic_home_nbt_tests {
use super::*;
use crate::mobs::Species;
fn mob(species: Species, id: &str) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.species = species;
ty.id = id.into();
Mob::new(1, kind, Vec3::new(3.5, 201.0, 0.5), 0.0)
}
fn stale() -> Tag {
Tag::compound([
("home_radius", Tag::Int(99)),
("home_pos", Tag::IntArray(vec![90, 91, 92])),
("audit:unrelated", Tag::Int(7)),
])
}
#[test]
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
let mut m = mob(Species::Pig, "minecraft:pig");
let raw = stale();
for raw in [None, Some(&raw)] {
for radius in [0, 5, -2, -1] {
m.ai.home = Some(([4, 5, 6], radius));
let saved = mob_to_nbt(&m, [0; 4], raw, None);
if radius == -1 {
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
} else {
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(radius)));
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
}
}
m.ai.home = None;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
assert!(saved.get("home_radius").is_none());
assert!(saved.get("home_pos").is_none());
if raw.is_some() {
assert_eq!(saved.get("audit:unrelated"), Some(&Tag::Int(7)));
}
}
}
#[test]
fn negative_home_updates_retain_position_without_replacing_runtime_ai() {
let mut m = mob(Species::Pig, "minecraft:pig");
m.ai.home = Some(([4, 5, 6], 5));
m.ai.jump = true;
m.ai.speed = 0.37;
m.ai.no_action_time = 29;
for radius in [-2, -1, -2] {
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(radius))]));
assert_eq!(m.ai.home, (radius != -1).then_some(([4, 5, 6], radius)));
assert_eq!(m.ai.home_position(), [4, 5, 6]);
assert!(m.ai.jump);
assert_eq!((m.ai.speed, m.ai.no_action_time), (0.37, 29));
}
// Runtime setters still assign ai.home directly. clearHome must retain
// that latest live position, rather than a previous load's position.
m.ai.home = Some(([7, 8, 9], 5));
m.ai.clear_home();
assert!(m.ai.home.is_none());
read_mob_home(&mut m, &Tag::compound([("home_radius", Tag::Int(-2))]));
assert_eq!(m.ai.home, Some(([7, 8, 9], -2)));
}
#[test]
fn fresh_home_loads_apply_radius_and_position_codec_defaults() {
let m = mob(Species::Pig, "minecraft:pig");
let types = [(m.kind.id.clone(), m.kind.clone())].into();
for radius in [0, 5, -1, -2] {
for pos in [
None,
Some(Tag::IntArray(vec![4, 5, 6])),
Some(Tag::IntArray(vec![4, 5])),
] {
let mut raw = Tag::compound([
("id", string("minecraft:pig")),
("Pos", doubles(&[3.5, 201.0, 0.5])),
("home_radius", Tag::Int(radius)),
]);
if let Some(pos) = pos.clone() {
put(&mut raw, "home_pos", pos);
}
let loaded = mob_from_nbt(&raw, &types, 1, None).unwrap();
let expected_pos = if radius >= 0 && pos == Some(Tag::IntArray(vec![4, 5, 6])) {
[4, 5, 6]
} else {
[0; 3]
};
assert_eq!(
loaded.ai.home,
(radius != -1).then_some((expected_pos, radius))
);
}
}
let mut loaded = m;
read_mob_home(
&mut loaded,
&Tag::compound([
("home_radius", Tag::Float((-2.5_f32).to_bits())),
("home_pos", Tag::IntArray(vec![4, 5, 6])),
]),
);
assert_eq!(loaded.ai.home, Some(([0; 3], -3)));
read_mob_home(
&mut loaded,
&Tag::compound([
("home_radius", Tag::Int(5)),
("home_pos", doubles(&[-2.5, 5.5, 6.5])),
]),
);
assert_eq!(loaded.ai.home, Some(([-2, 5, 6], 5)));
}
#[test]
fn turtle_and_creaking_keep_their_own_home_position_writer() {
let raw = stale();
for (species, id) in [
(Species::Turtle, "minecraft:turtle"),
(Species::Creaking, "minecraft:creaking"),
] {
let mut m = mob(species, id);
m.extra.home = Some([4, 5, 6]);
for raw in [None, Some(&raw)] {
for home in [None, Some(([7, 8, 9], 5))] {
m.ai.home = home;
let saved = mob_to_nbt(&m, [0; 4], raw, None);
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 5, 6])));
assert_eq!(
saved.get("home_radius"),
home.map(|(_, r)| Tag::Int(r)).as_ref()
);
}
}
}
}
}
+180 -80
View File
@@ -120,6 +120,7 @@ mod enchant_effects;
mod end;
mod entity;
mod entity_culling;
mod entity_sounds;
mod event_loop;
mod events;
mod execution_role;
@@ -206,6 +207,7 @@ pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_chunk_tickets;
mod native_claimed_items;
pub mod native_client_services;
mod native_components;
mod native_configs;
mod native_criteria;
@@ -226,6 +228,7 @@ mod native_level_refs;
mod native_lifecycle;
mod native_living_refs;
mod native_machines;
mod native_menu_stacks;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
@@ -238,6 +241,7 @@ mod native_projectiles;
mod native_quick_move;
mod native_recipes;
mod native_reload_listeners;
mod native_scene;
mod native_server;
mod native_shaders;
mod native_ticks;
@@ -304,6 +308,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod sheep_dye;
mod shelf;
mod shield;
mod shoulders;
@@ -355,6 +360,7 @@ mod win;
mod wind;
mod wither;
mod world_loading;
mod world_primitives;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
@@ -419,6 +425,7 @@ use horizon_world::{
},
registry::BlockStateId,
};
pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
@@ -427,7 +434,7 @@ pub use native_configs::{NativeConfig, NativeConfigFile, NativeConfigKind};
pub use native_criteria::NativeCriterion;
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeItemEntity, NativeShake};
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_reload_listeners::NativeReloadListener;
@@ -2371,6 +2378,9 @@ struct Game<'a> {
world_renderer_ready: bool,
collision: &'a BlockCollisionRegistry<'a>,
motion: &'a MotionResources,
/// This world's frozen damage definitions and reloadable tag snapshot.
pub(super) health_resources: horizon_client::health::HealthResources,
damage_tag_generation: u64,
/// This game's resource packs (`reload_packs` replaces them).
pack: PackResources,
/// The title and menu bundle (GUI atlas, font and panorama).
@@ -2891,6 +2901,9 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// Vehicles that gained a rider this tick whose `START_RIDING_TRIGGER` was
/// not fired yet (`game/boarding.rs`), for a guest's turn.
started_riding: Vec<u32>,
/// `Entity.boardingCooldown` of the acting player, and its vehicle at the
/// last tick (`game/boarding.rs`).
boarding_cooldown: i32,
@@ -3076,6 +3089,7 @@ struct Game<'a> {
text_selection: horizon_client::clipboard::AppendSelection,
/// The world was saved for the application ending (`save_for_exit`).
saved_for_exit: bool,
native_client_stopped: bool,
pending_save: Option<saving::ClosingSave>,
/// The last world frame reached the swap chain. A window the compositor
/// stops showing gets no frames, and the event loop must not spin on it.
@@ -4356,9 +4370,7 @@ impl Game<'_> {
&& (alive || item.age == 6000 && item.position.y > -128.0)
&& item.ticks.is_multiple_of(rate)
{
let catalog = world_items!(self);
let max_stack =
|stack: &ItemStack| catalog.get(&stack.item).map_or(64, |i| i.max_stack).max(1);
let max_stack = |stack: &ItemStack| self.stack_max_size(stack).max(1);
horizon_client::entities::merge_with_neighbours(
&mut items,
&mut removed,
@@ -5178,15 +5190,19 @@ impl Game<'_> {
let sent = self.lan_broadcast_changes();
self.with_level(|b, level, ticks| {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
self.process_level_events()?;
self.sync_moving_pistons();
Ok(sent)
}
/// `ServerLevel.tick`: moving block entities push after entity travel.
fn tick_moving_pistons(&mut self) -> Result<()> {
self.with_level(|b, level, ticks| b.tick_block_entities(level, ticks));
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
Ok(sent)
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
@@ -7072,12 +7088,7 @@ impl Game<'_> {
return;
};
self.start_using_item();
let at = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
self.audio
.play(&instrument.sound, at, instrument.range / 16.0, 1.0);
self.play_player_bound_sound(&instrument.sound, 2, instrument.range / 16.0, 1.0, true);
self.post_entity_event(
"minecraft:instrument_play",
horizon_client::mobs::Who::Player,
@@ -7890,6 +7901,7 @@ impl Game<'_> {
self.apply_late_wind_pushes();
}
if running {
horizon_client::spans::time("tick_moving_pistons", || self.tick_moving_pistons())?;
horizon_client::spans::time("tick_block_entities", || self.tick_block_entities())?;
horizon_client::spans::time("tick_sculk_entities", || self.tick_sculk_entities());
horizon_client::spans::time("tick_sculk_blocks", || self.tick_sculk_blocks())?;
@@ -8593,11 +8605,11 @@ impl Game<'_> {
self.frame_graph
.draw(self.settings.horizon.overlay, &mut batch, &self.gui.atlas);
self.draw_pad_cursor(&mut batch);
self.resolve_inline_profiles(&batch.inline_profiles);
batch
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = HudBatch::new(size);
batch.glint_colour = self.settings.extras.pvp.tweaks.glint_texel();
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
+ self.clock.fraction() as f32)
@@ -8749,7 +8761,10 @@ impl Game<'_> {
}
});
self.gui_layer("minecraft:effects", &mut batch, |g, batch| {
g.draw_effect_icons(batch);
// The PvP & HUD effects widget takes the icons' place.
if !g.pvp_effects_shown() {
g.draw_effect_icons(batch);
}
});
// Hud: the action bar and titles draw over the hotbar (nextStratum).
self.gui_layer("minecraft:boss_overlay", &mut batch, |g, batch| {
@@ -11693,6 +11708,25 @@ impl Game<'_> {
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
let names = if self.settings.improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
};
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -11718,7 +11752,7 @@ impl Game<'_> {
d.length_squared() <= 1024.0
})
.filter_map(|(pos, stage)| {
let name = DESTROY_STAGES.get(stage as usize)?;
let name = names.get(stage as usize)?;
let r = self.gui.atlas.sprites.get(*name)?;
Some((pos, [r.u0, r.v0]))
})
@@ -12274,7 +12308,7 @@ impl Game<'_> {
"first_quarter",
"waxing_gibbous",
];
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
let phase = phases[(self.view_day_time() / 24000).rem_euclid(8) as usize];
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
@@ -12459,16 +12493,19 @@ impl Game<'_> {
self.share_spawn_conditions();
self.score = Default::default();
self.fns = Default::default();
self.world_tables = self.build_world_tables();
(self.world_tables, self.health_resources) =
self.build_world_tables(false, &level.enabled_features)?;
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
// Native tag reads use the lock to tell that the tag tables are ready.
self.session_lock = Some(session_lock);
// The registries a preload or an earlier world read are not this
// world's packs': a world with packs sends its own (their dialogs,
// recipes and advancements). For a world without packs, `sync_data`
// drops what was read with packs.
if self.world_tables.is_some() {
lan::forget_sync_data();
}
// Keep the no-pack preload; a world's packs replace it atomically.
// A later /reload keeps this session's synchronized registries.
lan::open_world_sync_data(
&self.options.resources,
&pack_files(&self.world_tables),
&self.health_resources.damage,
)
.map_err(|e| format!("world registries: {e}"))?;
// Warm the loot data with this world's packs before chunks decode
// (`save::roll_loaded_loot` reads the cached copy).
let _ = horizon_client::structure_blocks::world_loot_data(
@@ -12757,6 +12794,10 @@ impl Game<'_> {
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
if self.native_window() && !self.native_client_stopped {
self.native_client_stopped = true;
self.native_client_stopping();
}
// Native application termination can bypass CloseRequested. Finish any
// save screen synchronously here, before the process can go away.
while self.pending_save.is_some() {
@@ -12964,15 +13005,13 @@ impl Game<'_> {
}
let amount = ((damage / 4.0) as u32).max(1);
let name = self
.motion
.health
.health_resources
.damage
.definition(source)
.map(|d| d.name.clone())
.unwrap_or_default();
let fire = self
.motion
.health
.health_resources
.damage
.in_tag(source, "minecraft:is_fire");
let mut broke = false;
@@ -13616,9 +13655,14 @@ impl Game<'_> {
let want = sound.trim_start_matches("minecraft:");
let mut keep = Vec::with_capacity(capture.len());
for entry in capture.drain(..) {
let name = entry.0.trim_start_matches("minecraft:");
let name = entry.event.trim_start_matches("minecraft:");
if name == want || name == "block.fire.extinguish" {
self.guest_player_sounds.push(entry);
self.guest_player_sounds.push((
entry.event,
entry.position,
entry.volume,
entry.pitch,
));
} else {
keep.push(entry);
}
@@ -14410,8 +14454,13 @@ impl Game<'_> {
}
_ => ItemPose::Swing,
};
let m = to_world
let mut m = to_world
* horizon_client::first_person::item_pose(left, inverse_height, attack, pose);
// PvP & HUD's shield size (100% is vanilla's), toward the hand holding it.
let shield_size = self.settings.extras.pvp.tweaks.shield_size;
if shield_size != 100 && &*stack.item == "minecraft:shield" {
m *= glam::Mat4::from_scale(glam::Vec3::splat(f32::from(shield_size) / 100.0));
}
// Seeded by the player id plus the context ordinal.
let (context, seed) = if left {
(
@@ -14633,7 +14682,7 @@ impl Game<'_> {
alpha: f64,
) -> Vec<horizon_client::weather::Vertex> {
use horizon_client::weather;
let (intensity, _) = self.weather_levels(alpha as f32);
let (intensity, _) = self.view_weather(alpha as f32);
if intensity <= 0.0 {
return Vec::new();
}
@@ -15961,8 +16010,8 @@ impl Game<'_> {
);
// visual/water_fog_color: no dimension sets it, its default is #050533.
let water_fog = blend(|b| Some(b.water_fog), [0.0; 3]);
let mut sky = environment.sample(self.world.day_time, Some(biome_sky), Some(biome_fog));
let (rain, thunder) = self.weather_levels(alpha as f32);
let mut sky = environment.sample(self.view_day_time(), Some(biome_sky), Some(biome_fog));
let (rain, thunder) = self.view_weather(alpha as f32);
horizon_client::environment::apply_weather(&mut sky, rain, thunder);
self.end_flash_sky(&mut sky, alpha);
if self.flash > 0 && !self.settings.file.bool("hideLightningFlashes") {
@@ -15997,6 +16046,11 @@ impl Game<'_> {
Vec::new()
};
let view = horizon_client::camera::view_matrix(view_yaw, view_pitch);
let world_primitives = if self.title.is_none() {
self.world_primitive_geometry(camera, alpha, view)
} else {
Default::default()
};
let at = camera.to_array();
// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown.
let mut crosshair = horizon_client::gizmos::Gizmos::default();
@@ -16076,8 +16130,6 @@ impl Game<'_> {
} else {
Vec::new()
};
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
let title = self.shows_panorama();
let (yaw, pitch) = if title {
// PanoramaRenderer: the title cube spins at 0.1 degrees per tick, pitched 10 degrees.
@@ -16096,8 +16148,6 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let bob = if title {
glam::Mat4::IDENTITY
} else {
@@ -16108,7 +16158,6 @@ impl Game<'_> {
} else {
self.spinning_matrix(camera_alpha as f32)
};
let requested_post_effects = self.requested_post_effects();
let terrain_distance = self.terrain_distance();
let distance = (terrain_distance * 16) as f32;
let depth_far =
@@ -16139,6 +16188,14 @@ impl Game<'_> {
dimension: self.dim.id().to_string(),
}
});
// GUI hooks project against this frame's camera, including its bob/spin and FOV.
let mut hud = horizon_client::spans::time("hud", || self.hud(size));
self.flush_ime(&mut hud);
self.native_gui_render_post(&mut hud, self.ui.hide_gui, self.focused);
self.resolve_inline_profiles(&hud.inline_profiles);
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let requested_post_effects = self.requested_post_effects();
self.view_frustum = self
.renderer
.as_ref()
@@ -16220,6 +16277,7 @@ impl Game<'_> {
if !title {
self.end_geometry(camera, &mut entities);
}
self.outline_fill(&outline, camera, &mut entities);
let weather = if !title && self.can_have_weather() && self.settings.horizon.weather {
horizon_client::spans::time("weather", || self.weather_geometry(camera, alpha))
} else {
@@ -16307,6 +16365,7 @@ impl Game<'_> {
outline,
outline_after_terrain,
outline_high_contrast: self.settings.file.bool("highContrastBlockOutline"),
pvp: self.settings.extras.pvp.frame(),
debug_lines: &debug_lines,
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
@@ -16345,6 +16404,7 @@ impl Game<'_> {
},
additive: &additive,
see_through: &see_through,
world_primitives: world_primitives.batch(),
shadow_only: &shadow_only,
celestial: if self.settings.horizon.sky || title {
&celestial
@@ -16508,7 +16568,9 @@ impl Game<'_> {
|| self.dialog_open()
|| (self.screen == Screen::Win && self.win_screen_pauses())));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
let mut radius = (blurred && radius >= 1.0).then_some(radius);
self.native_menu_background(&mut radius);
radius.filter(|r| r.is_finite() && (1.0..=64.0).contains(r))
}
/// `LevelExtractor` / `LevelRenderer`: the world border wall this frame.
fn border_draw(
@@ -18943,6 +19005,14 @@ impl Game<'_> {
event_loop.exit();
return;
}
// A Realm's address came back (GetServerDetailsTask hands over to ConnectScreen).
if let Some(action) = self.title.as_mut().and_then(|t| t.take_connect_request())
&& let Err(error) = self.title_action(event_loop, action)
{
self.error = Some(error);
event_loop.exit();
return;
}
horizon_client::spans::time("server_packs", || self.tick_server_packs());
horizon_client::spans::time("control", || self.poll_control(event_loop));
if self.pending_save.is_some() {
@@ -19051,7 +19121,7 @@ impl<'a> Game<'a> {
&gui.atlas.sprites,
&gui.atlas.translucent,
);
Ok(Game {
let mut game = Game {
world_biomes: None,
world_biomes_base: None,
res: res.clone(),
@@ -19070,6 +19140,8 @@ impl<'a> Game<'a> {
world_renderer_ready: false,
collision: res.collision(),
motion: res.motion(),
health_resources: res.motion().health.clone(),
damage_tag_generation: 1,
pack,
gui,
fog: Default::default(),
@@ -19380,6 +19452,7 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
started_riding: Vec::new(),
boarding_cooldown: 0,
riding_at_tick: None,
rotation_forced: false,
@@ -19481,6 +19554,7 @@ impl<'a> Game<'a> {
clipboard: Default::default(),
text_selection: Default::default(),
saved_for_exit: false,
native_client_stopped: false,
pending_save: None,
frame_drawn: true,
world_icon: None,
@@ -19552,7 +19626,11 @@ impl<'a> Game<'a> {
camera_type: Default::default(),
fangs: Default::default(),
player_model: None,
})
};
if game.native_window() {
game.native_client_start();
}
Ok(game)
}
}
@@ -19893,7 +19971,11 @@ impl Game<'_> {
/// Load the world's data packs' content tables (`None`: the world has no
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
/// so it runs once per world open and on `/reload`.
fn build_world_tables(&self) -> Option<WorldTables> {
fn build_world_tables(
&self,
reload: bool,
enabled_features: &[String],
) -> Result<(Option<WorldTables>, horizon_client::health::HealthResources)> {
// The ask of this load, both here and on `/reload`: a native mod
// recomputes the pack files it contributes every time.
let mut contributed = Vec::new();
@@ -19902,31 +19984,27 @@ impl Game<'_> {
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
enabled_features,
contributed,
);
if files.is_empty() {
return None;
}
let overlay = horizon_world::loot::PackOverlay { files: &files };
let root = &self.options.resources;
let items = match Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay) {
Ok(items) => items,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
let base = if reload {
&self.health_resources
} else {
&self.motion.health
};
let health = base.with_packs(root, &overlay, reload)?;
if files.is_empty() {
return Ok((None, health));
}
let mut items = Items::load(root, self.pack.assets.registry, self.res.facts(), &overlay)?;
// Item damage predicates use the same frozen registry as player hurt.
Arc::make_mut(&mut items.enchantments).bind_damage_catalog(&health.damage);
// The recipe tables, the stations and the recipe book read one folder:
// hold its files so it is read once.
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
let recipes = match Recipes::load(root, &items, &overlay) {
Ok(recipes) => recipes,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
};
let recipes = Recipes::load(root, &items, &overlay)?;
let stations = horizon_client::stations::StationData::load(root, &items, &overlay);
let mut data_map_types = Vec::new();
self.native_data_map_types(&mut data_map_types);
@@ -19943,24 +20021,21 @@ impl Game<'_> {
.flatten()
.cloned()
.collect();
let suffocation = match horizon_client::mobs::suffocation::bind(
let suffocation = horizon_client::mobs::suffocation::bind(
self.res.core.collision.clone(),
&causes_suffocation,
) {
Ok(binding) => binding,
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return None;
}
};
Some(WorldTables {
suffocation,
files,
items,
recipes,
stations,
data_maps,
})
)?;
Ok((
Some(WorldTables {
suffocation,
files,
items,
recipes,
stations,
data_maps,
}),
health,
))
}
/// Reload the content tables with the world's packs, as
/// `/reload` reloads a server's (`ReloadableServerResources`).
@@ -19968,7 +20043,17 @@ impl Game<'_> {
if self.remote.is_some() {
return;
}
self.world_tables = self.build_world_tables();
match self.build_world_tables(true, &self.level.enabled_features) {
Ok((tables, health)) => {
self.world_tables = tables;
self.health_resources = health;
}
Err(e) => {
log::warn!("Horizon: the world's data packs are not applied: {e}");
return;
}
}
self.damage_tag_generation = self.damage_tag_generation.wrapping_add(1);
self.recipe_tables.categories = None;
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
@@ -19991,7 +20076,13 @@ impl Game<'_> {
}
}
}
None => lan::forget_sync_data(),
None => {
if let Err(e) =
lan::reload_cached_sync_data(&self.options.resources, &self.pack_files())
{
log::warn!("Horizon: the cached world data is not reloaded: {e}");
}
}
}
let overlay = pack_files(&self.world_tables);
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
@@ -21023,6 +21114,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
outline: Vec::new(),
outline_after_terrain: false,
outline_high_contrast: false,
pvp: Default::default(),
debug_lines: &[],
debug_lines_on_top: &[],
debug_crosshair: &[],
@@ -21045,6 +21137,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -21177,6 +21270,12 @@ fn start_game<'a>(
Some(folder) => {
let (seed, creative) = (game.options.seed, game.options.creative);
if std::env::var_os("HORIZON_COMMAND_SCRIPT").is_some() {
// The loaded-command fixture pins the server's valid minimum.
// Client options reject simulationDistance2 (their UI starts at5),
// so an options.txt value cannot establish this prerequisite.
if std::env::var_os("HORIZON_HARNESS_LOADED_FIXTURE").is_some() {
game.settings.simulation_distance = 2;
}
// Headless, but with the same server and memory connection as play.
game.open_integrated(&folder, None, Some(seed), creative, None)?;
} else {
@@ -21476,6 +21575,7 @@ impl Loading {
event_loop.exit();
return;
}
self.poll_menu_realms(event_loop);
if let Some(w) = &self.window {
if self.options.as_ref().is_some_and(|o| o.offscreen) {
self.draw();
+181 -47
View File
@@ -205,15 +205,9 @@ impl adv::World for View<'_, '_> {
let l = self.0.world.light(p[0], p[1], p[2]);
i32::from((l >> 4).max(l & 15))
}
/// `CampfireBlock.isSmokeyPos`: share the beehive smoke test.
fn smokey(&self, p: [i32; 3]) -> bool {
(1..=5).any(|d| {
self.0
.res
.facts()
.get(self.0.world.block(p[0], p[1] - d, p[2]))
.name
.ends_with("campfire")
})
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
}
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
let items = &self.0.res.items();
@@ -224,12 +218,10 @@ impl adv::World for View<'_, '_> {
"worldgen/biome" => self.0.res.mob_data().biome_has(tag, id),
"damage_type" => self
.0
.res
.stations()
.enchantments
.damage_tags
.get(tag)
.is_some_and(|t| t.contains(id)),
.health_resources
.damage
.id(id)
.is_some_and(|source| self.0.health_resources.damage.in_tag(source, tag)),
_ => false,
}
}
@@ -448,11 +440,94 @@ impl Game<'_> {
pub(super) fn player_entity(&self) -> EntityView {
self.player_entity_looking(true)
}
/// [`Self::entity_view_of`] for testing `predicate` (a predicate, a list
/// of conditions or an item modifier): the player with the level,
/// statistics, advancements and recipes it may ask for, and nothing
/// copied when it asks for none.
pub(super) fn entity_view_asked(
&self,
t: super::commands::Target,
predicate: &Value,
) -> EntityView {
self.entity_view_asking(t, adv::facts_asked_in(predicate))
}
/// [`Self::entity_view_of`] with the player's records `asked` names.
pub(super) fn entity_view_asking(
&self,
t: super::commands::Target,
asked: adv::FactsAsked,
) -> EntityView {
use super::commands::Target;
let mut view = self.entity_view_of(t);
match t {
Target::Player => view.player_facts = self.player_facts(asked),
Target::Guest(conn) => view.player_facts = self.guest_facts(conn, asked),
_ => {}
}
view
}
/// [`Self::player_facts`] of a server's player who is not in place
/// (selectors leave out the one in place): its own records, kept on the
/// hub while another acts.
fn guest_facts(&self, conn: u32, asked: adv::FactsAsked) -> adv::PlayerFacts {
let Some(host) = self.lan.as_ref() else {
return adv::PlayerFacts::default();
};
let Some(g) = host
.hub
.guest(conn)
.filter(|_| self.lan_acting != Some(conn))
else {
return adv::PlayerFacts::default();
};
let stats = (host.play.get(&conn).map(|p| &p.stats)).filter(|s| s.loaded);
adv::PlayerFacts {
level: asked.level.then_some(g.xp_level),
stats: stats.filter(|_| asked.stats).map(|s| s.values.clone()),
advancements: asked
.advancements
.then(|| (self.adv.tree.clone(), g.advancements.clone())),
recipes: asked.recipes.then(|| g.recipe_book.recipes.clone()),
}
}
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
/// looks at only when one of them may ask (a ray against every entity
/// otherwise).
pub(super) fn player_entity_for(&self, trigger: &str) -> EntityView {
self.player_entity_looking(self.adv.tree.may_look(trigger))
/// looks at, and its level, statistics, advancements and recipes, only
/// when one of them may ask (a ray against every entity and copies of
/// the player's records otherwise), and its compound only when one of
/// them may test `nbt`.
pub(super) fn player_entity_for(&mut self, trigger: &str) -> EntityView {
let mut me = self.player_entity_for_any(&[trigger]);
if self.adv.tree.may_test_nbt(trigger) {
me.nbt = self.player_nbt();
}
me
}
/// [`Self::player_entity_for`] for the criteria of several triggers,
/// without the player's compound (building it needs `&mut self`).
pub(super) fn player_entity_for_any(&self, triggers: &[&str]) -> EntityView {
let tree = &self.adv.tree;
let mut me = self.player_entity_looking(triggers.iter().any(|t| tree.may_look(t)));
let asked = (triggers.iter()).fold(adv::FactsAsked::default(), |all, t| {
all.or(tree.player_facts_asked(t))
});
me.player_facts = self.player_facts(asked);
me
}
/// The player's level, statistics, advancements and recipes that
/// `asked` names, for `PlayerPredicate`; records not loaded stay `None`.
fn player_facts(&self, asked: adv::FactsAsked) -> adv::PlayerFacts {
if self.player.is_none() {
return adv::PlayerFacts::default();
}
adv::PlayerFacts {
level: asked.level.then_some(self.experience.level),
stats: (asked.stats && self.stats.loaded).then(|| self.stats.values.clone()),
advancements: (asked.advancements && self.adv.loaded)
.then(|| (self.adv.tree.clone(), self.adv.progress.clone())),
recipes: asked
.recipes
.then(|| self.level.recipe_book.recipes.clone()),
}
}
/// The player as predicates see it, with what it looks at when `looking`.
pub(super) fn player_entity_looking(&self, looking: bool) -> EntityView {
@@ -565,6 +640,24 @@ impl Game<'_> {
armor.damage = m.extra.body_damage.max(0) as u32;
item_view(&armor)
});
let passengers: Vec<EntityView> = self
.mobs
.iter()
.filter(|r| r.extra.mount == Some(m.id))
.map(|r| self.mob_entity(r))
.chain(self.rider_entity(m.id))
.collect();
// `NbtPredicate.getEntityTagToCompare`: the mob as saved, with the
// mobs riding it under `Passengers`, when a criterion may ask.
let nbt = self.adv.tree.tests_nbt().then(|| {
let uuid = self.persist.uuids.get(&m.id).copied().unwrap_or_default();
let raw = self.persist.raw.get(&m.id);
let registry = Some(self.pack.assets.registry);
let mut tag = horizon_client::entity_nbt::mob_to_nbt(m, uuid, raw, registry);
let riders = passengers.iter().filter_map(|r| r.nbt.clone()).collect();
super::persist::add_passengers(&mut tag, riders);
tag
});
EntityView {
kind: m.kind.id.clone(),
position: [m.position.x, m.position.y, m.position.z],
@@ -576,13 +669,8 @@ impl Game<'_> {
.map(|(id, value)| (id.to_owned(), value))
.into_iter()
.collect(),
passengers: self
.mobs
.iter()
.filter(|r| r.extra.mount == Some(m.id))
.map(|r| self.mob_entity(r))
.chain(self.rider_entity(m.id))
.collect(),
passengers,
nbt,
..Default::default()
}
}
@@ -897,6 +985,10 @@ impl Game<'_> {
let tree = self.adv.tree.clone();
let index = self.inventory_index();
let list = index.candidates(stacks.iter().map(|s| s.id.as_str()));
if list.is_empty() {
return;
}
let me = self.player_entity_for("minecraft:inventory_changed");
self.trigger_criteria(&tree, &list, move |c, w| {
let slots = &c["slots"];
let range = |k: &str, v: f64| {
@@ -917,7 +1009,19 @@ impl Game<'_> {
for s in &stacks {
wanted.retain(|p| !adv::item(p, s, w));
}
// `InventoryChangeTrigger.TriggerInstance`'s `player`
// (`ContextAwarePredicate`), tested by `SimpleCriterionTrigger.trigger`.
wanted.is_empty()
&& adv::conditions(
&c["player"],
&adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
},
w,
)
});
}
/// `PlayerTrigger.LOCATION` every second, and the other per-tick checks.
@@ -1012,23 +1116,7 @@ impl Game<'_> {
let target = target.clone();
let blow = blow.cloned();
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let ectx = adv::Entities {
this: Some(&target),
..pctx
};
let blow_ok = c["killing_blow"].is_null()
|| blow
.as_ref()
.is_some_and(|d| adv::damage_source(&c["killing_blow"], d, w, me.position));
adv::conditions(&c["player"], &pctx, w)
&& adv::conditions(&c["entity"], &ectx, w)
&& blow_ok
adv::killed(c, &me, &target, blow.as_ref(), w)
});
}
/// `KilledByArrowTrigger`: what the arrow killed and the weapon it came from.
@@ -1414,11 +1502,10 @@ impl Game<'_> {
) {
let target = if thrower == self.acting_uuid() {
// The player is the entity of both triggers.
let tree = &self.adv.tree;
let me = self.player_entity_looking(
tree.may_look("minecraft:thrown_item_picked_up_by_entity")
|| tree.may_look("minecraft:thrown_item_picked_up_by_player"),
);
let me = self.player_entity_for_any(&[
"minecraft:thrown_item_picked_up_by_entity",
"minecraft:thrown_item_picked_up_by_player",
]);
self.trigger_item_entity("minecraft:thrown_item_picked_up_by_entity", stack, &me);
me
} else if let Some(id) = self.mob_by_uuid(thrower) {
@@ -1734,6 +1821,18 @@ impl Game<'_> {
&& adv::conditions(&c["player"], &pctx, w)
});
}
/// `Entity.startRiding`'s `sendEventAndTriggers`: `START_RIDING_TRIGGER`
/// for every `ServerPlayer` in `entityToRide.getIndirectPassengersStream()`.
pub(super) fn trigger_started_riding(&mut self, vehicle: u32) {
let mounts: Vec<(u32, Option<u32>)> =
self.mobs.iter().map(|m| (m.id, m.extra.mount)).collect();
let aboard = super::boarding::passenger_vehicles(vehicle, &mounts);
self.for_each_player(|g| {
if g.riding.is_some_and(|v| aboard.contains(&v)) {
g.trigger_player("minecraft:started_riding");
}
});
}
/// A trigger with only the player condition (slept_in_bed, started_riding).
pub(super) fn trigger_player(&mut self, name: &str) {
let me = self.player_entity_for(name);
@@ -2240,6 +2339,41 @@ mod tests {
assert!(!tree.may_look("minecraft:tick"));
}
#[test]
fn nbt_triggers_are_the_ones_that_ask() {
let criterion = |trigger: &str, conditions: serde_json::Value| adv::Criterion {
name: "c".into(),
trigger: trigger.into(),
conditions: horizon_world::lazy_json::LazyJson::new(&conditions),
};
let nbt = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:nbt": "{Variant:1}"}}]});
let reference = serde_json::json!({"player": [{"condition": "minecraft:reference",
"name": "pack:saved"}]});
let plain = serde_json::json!({"entity": [{"condition": "minecraft:entity_properties",
"entity": "this", "predicate": {"minecraft:flags": {"is_baby": true}}}]});
let mut tree = Tree::default();
tree.index_nbt();
assert!(!tree.tests_nbt());
tree.list.push(adv::Advancement {
id: "pack:a".into(),
parent: None,
display: None,
criteria: vec![
criterion("minecraft:player_interacted_with_entity", nbt),
criterion("minecraft:location", reference),
criterion("minecraft:tame_animal", plain),
],
requirements: Vec::new(),
rewards: Default::default(),
});
tree.index_nbt();
assert!(tree.tests_nbt());
assert!(tree.may_test_nbt("minecraft:player_interacted_with_entity"));
assert!(tree.may_test_nbt("minecraft:location"));
assert!(!tree.may_test_nbt("minecraft:tame_animal"));
}
#[test]
fn toast_labels_are_yellow_or_pink() {
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
+1
View File
@@ -670,6 +670,7 @@ impl Game<'_> {
json!({ "mode": "multiplayer", "disconnected": reason })
}
Mode::Multiplayer(_) => json!({ "mode": "multiplayer" }),
Mode::Realms(_) => json!({ "mode": "realms" }),
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
+76 -9
View File
@@ -11,6 +11,44 @@ use horizon_client::{
use horizon_core::random::RandomSource;
impl Game<'_> {
/// The client's `Allay.mobInteract` runs before the server's response.
pub(super) fn predict_allay_interaction(
&mut self,
index: usize,
hand: Hand,
offered: Option<ItemStack>,
) -> bool {
use allay::interaction::{Interaction, predict};
let duplicates = offered.as_ref().is_some_and(|s| {
self.items()
.item_tags
.get("minecraft:duplicates_allays")
.is_some_and(|tag| tag.contains(&*s.item))
});
let actor = self.acting_uuid();
let Some(result) = predict(
&mut self.mobs[index],
offered.as_ref(),
hand == Hand::Main,
duplicates,
actor,
) else {
return false;
};
self.play_mob_bound_sound(index, result.sound(), 6, 2.0, 1.0, false);
match result {
Interaction::Duplicate | Interaction::Give => {
self.with_hand(hand, Self::consume_held);
}
Interaction::Take(stack) => {
// Player.addItem returns leftovers on the client without
// spawning an item entity; the server owns any dropped stack.
let _ = self.give(stack);
}
}
true
}
pub(super) fn tick_allays(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
// The playing jukeboxes, found at the first allay: no allay starts
@@ -248,8 +286,14 @@ impl Game<'_> {
3.0, 3.75, 4.0,
];
let pitch = PITCHES[self.world.random.next_int_bound(16).unwrap_or(4) as usize];
self.audio
.play("minecraft:entity.allay.item_thrown", Some(from), 1.0, pitch);
self.play_mob_bound_sound(
i,
"minecraft:entity.allay.item_thrown",
6,
1.0,
pitch,
false,
);
}
}
/// `Allay.mobInteract`: duplicate when dancing with an amethyst shard,
@@ -260,7 +304,12 @@ impl Game<'_> {
return false;
}
let at = [m.position.x, m.position.y, m.position.z];
let shard = item.is_some_and(|s| &*s.item == "minecraft:amethyst_shard");
let shard = item.is_some_and(|s| {
self.items()
.item_tags
.get("minecraft:duplicates_allays")
.is_some_and(|tag| tag.contains(&*s.item))
});
if m.extra.flag && shard && m.extra.egg_time == 0 {
let pos = at;
self.mobs[index].extra.egg_time = 6000;
@@ -272,8 +321,14 @@ impl Game<'_> {
// Allay.duplicateAllay: entity event 18, three spawnHeartParticle.
let id = self.mobs[index].id;
self.mob_event_particles(id, 18);
self.audio
.play("minecraft:block.amethyst_block.chime", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:block.amethyst_block.chime",
6,
2.0,
1.0,
true,
);
self.consume_held();
return true;
}
@@ -287,8 +342,14 @@ impl Game<'_> {
m.extra.tame = true;
// `Allay.mobInteract` remembers LIKED_PLAYER by UUID.
m.extra.owner = Some(acting);
self.audio
.play("minecraft:entity.allay.item_given", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_given",
6,
2.0,
1.0,
true,
);
self.consume_held();
return true;
}
@@ -305,8 +366,14 @@ impl Game<'_> {
let carried = m.extra.allay.as_mut().and_then(|st| st.inventory.take());
m.extra.tame = false;
m.extra.owner = None;
self.audio
.play("minecraft:entity.allay.item_taken", Some(at), 2.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:entity.allay.item_taken",
6,
2.0,
1.0,
true,
);
// Allay.mobInteract ends with swing(MAIN_HAND, ...).
self.mobs[index].swing();
if let Some(stack) = carried {
@@ -638,7 +638,7 @@ impl Game<'_> {
}
if short {
self.audio.play(
"minecraft:block.conduit.ambient_short",
"minecraft:block.conduit.ambient.short",
Some(centre),
1.0,
1.0,
@@ -19,7 +19,51 @@ pub(super) fn after_leaving(cooldown: i32, was: Option<u32>, now: Option<u32>) -
}
}
/// `Entity.getIndirectPassengersStream` for `vehicle`: the vehicle and every
/// vehicle riding it, directly or through another rider. `mounts` holds
/// `(vehicle, its vehicle)` for every mob.
pub(super) fn passenger_vehicles(vehicle: u32, mounts: &[(u32, Option<u32>)]) -> Vec<u32> {
let mut ids = vec![vehicle];
let mut i = 0;
while i < ids.len() {
let v = ids[i];
i += 1;
for &(id, mount) in mounts {
if mount == Some(v) && !ids.contains(&id) {
ids.push(id);
}
}
}
ids
}
impl Game<'_> {
/// `Entity.startRiding` for the acting player: the rider link, then
/// `CriteriaTriggers.START_RIDING_TRIGGER` for every server player the
/// vehicle carries.
pub(super) fn start_riding(&mut self, vehicle: u32) {
self.riding = Some(vehicle);
self.note_started_riding(vehicle);
}
/// A rider link changed on `vehicle` (`sendEventAndTriggers`). Every
/// player aboard hears the trigger; a host's turn can fire it now, while
/// a guest's turn (the hub held by its action) leaves it for
/// [`Self::flush_started_riding`] once every player is back in place.
pub(super) fn note_started_riding(&mut self, vehicle: u32) {
self.started_riding.push(vehicle);
if self.lan.is_some() && self.lan_acting.is_none() {
self.flush_started_riding();
}
}
/// Fires the queued `started_riding` triggers with every player in place.
pub(super) fn flush_started_riding(&mut self) {
for vehicle in std::mem::take(&mut self.started_riding) {
self.trigger_started_riding(vehicle);
}
}
/// `Entity.canRide` for the player acting now (the host's, or the guest
/// whose turn it is): the cooldown has elapsed, and the player did not
/// leave a vehicle since the tick began (the tick that starts the
@@ -60,4 +104,17 @@ mod tests {
fn never_negative() {
assert_eq!(tick_down(0), 0);
}
#[test]
fn started_riding_reaches_every_rider_of_the_vehicle() {
// Entity.startRiding: START_RIDING_TRIGGER for every ServerPlayer in
// entityToRide.getIndirectPassengersStream(). A mob between two
// riders is still part of that stream, and another vehicle is not.
let mounts = [(7, Some(5)), (8, Some(7)), (9, Some(4))];
let aboard = passenger_vehicles(5, &mounts);
assert!(aboard.contains(&5), "the rider on the vehicle itself");
assert!(aboard.contains(&7), "the mob in the vehicle's seat");
assert!(aboard.contains(&8), "the rider of that mob");
assert!(!aboard.contains(&9), "a mob riding another vehicle");
assert!(!aboard.contains(&6), "a mob riding nothing");
}
}
@@ -269,6 +269,7 @@ impl Game<'_> {
count += 1;
// Mob.startRiding drops its lead.
self.break_leash(mob, true, false);
self.note_started_riding(id);
}
continue;
}
+6 -71
View File
@@ -2,6 +2,8 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
use horizon_client::entity_rules::EntityRules;
use horizon_client::{
@@ -1251,48 +1253,7 @@ impl Game<'_> {
if !self.boat_models.has(wood, chest) {
return None;
}
let (eye, dir) = self.aim()?;
let reach = self.reach();
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
let block = self.pick().map(|h| {
(
h.point,
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt(),
)
});
let mut fluid = None;
let mut t = 0.0;
while t < reach {
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
let (x, y, z) = (
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
);
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
fluid = Some((fluid_hit_point(p, y, h), t));
break;
}
t += 0.05;
}
let hit = match (block, fluid) {
(Some(b), Some(w)) => {
if w.1 < b.1 {
w.0
} else {
b.0
}
}
(Some(b), None) => b.0,
(None, Some(w)) => w.0,
(None, None) => return None,
};
let hit = self.boat_item_hit()?;
let mut boat = Boat::new(
self.next_entity + 1,
Arc::from(wood),
@@ -1387,10 +1348,10 @@ impl Game<'_> {
if !self.native_may_board(self.boats[i].id) {
return true;
}
self.riding = Some(self.boats[i].id);
let id = self.boats[i].id;
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding.
self.trigger_player("minecraft:started_riding");
// Entity.startRiding (ServerPlayer.startRiding fires the trigger).
self.start_riding(id);
true
}
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
@@ -1623,36 +1584,10 @@ impl Game<'_> {
}
}
/// Where a fluid pick lands in the cell the ray sample `p` is in. Vanilla's
/// `ClipContext.Fluid.ANY` clips against the fluid shape: a full-height cell
/// (the same fluid above it) is a full cube, so the hit is the ray's own
/// point and a boat placed under water stays at the eye; a partial cell is
/// clipped to its surface.
fn fluid_hit_point(p: [f64; 3], y: i32, h: f32) -> [f64; 3] {
if h >= 1.0 {
p
} else {
[p[0], f64::from(y) + f64::from(h), p[2]]
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `VoxelShape.clip` on a full-cube fluid cell keeps the true hit point
/// (a ray starting inside hits at `from + delta * 0.001`); only a partial
/// cell is clipped to its surface.
#[test]
fn a_full_height_fluid_cell_keeps_the_ray_point_and_a_partial_one_snaps_to_its_surface() {
let eye = [0.5, 3.62, 0.5];
assert_eq!(fluid_hit_point(eye, 3, 1.0), eye);
assert_eq!(
fluid_hit_point(eye, 3, 8.0 / 9.0),
[0.5, 3.0 + f64::from(8.0f32 / 9.0), 0.5]
);
}
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
@@ -0,0 +1,106 @@
//! BoatItem's server POV clip; deliberately separate from renderer/mining picks.
use super::{Fluid, Game};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::io;
struct BoatItemRay<'a, 'r>(&'a Game<'r>);
impl RayWorld for BoatItemRay<'_, '_> {
type Error = io::Error;
type Block = BlockStateId;
type Fluid = Option<(f32, bool, Fluid)>;
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
Ok(self.0.world.block(p.x, p.y, p.z))
}
fn fluid_state(&mut self, p: BlockPos) -> io::Result<Self::Fluid> {
Ok(self
.0
.fluid_height_of(p.x, p.y, p.z, None)
.map(|(h, source)| {
(
h,
source,
self.0
.res
.facts()
.get(self.0.world.block(p.x, p.y, p.z))
.fluid,
)
}))
}
fn block_shape(
&mut self,
state: BlockStateId,
p: BlockPos,
_: RayBlockMode,
) -> io::Result<VoxelShape> {
// LiquidBlock.getShape is empty. Waterlogged blocks retain their outline.
if matches!(
&*self.0.res.facts().get(state).name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
) {
return Ok(VoxelShape::empty());
}
let mut shape = VoxelShape::empty();
for b in self.0.outline_boxes([p.x, p.y, p.z]) {
let b: [f64; 6] = std::array::from_fn(|i| b[i] - f64::from([p.x, p.y, p.z][i % 3]));
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))
.map_err(|e| io::Error::other(format!("{e:?}")))?;
shape = shape
.join(&part, ShapeOp::OR)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
Ok(shape)
}
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
// Pinned vanilla's registered interaction shapes are all empty;
// interaction_shapes::InteractionShapes validates that same table.
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.1))
}
fn fluid_is_empty(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_none())
}
fn fluid_is_water(&mut self, s: Self::Fluid) -> io::Result<bool> {
Ok(s.is_some_and(|s| s.2 == Fluid::Water))
}
fn fluid_shape(&mut self, s: Self::Fluid, _: BlockPos) -> io::Result<VoxelShape> {
match s {
None => Ok(VoxelShape::empty()),
Some((h, _, _)) => {
VoxelShape::from_box(Aabb::new(Vec3::ZERO, Vec3::new(1.0, f64::from(h), 1.0)))
.map_err(|e| io::Error::other(format!("{e:?}")))
}
}
}
}
impl Game<'_> {
/// `Item.getPlayerPOVHitResult`: float view vector, OUTLINE and Fluid.ANY.
pub(super) fn boat_item_hit(&self) -> Option<[f64; 3]> {
let [x, y, z] = self.eye_position()?;
let start = Vec3::new(x, y, z);
let dir = horizon_world::view_direction::view_vector(self.pitch, self.yaw);
let end = start.add_vector(dir.scale(self.reach()));
let hit = clip_world(
&mut BoatItemRay(self),
start,
end,
RayBlockMode::Outline,
RayFluidMode::Any,
256,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
}
}
+190 -24
View File
@@ -195,7 +195,6 @@ impl UiState {
/// instead of at every world opening (thousands of files).
struct ResourceIds {
recipes: Vec<String>,
damage_types: Vec<String>,
loot_tables: Vec<String>,
templates: Vec<String>,
/// `entity-types.json`'s keys, sorted (empty when the file is missing).
@@ -287,7 +286,6 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let (timelines, markers) = overworld_timelines(root);
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
@@ -549,7 +547,12 @@ impl Game<'_> {
reg.permission = permission;
let ids = resource_ids(&self.options.resources);
self.ui.registries.recipes = ids.recipes.clone();
self.ui.registries.damage_types = ids.damage_types.clone();
self.ui.registries.damage_types = self
.health_resources
.damage
.entries()
.map(|(_, d)| d.name.clone())
.collect();
self.ui.registries.loot_tables = ids.loot_tables.clone();
self.ui.registries.templates = ids.templates.clone();
self.ui.registries.sounds = self.audio.event_names();
@@ -759,6 +762,16 @@ impl Game<'_> {
/// sent); blank text discards the draft. A draft still shown unchanged
/// stays as it is.
fn keep_chat_draft(&mut self, text: &str, reason: ChatExit) {
// During PvP & HUD's chat search the box holds the query: the draft is the text the
// search put aside, whichever way the chat closed.
let aside;
let text = if self.pvp.chat_search {
self.pvp.chat_search = false;
aside = std::mem::take(&mut self.pvp.chat_search_aside);
aside.as_str()
} else {
text
};
let unchanged =
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
if should_discard_draft(reason, self.settings.file.bool("saveChatDrafts"))
@@ -814,6 +827,40 @@ impl Game<'_> {
fn leave_bed_button(width: f32, height: f32) -> (i32, i32, i32, i32) {
(width as i32 / 2 - 100, height as i32 - 40, 200, 20)
}
/// The Leave Bed button's draws. It registers as a button
/// (`widgets::record`), so the pad's and the keyboard's focus reach it (the
/// pad's starts on it) and `horizon-ctl click-button` finds it.
fn leave_bed_draws(
m: &Measure,
mouse: (i32, i32),
width: f32,
height: f32,
) -> Vec<horizon_client::screen_draw::Draw> {
let (x, y, w, h) = Self::leave_bed_button(width, height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
horizon_client::widgets::record(
&label.plain(),
[x as f32, y as f32, w as f32, h as f32],
true,
);
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
horizon_client::hud_overlays::now_ms(),
);
button
}
/// `Minecraft.computeChatAbilities` from the Chat option.
pub(super) fn chat_abilities(&self) -> ChatAbilities {
ChatAbilities::new(ChatVisibility::from_option(self.settings.chat_visibility))
@@ -890,6 +937,17 @@ impl Game<'_> {
// while a screen is open.
let ctrl = self.has_control_down_with_quirk();
let shift = self.has_shift_down();
// PvP & HUD's chat search: Ctrl+F starts and stops it, Enter stops it (sends nothing).
if self.settings.extras.pvp.chat.search
&& match key {
KeyCode::KeyF => ctrl,
KeyCode::Enter | KeyCode::NumpadEnter => self.pvp.chat_search,
_ => false,
}
{
self.toggle_chat_search();
return;
}
match key {
// ChatScreen.keyPressed: the chat scrolls a page.
KeyCode::PageUp | KeyCode::PageDown
@@ -917,7 +975,8 @@ impl Game<'_> {
};
match screen.key(&m, &c, &self.ui.chat.recent, key, shift, ctrl) {
ChatAction::Close => {
// Escape: the box still holds its text, which close_chat keeps as the draft.
// Escape: the box still holds its text, which close_chat keeps as the draft
// (a search's query gives way to the text it put aside: keep_chat_draft).
self.ui.chat_screen = Some(screen);
self.close_chat(ChatExit::Intentional);
}
@@ -1063,12 +1122,29 @@ impl Game<'_> {
} else {
return;
}
let Some(style) = self.ui.chat.style_at(
let style = self.ui.chat.style_at(
&self.gui.atlas.font,
height as i32,
cursor.0 as f64,
cursor.1 as f64,
) else {
);
// PvP & HUD's copy on click: a message where the click has nothing else to do.
if self.settings.extras.pvp.chat.copy
&& !self.has_shift_down()
&& style.as_ref().is_none_or(|s| s.click.is_none())
&& let Some(message) =
self.ui
.chat
.message_at(height as i32, cursor.0 as f64, cursor.1 as f64)
{
use horizon_client::clipboard::Clipboard;
self.clipboard.set(message);
let copied =
Text::translate_with_fallback("horizon.chat.copied", "Message copied", &[]);
self.titles.set_overlay(copied, false);
return;
}
let Some(style) = style else {
return;
};
// ChatScreen.handleComponentClicked: with Shift held only an
@@ -1206,9 +1282,47 @@ impl Game<'_> {
self.run_command(command);
}
}
/// PvP & HUD's chat search on or off. The box holds the query: what was typed is put aside
/// when the search starts and comes back when it stops.
fn toggle_chat_search(&mut self) {
let typed = self.ui.chat_screen.as_ref().map(|s| s.value().to_owned());
let next = if self.pvp.chat_search {
std::mem::take(&mut self.pvp.chat_search_aside)
} else {
self.pvp.chat_search_aside = typed.unwrap_or_default();
String::new()
};
self.pvp.chat_search = !self.pvp.chat_search;
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
screen.suggest_command(&m, &c, &next);
}
}
/// Chat lines over the HUD (focused while the chat screen is open).
pub(super) fn draw_chat(&mut self, batch: &mut HudBatch) {
let focused = self.screen == Screen::Chat;
let options = self.settings.extras.pvp.chat;
self.ui.chat.set_qol(&self.gui.atlas, options.qol());
// A search ends with its option; a chat closed some other way ends it in keep_chat_draft.
if self.pvp.chat_search && self.ui.chat_screen.is_none() {
// The chat went without closing (leaving the world): nothing to give back.
self.pvp.chat_search = false;
self.pvp.chat_search_aside.clear();
} else if self.pvp.chat_search && !options.search {
self.toggle_chat_search();
}
let query = self
.ui
.chat_screen
.as_ref()
.filter(|_| focused && self.pvp.chat_search)
.map(|s| s.value());
self.ui.chat.set_search(&self.gui.atlas, query);
self.ui.chat.search_prompt = focused && self.pvp.chat_search;
if self.ui.hide_gui && !focused {
return;
}
@@ -1290,24 +1404,7 @@ impl Game<'_> {
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &draws);
self.ui.chat_screen = Some(screen);
if in_bed {
let (x, y, w, h) = Self::leave_bed_button(batch.width, batch.height);
let label = Text::literal(horizon_client::lang::get_or(
"multiplayer.stopSleeping",
"Leave Bed",
));
let mut button = Vec::new();
horizon_client::screen_draw::button(
&mut button,
&m,
&label,
x,
y,
w,
h,
true,
horizon_client::screen_draw::inside(mouse, x, y, w, h),
horizon_client::hud_overlays::now_ms(),
);
let button = Self::leave_bed_draws(&m, mouse, batch.width, batch.height);
horizon_client::screen_draw::paint(batch, &self.gui.atlas, &button);
}
// The hovered message part's tooltip (`renderComponentHoverEffect`),
@@ -1897,4 +1994,73 @@ mod tests {
assert_eq!(prefilled.summonable, vec!["minecraft:pig".to_string()]);
assert_eq!(prefilled.entity_types, vec!["minecraft:pig".to_string()]);
}
/// The sleeper's chat: Leave Bed is a button target for the pad and the
/// keyboard, right above the chat box, and the D-pad steps between the
/// two.
#[test]
fn leave_bed_is_a_focus_target_above_the_chat_box() {
use super::super::key_focus;
use horizon_client::{
font::Font,
gamepad::{Button, focus_step_strict},
widgets::Kind,
};
use std::collections::HashMap;
// The missing-glyph metrics measure without game assets.
let font = Font::load(
Path::new("/nonexistent-horizon-chat-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
horizon_client::hud::Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
);
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: horizon_client::command::vanilla::game_dispatcher(),
registries: &registries,
};
let (width, height) = (427.0, 240.0);
let mut screen = ChatScreen::new(
&m,
&c,
"",
0,
width as i32,
height as i32,
ChatAbilities::new(ChatVisibility::Full),
);
screen.draw(&m, (0, 0), 0, 0);
Game::leave_bed_draws(&m, (0, 0), width, height);
horizon_client::widgets::end_frame(u64::MAX);
let targets = key_focus::drawn_targets();
let (x, y, w, h) = Game::leave_bed_button(width, height);
let bed = targets
.iter()
.copied()
.find(|t| t.rect == [x as f32, y as f32, w as f32, h as f32])
.expect("Leave Bed is a focus target");
assert_eq!(bed.kind, Kind::Button);
assert!(key_focus::keyboard_targets(targets.clone(), false).contains(&bed));
let field = targets
.iter()
.copied()
.find(|t| t.kind == Kind::Field)
.expect("the chat box is a focus target");
let rects: Vec<_> = targets.iter().map(|t| t.rect).collect();
let step =
|from: [f32; 4], button| focus_step_strict(from, button, &rects).map(|i| targets[i]);
assert_eq!(step(field.rect, Button::DPadUp), Some(bed));
assert_eq!(step(bed.rect, Button::DPadDown), Some(field));
}
}
+37 -1
View File
@@ -80,6 +80,26 @@ const MUTE_LEVEL: u8 = 3;
/// The commands of `mute_command`.
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
/// The mute commands as operators' clients get them in the command tree (`command_tree`'s usages).
pub(super) const MUTE_USAGES: [&str; 4] = [
"mute <player:player> [duration_and_reason:text]",
"tempmute <player:player> <duration:word> [reason:text]",
"mutelist",
"unmute <player:player>",
];
/// `/mute <player> [duration] [reason]` (`words`, the command first): how long, when the word after
/// the name is a duration (`10m`, `2h`, `1d`; none: until `/unmute`), and the index the reason starts at.
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_chat_filter::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
}
}
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
@@ -295,7 +315,10 @@ impl Game<'_> {
lines
}
["unmute", name] => vec![self.unmute(host, name, by)],
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), by)],
["mute", name, ..] => {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
@@ -498,4 +521,17 @@ mod tests {
}
});
}
#[test]
fn mute_takes_a_duration_after_the_name_or_mutes_until_unmuted() {
assert_eq!(mute_time(&["mute", "Steve"]), (None, 2));
assert_eq!(mute_time(&["mute", "Steve", "spamming", "chat"]), (None, 2));
assert_eq!(
mute_time(&["mute", "Steve", "1h", "spam"]),
(Some(3_600_000), 3)
);
assert_eq!(mute_time(&["mute", "Steve", "30m"]), (Some(1_800_000), 3));
// Not a duration: the word starts the reason.
assert_eq!(mute_time(&["mute", "Steve", "0m", "spam"]), (None, 2));
}
}
+4 -5
View File
@@ -295,7 +295,7 @@ impl Game<'_> {
continue;
}
MotionEvent::Health(HealthEvent::RecordedDamage { source, amount }) => {
let Some(d) = self.motion.health.damage.definition(*source) else {
let Some(d) = self.health_resources.damage.definition(*source) else {
continue;
};
(d.name.clone(), *amount)
@@ -394,7 +394,7 @@ impl Game<'_> {
}
}
fn damage_in_tag(&self, name: &str, tag: &str) -> bool {
let damage = &self.motion.health.damage;
let damage = &self.health_resources.damage;
damage.id(name).is_some_and(|id| {
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
})
@@ -472,11 +472,10 @@ impl Game<'_> {
return Text::translate("death.attack.generic", &[me]);
};
let def = self
.motion
.health
.health_resources
.damage
.id(&killing.damage_type)
.and_then(|id| self.motion.health.damage.definition(id))
.and_then(|id| self.health_resources.damage.definition(id))
.map(|d| (d.message_id.clone(), d.death_message_type.clone()))
.unwrap_or_else(|| ("generic".into(), "default".into()));
self.combat.entries = entries;
@@ -51,8 +51,27 @@ impl Game<'_> {
/// boundary through Integrated::stop, including script and save failures.
pub(super) fn wait_command_harness(&mut self) -> Result<()> {
let server = self.integrated.take().ok_or("command harness: no server")?;
let client_config = std::env::var_os("HORIZON_HARNESS_CLIENT_CONFIG");
let mut wrote_config = false;
while !server.stopped() {
self.poll_stationary_client()?;
if !wrote_config
&& let Some(path) = &client_config
&& let Some(remote) = self.remote.as_ref().filter(|r| r.logged_in && r.joined)
{
std::fs::write(
path,
json!({
"view_distance": remote.view_distance,
"simulation_distance": remote.simulation_distance,
"render_setting": self.settings.render_distance,
"simulation_setting": self.settings.simulation_distance,
"logged_in": remote.logged_in, "joined": remote.joined,
})
.to_string(),
)?;
wrote_config = true;
}
std::thread::sleep(Duration::from_millis(2));
}
server.stop()
@@ -127,7 +146,7 @@ impl Game<'_> {
})
}
/// Load terrain until the player stands in lit chunks.
fn harness_settle(&mut self, deadline: Instant) -> Result<()> {
fn harness_settle(&mut self, deadline: Instant, required: Option<&Value>) -> Result<()> {
loop {
self.update_world();
if let Some(p) = &self.player {
@@ -137,8 +156,23 @@ impl Game<'_> {
);
// Finish requested terrain before inspecting it, so worker
// timing cannot change which command positions are loaded.
if !self.streaming.busy()
&& (-1..=1).all(|dz| (-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz)))
if (std::env::var_os("HORIZON_HARNESS_TERRAIN_ONLY").is_some()
|| !self.streaming.busy())
&& required.and_then(Value::as_array).map_or_else(
|| {
(-1..=1).all(|dz| {
(-1..=1).all(|dx| self.streaming.is_lit(cx + dx, cz + dz))
})
},
|chunks| {
chunks.iter().all(|chunk| {
chunk[0]
.as_i64()
.zip(chunk[1].as_i64())
.is_some_and(|(x, z)| self.streaming.is_lit(x as i32, z as i32))
})
},
)
{
// The oracle's settle also waits for the entities saved
// in those chunks (areEntitiesActuallyLoadedAndTicking):
@@ -182,7 +216,7 @@ impl Game<'_> {
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
self.harness_owner(|g| g.harness_settle(deadline, None))?;
let mut out = String::new();
for line in std::fs::read_to_string(script)?.lines() {
if line.trim().is_empty() {
@@ -212,6 +246,76 @@ impl Game<'_> {
out.push('\n');
}
}
"server_distances" => {
let state = self.harness_owner(|g| {
let hub = &g.lan.as_ref().ok_or("no connected hub")?.hub;
Ok([hub.config.view_distance, hub.config.simulation_distance])
})?;
out.push_str(
&json!({"cmd": "#server_distances", "messages": [],
"results": state.map(|n| json!([true, n]))})
.to_string(),
);
out.push('\n');
}
"chunk_ready" => {
let [x, z] =
std::array::from_fn(|i| op["chunk"][i].as_i64().unwrap_or(0) as i32);
let state = self.harness_owner(|g| {
let resident = g.world.chunks.contains_key(&(x, z));
Ok([
resident,
g.streaming.is_lit(x, z),
resident
&& !g
.world
.loaded_entities
.iter()
.any(|(key, _)| *key == (x, z)),
])
})?;
out.push_str(
&json!({"cmd": format!("#chunk_ready {x} {z}"), "messages": [],
"results": state.map(|ready| json!([true, i32::from(ready)]))})
.to_string(),
);
out.push('\n');
}
"wait_chunks" => {
let targets: Vec<(i32, i32)> = op["chunks"]
.as_array()
.ok_or("missing chunks")?
.iter()
.map(|p| {
(
p[0].as_i64().unwrap_or(0) as i32,
p[1].as_i64().unwrap_or(0) as i32,
)
})
.collect();
self.harness_owner(|g| {
loop {
g.update_world();
g.spawn_loaded_entities();
if targets.iter().all(|&(x, z)| {
g.world.chunks.contains_key(&(x, z))
&& g.streaming.is_lit(x, z)
&& !g
.world
.loaded_entities
.iter()
.any(|(key, _)| *key == (x, z))
}) {
break;
}
if Instant::now() > deadline {
return Err("initial target readiness timed out".into());
}
std::thread::sleep(Duration::from_millis(2));
}
Ok(())
})?;
}
"weather_state" => {
// Saved state guards, separate from effective-weather predicates.
let state = self.harness_owner(|g| {
@@ -240,7 +344,8 @@ impl Game<'_> {
}
"tick" => {
self.chat_capture = Some(Vec::new());
for _ in 0..op["n"].as_u64().unwrap_or(1) {
let paced_start = Instant::now();
for i in 0..op["n"].as_u64().unwrap_or(1) {
// The oracle's profile resolver answers at once; the
// game's asks the services (none here: they refuse at
// once) on a thread, which a tick can outrun.
@@ -251,6 +356,19 @@ impl Game<'_> {
std::thread::sleep(Duration::from_millis(1));
}
self.tick_server(false)?;
if let Some(ms) = op["pace_ms"].as_u64() {
std::thread::sleep(
Duration::from_millis(ms * (i + 1))
.saturating_sub(paced_start.elapsed()),
);
}
}
if op["pace_ms"].is_number() {
eprintln!(
"harness paced wait: {} ticks, {} ms",
op["n"],
paced_start.elapsed().as_millis()
);
}
let messages = self.chat_capture.take().unwrap_or_default();
if op["record"].as_bool() == Some(true) {
@@ -272,7 +390,53 @@ impl Game<'_> {
}
std::thread::sleep(Duration::from_millis(2));
}
self.harness_owner(|g| g.harness_settle(deadline))?;
self.harness_owner(|g| g.harness_settle(deadline, op.get("required_chunks")))?;
}
"use_item" if op.get("sequence").is_some() => {
// Exercise the connected owner's normal server packet route.
let sequence = op["sequence"].as_i64().unwrap_or(0) as i32;
let yaw = op["yaw"].as_f64().unwrap_or(0.0) as f32;
let pitch = op["pitch"].as_f64().unwrap_or(0.0) as f32;
let owner = self
.serving
.as_ref()
.ok_or("command harness: no server")?
.owner;
self.in_owner_level(|g| -> Result<()> {
let conn = g
.lan
.as_ref()
.and_then(|h| {
h.hub
.guests
.iter()
.find(|guest| guest.uuid == owner && guest.placed)
.map(|guest| guest.conn)
})
.ok_or("command harness: owner is not connected")?;
let mut host = g.lan.take().ok_or("command harness: no hub")?;
g.lan_guest_packet(
&mut host,
conn,
horizon_protocol::packets::play::Serverbound::UseItem(
horizon_protocol::packets::play::UseItem {
hand: 0,
yaw,
pitch,
sequence,
},
),
);
let ack = host
.hub
.guest(conn)
.is_some_and(|g| g.ack_up_to.get() >= sequence);
g.lan = Some(host);
if !ack {
return Err("command harness: UseItem was not acknowledged".into());
}
Ok(())
})?;
}
// Java produced the fixture file before this world was opened; map_state loads it.
"crafter_max_map_fixture" => {}
+42 -19
View File
@@ -1516,7 +1516,7 @@ impl Game<'_> {
// An entity in another level is teleported in the level that holds it.
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation, turn_riders));
}
fn teleport_to_in_level(
pub(super) fn teleport_to_in_level(
&mut self,
t: Target,
pos: [f64; 3],
@@ -1779,7 +1779,16 @@ impl Game<'_> {
level: super::Dim,
pos: [f64; 3],
rotation: Option<[f32; 2]>,
) -> bool {
turn_riders: bool,
) -> R<bool> {
match self.teleport_vehicle_across(t, level, pos, rotation, turn_riders) {
Ok(true) => return Ok(true),
Ok(false) => {}
Err(error) => {
log::error!("entity teleport into {}: {error}", level.id());
return Err(err("commands.generic.exception", &[]));
}
}
let conn = match t {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
@@ -1787,7 +1796,7 @@ impl Game<'_> {
};
let at = self.target_level(t);
let Some(conn) = conn.filter(|_| at != level) else {
return false;
return Ok(false);
};
self.level_teleports
.push(super::server_travel::LevelTeleport {
@@ -1795,8 +1804,9 @@ impl Game<'_> {
dim: level,
at: pos,
rotation,
vehicle: None,
});
true
Ok(true)
}
/// `ServerPlayer.lookAt`: besides turning the player, tell their client
/// with a `ClientboundPlayerLookAtPacket` (`from` anchor, the point, and
@@ -1854,13 +1864,22 @@ impl Game<'_> {
};
if id.ends_with("destination") {
let dest = self.entities(ctx, "destination", false)?[0];
let pos = self.target_position(dest).unwrap_or(self.source_position());
// Into the destination's level.
let level = self.target_level(dest);
// TeleportCommand.teleportToEntity: the destination's rotation too.
let rotation = Some(self.target_rotation(dest).map(wrap_degrees));
let (pos, level, rotation, destination_name) = self
.in_target_level(dest, |game| {
(
game.target_position(dest).unwrap_or(game.source_position()),
game.target_level(dest),
Some(game.target_rotation(dest).map(wrap_degrees)),
game.target_name(dest),
)
})
.ok_or_else(|| err("argument.entity.notfound.entity", &[]))?;
let target_name = (targets.len() == 1).then(|| {
self.in_target_level(targets[0], |game| game.target_name(targets[0]))
.unwrap_or_else(Text::empty)
});
for &t in &targets {
if !self.teleport_across(t, level, pos, rotation) {
if !self.teleport_across(t, level, pos, rotation, true)? {
// TeleportCommand.teleportToEntity: the destination's angles,
// which the riders turn to as well.
self.teleport_to(t, pos, rotation, true);
@@ -1869,15 +1888,12 @@ impl Game<'_> {
let message = if targets.len() == 1 {
Text::translate(
"commands.teleport.success.entity.single",
&[self.target_name(targets[0]), self.target_name(dest)],
&[target_name.unwrap(), destination_name],
)
} else {
Text::translate(
"commands.teleport.success.entity.multiple",
&[
Text::literal(targets.len().to_string()),
self.target_name(dest),
],
&[Text::literal(targets.len().to_string()), destination_name],
)
};
self.feedback(message);
@@ -1940,6 +1956,10 @@ impl Game<'_> {
None if self.source_entity().is_none() => super::Dim::Overworld,
None => self.dim,
};
let target_name = (targets.len() == 1).then(|| {
self.in_target_level(targets[0], |game| game.target_name(targets[0]))
.unwrap_or_else(Text::empty)
});
for &t in &targets {
// `facing` teleports with the rotation unchanged (Relative.ROTATION,
// no turn for the riders), then LookAt.perform turns the target alone.
@@ -1947,7 +1967,9 @@ impl Game<'_> {
let turn_riders = looked.is_none();
let rotation = looked.or_else(|| {
let (angles, relative) = rotation?;
let current = self.target_rotation(t);
let current = self
.in_target_level(t, |game| game.target_rotation(t))
.unwrap_or([0.0, 0.0]);
// performTeleport wraps relative deltas before calculateAbsolute
// adds them back; wrapping the resolved pitch changes its clamp.
Some(std::array::from_fn(|axis| {
@@ -1958,7 +1980,7 @@ impl Game<'_> {
}
}))
});
if self.teleport_across(t, source_level, pos, rotation) {
if self.teleport_across(t, source_level, pos, rotation, turn_riders)? {
continue;
}
self.teleport_to(t, pos, rotation, turn_riders);
@@ -1971,7 +1993,7 @@ impl Game<'_> {
let message = if targets.len() == 1 {
Text::translate(
"commands.teleport.success.location.single",
&[self.target_name(targets[0]), x, y, z],
&[target_name.unwrap(), x, y, z],
)
} else {
Text::translate(
@@ -2778,7 +2800,8 @@ impl Game<'_> {
entity: &mut horizon_client::containers::BlockEntity,
) -> bool {
use horizon_client::{containers::BlockEntity, save, structure_blocks};
let table = save::text(tag.get("LootTable"));
let table = save::text(tag.get("LootTable"))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id));
let rolled = match (table, entity) {
(Some(table), BlockEntity::Chest(slots)) if slots.iter().all(Option::is_none) => {
let loot = structure_blocks::loot_data(&self.options.resources, &self.pack_files());
@@ -530,7 +530,7 @@ impl Game<'_> {
}
_ => {
let source = self.command_source.unwrap_or_else(|| self.player_source());
let view = source.entity.map(|t| self.entity_view_of(t));
let view = source.entity.map(|t| self.entity_view_asked(t, cond));
self.conditions_hold(cond, view.as_ref(), source.pos)
}
}
+125 -35
View File
@@ -67,43 +67,47 @@ impl Game<'_> {
self.in_target_level(t, |g| g.entity_nbt_in_level(t))
.flatten()
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
// The acting player's own UUID: a guest's turn (the window's player on
// the server thread) keeps the world owner's in `level`.
let player_uuid = self
.lan_acting_uuid
/// The acting player's own UUID: a guest's turn (the window's player on
/// the server thread) keeps the world owner's in `level`.
fn saved_player_uuid(&self) -> Option<[i32; 4]> {
self.lan_acting_uuid
.map(horizon_client::lan::playerdata::uuid_ints)
.or(self.level.player_uuid);
.or(self.level.player_uuid)
}
/// [`Self::entity_nbt`] of the player whose turn it is.
pub(in crate::game) fn player_nbt(&mut self) -> Option<Tag> {
self.player.as_ref()?;
self.sync_level();
let mut tag = self.level.player_tag();
if let Some(u) = self.saved_player_uuid() {
set_key(&mut tag, "UUID", Tag::IntArray(u.to_vec()));
}
// NbtPredicate.getEntityTagToCompare: a player's held stack.
let selected = self.level.selected_slot;
let held = match tag.get("Inventory") {
Some(Tag::List(items)) => items
.iter()
.find(|i| matches!(i.get("Slot"), Some(Tag::Byte(b)) if i32::from(*b) == selected))
.cloned(),
_ => None,
};
if let Some(mut stack) = held {
if let Tag::Compound(c) = &mut stack {
c.remove(&"Slot".into());
}
set_key(&mut tag, "SelectedItem", stack);
}
Some(tag)
}
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
let player_uuid = self.saved_player_uuid();
Some(match t {
Target::Guest(_) => {
return self
.for_player(t, |g| g.entity_nbt(Target::Player))
.flatten();
}
Target::Player => {
self.player.as_ref()?;
self.sync_level();
let mut tag = self.level.player_tag();
if let Some(u) = player_uuid {
set_key(&mut tag, "UUID", Tag::IntArray(u.to_vec()));
}
// NbtPredicate.getEntityTagToCompare: a player's held stack.
let selected = self.level.selected_slot;
let held = match tag.get("Inventory") {
Some(Tag::List(items)) => items
.iter()
.find(|i| matches!(i.get("Slot"), Some(Tag::Byte(b)) if i32::from(*b) == selected))
.cloned(),
_ => None,
};
if let Some(mut stack) = held {
if let Tag::Compound(c) = &mut stack {
c.remove(&"Slot".into());
}
set_key(&mut tag, "SelectedItem", stack);
}
tag
}
Target::Player => return self.player_nbt(),
Target::Mob(id) => {
// `saveWithoutId` writes the entity as saved, riders under
// `Passengers`: `/data get` reads the same tree persistence does (#1519).
@@ -313,7 +317,10 @@ impl Game<'_> {
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
let stale = || err("commands.data.merge.failed", &[]);
match t {
Target::Player => return Err(err("commands.data.entity.invalid", &[])),
// EntityDataAccessor.setData: players can't be modified, guests neither.
Target::Player | Target::Guest(_) => {
return Err(err("commands.data.entity.invalid", &[]));
}
Target::Mob(id) => {
let uuid = self.uuid_for(id);
let kind = self
@@ -348,6 +355,9 @@ impl Game<'_> {
slot.extra.owner = old.extra.owner;
}
slot.ai = old.ai;
// Mob.readAdditionalSaveData updates persistent home fields,
// while retaining the rest of the live AI/runtime state.
entity_nbt::read_mob_home(slot, &tag);
slot.extra.armor_attributes = old.extra.armor_attributes;
slot.age = old.age;
slot.previous = slot.position;
@@ -383,6 +393,45 @@ impl Game<'_> {
*slot = new;
}
}
Target::Boat(id) => {
// AbstractBoat.readAdditionalSaveData over Entity.load: the
// position, rotation, motion, NoGravity and a chest boat's items.
let (position, velocity, yaw) =
entity_nbt::boat_from_nbt(&tag).ok_or_else(stale)?;
let uuid = self.uuid_for(id);
let b = self
.boats
.iter_mut()
.find(|b| b.id == id)
.ok_or_else(stale)?;
b.position = position;
b.previous = position;
b.velocity = velocity;
b.yaw = yaw;
b.previous_yaw = yaw;
b.pitch = entity_nbt::rotation(&tag)[1];
b.no_gravity = entity_nbt::flag(&tag, "NoGravity");
if b.chest {
b.items = save::decode_items(tag.get("Items"), crate::game::boats::CHEST_SLOTS);
}
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Cart(id) => {
let uuid = self.uuid_for(id);
let i = self
.carts
.iter()
.position(|c| c.id == id)
.ok_or_else(stale)?;
let mut cart = self.carts[i].clone();
cart.load(&tag).ok_or_else(stale)?;
crate::game::minecarts::forget_unreadable_display(&mut cart, |t| {
self.state_from_nbt(t).is_some()
});
self.roll_cart_loot(&mut cart, &tag);
self.carts[i] = cart;
set_key(&mut tag, "UUID", Tag::IntArray(uuid.to_vec()));
}
Target::Other(id) => {
let uuid = self.uuid_for(id);
let marker = self
@@ -407,6 +456,8 @@ impl Game<'_> {
| Target::Item(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id)
| Target::Fangs(id) = t
{
@@ -508,6 +559,19 @@ impl Game<'_> {
let prefix = if role == 0 { "target" } else { "source" };
self.data_ref_named(ctx, role, prefix)
}
/// `PistonMovingBlockEntity.saveAdditional`, with full command metadata.
fn moving_piston_data(&self, p: [i32; 3]) -> Option<Tag> {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
let (_, piston) = self
.block_ticks
.moving_pistons
.iter()
.find(|(at, _)| *at == pos)?;
let mut tag = self.moving_piston_to_nbt(pos, piston);
// BlockEntity.saveWithoutMetadata includes the component map.
set_key(&mut tag, "components", empty());
Some(tag)
}
pub(in crate::game) fn data_ref_named(
&mut self,
ctx: &Context,
@@ -537,6 +601,7 @@ impl Game<'_> {
if !self.world.block_entities.contains_key(&(p[0], p[1], p[2]))
&& self.comparator_data(p).is_none()
&& self.pot_data(p).is_none()
&& self.moving_piston_data(p).is_none()
{
return Err(err("commands.data.block.invalid", &[]));
}
@@ -561,6 +626,9 @@ impl Game<'_> {
Ok(tag)
}
DataRef::Block(p) => {
if let Some(tag) = self.moving_piston_data(*p) {
return Ok(tag);
}
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
return Ok(tag);
}
@@ -590,6 +658,11 @@ impl Game<'_> {
match r {
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
DataRef::Block(p) => {
// This view is read-only: never decode a live piston into a
// second, detached entry in world.block_entities.
if self.moving_piston_data(*p).is_some() {
return Err(err("commands.data.block.invalid", &[]));
}
if self.set_pot_data(*p, &tag).is_some() {
return Ok(());
}
@@ -660,8 +733,25 @@ impl Game<'_> {
),
}
}
fn query_text(&self, r: &DataRef, data: &Tag) -> Text {
let pretty = snbt::pretty(data);
fn query_text(&self, r: &DataRef, data: &Tag, path: Option<&NbtPath>) -> Text {
// Only the live piston writer supplies this provenance. The same
// compound in storage, an entity, or another block keeps default order.
let piston = if let DataRef::Block(p) = r
&& self.moving_piston_data(*p).is_some()
{
match path {
None => Some(snbt::PistonPart::Entity),
Some(p) if p.is_field_path(&[]) => Some(snbt::PistonPart::Entity),
Some(p) if p.is_field_path(&["blockState"]) => Some(snbt::PistonPart::State),
Some(p) if p.is_field_path(&["blockState", "properties"]) => {
Some(snbt::PistonPart::Properties)
}
_ => None,
}
} else {
None
};
let pretty = piston.map_or_else(|| snbt::pretty(data), |p| snbt::pretty_piston(data, p));
match r {
DataRef::Entity(_) => {
Text::translate("commands.data.entity.query", &[self.data_name(r), pretty])
@@ -754,7 +844,7 @@ impl Game<'_> {
"data.get" => {
let data = self.data_of(&target)?;
if ctx.get("path").is_none() {
let message = self.query_text(&target, &data);
let message = self.query_text(&target, &data, None);
self.system_feedback(message);
return Ok(1);
}
@@ -767,7 +857,7 @@ impl Game<'_> {
return Ok(value);
}
let value = data_cmd::get_value(&tag, &path)?;
let message = self.query_text(&target, &tag);
let message = self.query_text(&target, &tag, Some(&path));
self.system_feedback(message);
Ok(value)
}
@@ -134,12 +134,12 @@ impl Game<'_> {
.boats
.iter()
.find(|b| b.id == id)
.map_or([0.0; 2], |b| [b.yaw, 0.0]),
.map_or([0.0; 2], |b| [b.yaw, b.pitch]),
Target::Cart(id) => self
.carts
.iter()
.find(|c| c.id == id)
.map_or([0.0; 2], |c| [c.yaw, 0.0]),
.map_or([0.0; 2], |c| [c.yaw, c.pitch]),
Target::Fangs(id) => self
.fangs
.list
@@ -685,13 +685,22 @@ impl Game<'_> {
.collect(),
_ => Vec::new(),
},
// Leashable.getLeashHolder: a fence knot is not an entity here.
Some("leasher") => match entity {
Target::Mob(id) => self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.extra.leash.map(|holder| (holder, m.extra.leash_player)))
// Leashable.getLeashHolder: the server's resolved holder, not a
// saved UUID that is still waiting for tickLeash to restore it.
Some("leasher") => {
let leash =
match entity {
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).and_then(|m| {
m.extra.leash.map(|holder| (holder, m.extra.leash_player))
}),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.and_then(|b| b.leash.map(|holder| (holder, b.leash_player))),
_ => None,
};
leash
.and_then(|(holder, player)| match holder {
// The player who tied it (the host's when unrecorded).
horizon_client::mobs::leash::Holder::Player => {
@@ -703,9 +712,8 @@ impl Game<'_> {
| horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
_ => Vec::new(),
},
.collect()
}
// TraceableEntity.getOwner: who shot a projectile, who threw an
// item, who summoned a vex.
Some("origin") => match entity {
@@ -949,6 +957,17 @@ impl Game<'_> {
let keep = results.iter().any(|r| r.is_some_and(|v| (v != 0) == want));
Ok((keep, 0))
}
/// `ExecuteCommand.isChunkLoaded`: an entity-ticking loading status and
/// completed entity loading, rather than merely a retained lit column.
fn command_chunk_loaded(&self, x: i32, z: i32) -> bool {
self.tickets.levels().loaded_entity_ticking(x, z)
&& self.world.chunks.contains_key(&(x, z))
&& self.streaming.is_lit(x, z)
// Streaming queues the saved tags before making the column resident;
// spawn_loaded_entities applies them before this chunk's entities run.
&& !self.world.loaded_entities.iter().any(|(key, _)| *key == (x, z))
}
fn condition_matches(&mut self, c: &Context) -> R<(bool, i32)> {
let flag = |b: bool| (b, i32::from(b));
if c.has("data") {
@@ -976,12 +995,10 @@ impl Game<'_> {
.coords(c, "pos")
.map(|co| co.block(self.source_position(), self.source_rotation()));
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
// ExecuteCommand.isChunkLoaded: ENTITY_TICKING, which is past lit.
let loaded = |p: [i32; 3]| {
let key = (p[0] >> 4, p[2] >> 4);
self.world.chunks.contains_key(&key) && self.streaming.is_lit(key.0, key.1)
};
return Ok(flag(dimension == self.dim && p.is_some_and(loaded)));
return Ok(flag(
dimension == self.dim
&& p.is_some_and(|p| self.command_chunk_loaded(p[0] >> 4, p[2] >> 4)),
));
}
if c.has("dimension") {
let dimension = self.command_source.map_or(self.dim, |s| s.dimension);
@@ -11,6 +11,9 @@ use horizon_client::{
};
use horizon_world::nbt::Tag;
mod contents;
use contents::Component;
/// One addressable slot.
#[derive(Clone, Debug, PartialEq)]
pub(in crate::game) enum Slot {
@@ -33,6 +36,9 @@ pub(in crate::game) enum Slot {
Block([i32; 3], usize),
/// A slot of another player (`for_player`): reached with their state in place.
Guest(u32, Box<Slot>),
/// A component collection for writes, or an indexed component item for reads.
Contents(Box<Slot>, Component),
ContentItem(Box<Slot>, Component, usize),
}
struct SlotCandidate {
slot: Slot,
@@ -224,6 +230,17 @@ impl Game<'_> {
.collect()
}),
"slot_range" => self.slots_named(owner, source["slots"].as_str().unwrap_or_default()),
"contents" => {
let Some(component) = source["component"].as_str().and_then(Component::named)
else {
return Vec::new();
};
let parents = self.inline_slots(owner, &source["slot_source"]);
parents
.into_iter()
.flat_map(|p| self.contents_slots(p, component))
.collect()
}
"limit_slots" => {
let mut slots = self.inline_slots(owner, &source["slot_source"]);
slots.truncate(source["limit"].as_u64().unwrap_or(0) as usize);
@@ -307,6 +324,17 @@ impl Game<'_> {
});
}
}
"contents" => {
if let Some(component) = source["component"].as_str().and_then(Component::named) {
for parent in self.inline_slots(owner, &source["slot_source"]) {
out.push(SlotCandidate {
slot: Slot::Contents(Box::new(parent), component),
filters: filters.to_vec(),
limit,
});
}
}
}
"filtered" => {
let mut filters = filters.to_vec();
filters.push(source["item_filter"].clone());
@@ -344,6 +372,11 @@ impl Game<'_> {
}
pub(in crate::game) fn slot_get(&mut self, s: &Slot) -> Option<ItemStack> {
match s {
Slot::Contents(..) => None,
Slot::ContentItem(parent, component, index) => {
let stack = self.slot_get(parent)?;
component.items(&stack).get(*index).cloned().flatten()
}
Slot::Guest(conn, slot) => self
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
.flatten(),
@@ -476,6 +509,19 @@ impl Game<'_> {
/// Put a stack (or nothing) in a slot; false when the slot cannot take it.
pub(in crate::game) fn slot_set(&mut self, s: &Slot, stack: Option<ItemStack>) -> bool {
match s {
Slot::Contents(..) => false,
Slot::ContentItem(parent, component, index) => {
let Some(mut outer) = self.slot_get(parent) else {
return false;
};
let mut slots = component.items(&outer);
let Some(slot) = slots.get_mut(*index) else {
return false;
};
*slot = stack;
component.store(&mut outer, slots);
self.slot_set(parent, Some(outer))
}
Slot::Guest(conn, slot) => self
.for_player(Target::Guest(*conn), |g| g.slot_set(slot, stack))
.unwrap_or(false),
@@ -937,7 +983,7 @@ impl Game<'_> {
};
let modifier = self.item_modifier_arg(ctx, "modifier")?;
let source = self.command_source.unwrap_or_else(|| self.player_source());
let view = source.entity.map(|t| self.entity_view_of(t));
let view = source.entity.map(|t| self.entity_view_asked(t, &modifier));
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
@@ -959,6 +1005,29 @@ impl Game<'_> {
}
let mut changed = 0;
for candidate in &slots {
if let Slot::Contents(parent, component) = &candidate.slot {
changed += self.modify_contents(
parent,
*component,
candidate,
&mut selected,
|g, current| {
g.apply_item_modifier(
&modifier,
Some(current),
&mut rng,
view.as_ref(),
source.pos,
0,
)
.map(|mut s| {
s.count = s.count.min(g.stack_max_size(&s));
s
})
},
);
continue;
}
if !self.select_slot(candidate, &mut selected, true) {
continue;
}
@@ -1080,7 +1149,7 @@ impl Game<'_> {
if ctx.get("modifier").is_some() {
let modifier = self.item_modifier_arg(ctx, "modifier")?;
let source = self.command_source.unwrap_or_else(|| self.player_source());
let view = source.entity.map(|t| self.entity_view_of(t));
let view = source.entity.map(|t| self.entity_view_asked(t, &modifier));
let mut rng = horizon_core::random::LegacyRandom::new(0);
found = found
.into_iter()
@@ -1119,6 +1188,16 @@ impl Game<'_> {
let mut changed = 0;
let mut i = 0;
for candidate in &slots {
if let Slot::Contents(parent, component) = &candidate.slot {
changed += self.replace_contents(
parent,
*component,
candidate,
&mut selected,
&mut contents::Provider::new(&items, &mut i, &mode),
);
continue;
}
if !self.select_slot(candidate, &mut selected, false) {
continue;
}
@@ -0,0 +1,359 @@
//! ContentsSlotSource's live component collections; mutable until written back.
use super::{Game, ItemStack, Slot, SlotCandidate};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(in crate::game) enum Component {
Container,
Bundle,
Charged,
}
pub(super) struct Provider<'a> {
items: &'a [Option<ItemStack>],
position: &'a mut usize,
mode: &'a str,
}
impl<'a> Provider<'a> {
pub(super) fn new(
items: &'a [Option<ItemStack>],
position: &'a mut usize,
mode: &'a str,
) -> Self {
Self {
items,
position,
mode,
}
}
}
impl Component {
pub(super) fn named(name: &str) -> Option<Self> {
match name.strip_prefix("minecraft:").unwrap_or(name) {
"container" => Some(Self::Container),
"bundle_contents" => Some(Self::Bundle),
"charged_projectiles" => Some(Self::Charged),
_ => None,
}
}
pub(super) fn items(self, stack: &ItemStack) -> Vec<Option<ItemStack>> {
let Some(data) = stack.data() else {
return Vec::new();
};
match self {
Self::Container => {
let size = data
.container
.iter()
.map(|(i, _)| usize::from(*i) + 1)
.max()
.unwrap_or(0);
let mut out = vec![None; size];
for (i, item) in &data.container {
out[usize::from(*i)] = Some(item.clone());
}
out
}
Self::Bundle => data.bundle_contents.iter().cloned().map(Some).collect(),
Self::Charged => data.charged.iter().cloned().map(Some).collect(),
}
}
/// ComponentSlotCollection writes a single normalized component back to
/// its live parent; all unrelated parent components remain modeled.
pub(super) fn store(self, stack: &mut ItemStack, mut slots: Vec<Option<ItemStack>>) {
for slot in &mut slots {
if slot
.as_ref()
.is_some_and(|s| s.count == 0 || &*s.item == "minecraft:air")
{
*slot = None;
}
}
if self == Self::Bundle {
// BundleContents.Mutable.mergeIdenticalStacks: merge into a later
// matching entry, preserving the order of the surviving entries.
let mut i = 0;
while i < slots.len() {
let Some(item) = slots[i].as_ref() else {
slots.remove(i);
continue;
};
if let Some(j) = (i + 1..slots.len()).find(|&j| {
slots[j]
.as_ref()
.is_some_and(|s| s.same_item_same_components(item))
}) {
let count = item.count;
slots[j].as_mut().unwrap().count += count;
slots.remove(i);
} else {
i += 1;
}
}
}
let data = stack.data_mut();
match self {
Self::Container => {
data.container = slots
.into_iter()
.enumerate()
.filter_map(|(i, s)| s.map(|s| (i as u8, s)))
.collect()
}
Self::Bundle => data.bundle_contents = slots.into_iter().flatten().collect(),
Self::Charged => data.charged = slots.into_iter().flatten().collect(),
}
stack.tidy();
}
}
impl Game<'_> {
pub(super) fn contents_slots(&mut self, parent: Slot, component: Component) -> Vec<Slot> {
let size = self
.slot_get(&parent)
.map_or(0, |s| component.items(&s).len());
(0..size)
.map(|i| Slot::ContentItem(Box::new(parent.clone()), component, i))
.collect()
}
fn select_contents_item(
&mut self,
candidate: &SlotCandidate,
selected: &mut usize,
stack: Option<&ItemStack>,
nonempty: bool,
) -> bool {
if *selected >= candidate.limit || (nonempty && stack.is_none()) {
return false;
}
let air = ItemStack::new("minecraft:air", 0);
if !candidate
.filters
.iter()
.all(|f| self.loot_item_filter(f, stack.unwrap_or(&air)))
{
return false;
}
*selected += 1;
true
}
fn contents_can_grow(&self, component: Component, slots: &[Option<ItemStack>]) -> bool {
match component {
Component::Container => slots.len() < 256,
Component::Charged => slots.len() < 1024,
Component::Bundle => !horizon_client::bundle::Contents::from_items(
self.res.items(),
slots.iter().flatten().cloned().collect(),
)
.is_full(),
}
}
fn contents_accepts(
&self,
component: Component,
slots: &[Option<ItemStack>],
index: usize,
item: Option<&ItemStack>,
) -> bool {
if component != Component::Bundle {
return true;
}
let Some(item) = item else { return true };
if !horizon_client::bundle::can_be_in_bundle(item) {
return false;
}
let rest = slots
.iter()
.enumerate()
.filter(|(i, _)| *i != index)
.filter_map(|(_, s)| s.clone())
.collect();
horizon_client::bundle::Contents::from_items(self.res.items(), rest)
.room_for(self.res.items(), item)
>= item.count
}
/// ComponentSlotCollection.modifySlots: visit occupied slots only, then
/// commit the component through the same live parent slot.
pub(super) fn modify_contents(
&mut self,
parent: &Slot,
component: Component,
candidate: &SlotCandidate,
selected: &mut usize,
mut modifier: impl FnMut(&mut Self, ItemStack) -> Option<ItemStack>,
) -> i32 {
let Some(mut stack) = self.slot_get(parent) else {
return 0;
};
let mut slots = component.items(&stack);
let mut changed = 0;
for i in 0..slots.len() {
if !self.select_contents_item(candidate, selected, slots[i].as_ref(), true) {
continue;
}
let Some(current) = slots[i].clone() else {
continue;
};
let next =
modifier(self, current).filter(|s| s.count > 0 && &*s.item != "minecraft:air");
if self.contents_accepts(component, &slots, i, next.as_ref()) {
slots[i] = next;
changed += 1;
}
}
component.store(&mut stack, slots);
if self.slot_set(parent, Some(stack)) {
changed
} else {
0
}
}
/// GrowableMutableContainer.replaceSlotItems: consume one provider shared
/// by the owner's source terms, including new component slots.
pub(super) fn replace_contents(
&mut self,
parent: &Slot,
component: Component,
candidate: &SlotCandidate,
selected: &mut usize,
provider: &mut Provider<'_>,
) -> i32 {
let items = provider.items;
let position = &mut *provider.position;
let mode = provider.mode;
let original = self.slot_get(parent);
let mut stack = original
.clone()
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
let mut slots = component.items(&stack);
let mut changed = 0;
for i in 0..slots.len() {
if !has_next(items, *position, mode) {
break;
}
if !self.select_contents_item(candidate, selected, slots[i].as_ref(), false) {
continue;
}
let next = next_item(items, position, mode);
if self.contents_accepts(component, &slots, i, next.as_ref()) {
slots[i] = next;
changed += 1;
}
}
while has_next(items, *position, mode) && self.contents_can_grow(component, &slots) {
if !self.select_contents_item(candidate, selected, None, false) {
break;
}
if component != Component::Container && !find_nonempty(items, position, mode) {
break;
}
let next = peek_item(items, *position, mode);
if !self.contents_accepts(component, &slots, slots.len(), next.as_ref()) {
break;
}
slots.push(next_item(items, position, mode));
changed += 1;
}
// ItemStack.EMPTY cannot gain components, though its virtual mutable
// collection can still report replacements.
if original.is_none() {
return changed;
}
component.store(&mut stack, slots);
if self.slot_set(parent, Some(stack)) {
changed
} else {
0
}
}
}
fn has_next(items: &[Option<ItemStack>], position: usize, mode: &str) -> bool {
!items.is_empty() && (mode != "replace" || position < items.len())
}
fn peek_item(items: &[Option<ItemStack>], position: usize, mode: &str) -> Option<ItemStack> {
let index = if mode == "fill" {
position % items.len()
} else {
position
};
items
.get(index)
.cloned()
.flatten()
.filter(|s| s.count > 0 && &*s.item != "minecraft:air")
}
fn next_item(items: &[Option<ItemStack>], position: &mut usize, mode: &str) -> Option<ItemStack> {
let next = peek_item(items, *position, mode);
*position += 1;
next
}
fn find_nonempty(items: &[Option<ItemStack>], position: &mut usize, mode: &str) -> bool {
// ArrayItemProvider.findNext changes position only when it finds a match.
// A failed component append must leave air available to later source terms.
for offset in 0..items.len() {
let next = *position + offset;
if mode != "fill" && next >= items.len() {
return false;
}
if peek_item(items, next, mode).is_some() {
*position = next;
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::Tag;
#[test]
fn failed_component_growth_keeps_air_for_the_next_source_term() {
let items = [None];
let mut position = 0;
assert!(!find_nonempty(&items, &mut position, "replace"));
assert_eq!(position, 0);
assert!(has_next(&items, position, "replace"));
assert!(next_item(&items, &mut position, "replace").is_none());
assert_eq!(position, 1);
}
#[test]
fn component_storage_keeps_sparse_positions_and_the_live_parent() {
let mut parent = ItemStack::new("minecraft:stone", 1);
parent.data_mut().other.push((
"minecraft:custom_data".into(),
Tag::compound([("audit", Tag::Int(7))]),
));
let mut slots = vec![None; 6];
slots[0] = Some(ItemStack::new("minecraft:diamond", 1));
slots[5] = Some(ItemStack::new("minecraft:dirt", 7));
Component::Container.store(&mut parent, slots.clone());
assert_eq!((&*parent.item, parent.count), ("minecraft:stone", 1));
assert_eq!(
parent.data().unwrap().other("minecraft:custom_data"),
Some(&Tag::compound([("audit", Tag::Int(7))]))
);
assert_eq!(Component::Container.items(&parent), slots);
}
#[test]
fn bundle_writeback_merges_identical_entries_but_keeps_distinct_components() {
let mut parent = ItemStack::new("minecraft:bundle", 1);
Component::Bundle.store(
&mut parent,
vec![Some(ItemStack::new("minecraft:gold_ingot", 1)); 64],
);
assert_eq!(
parent.data().unwrap().bundle_contents,
vec![ItemStack::new("minecraft:gold_ingot", 64)]
);
let plain = ItemStack::new("minecraft:gold_ingot", 1);
let mut marked = plain.clone();
marked.data_mut().other.push((
"minecraft:custom_data".into(),
Tag::compound([("audit", Tag::Int(7))]),
));
Component::Bundle.store(&mut parent, vec![Some(plain.clone()), Some(marked.clone())]);
assert_eq!(parent.data().unwrap().bundle_contents, vec![plain, marked]);
}
}
@@ -1458,10 +1458,17 @@ impl Game<'_> {
if !self.native_may_board(id) {
return Err(generic());
}
self.riding = Some(id);
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding seats the player immediately.
let at = self.player_boat_seat(i);
if let Some(player) = self.player.as_mut() {
player.position = at;
player.velocity = horizon_core::geometry::Vec3::ZERO;
player.teleported = true;
}
self.previous_position = at;
// sendEventAndTriggers: START_RIDING_TRIGGER.
self.trigger_player("minecraft:started_riding");
self.start_riding(id);
}
self.feedback(Text::translate(
"commands.ride.mount.success",
@@ -1476,8 +1483,7 @@ impl Game<'_> {
{
return Err(generic());
}
self.riding = Some(id);
self.trigger_player("minecraft:started_riding");
self.start_riding(id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
@@ -1512,9 +1518,9 @@ impl Game<'_> {
m.extra.counter = 0;
self.yaw = m.yaw;
}
self.riding = Some(mob_id);
self.mount.jump_ticks = 0;
self.mount.jump_scale = 0.0;
self.start_riding(mob_id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
@@ -1551,11 +1557,22 @@ impl Game<'_> {
return;
}
self.mobs[i].extra.mount = None;
let remaining = self
.mobs
.iter()
.find(|m| m.extra.mount == Some(v))
.map(|m| m.id);
if let Some(vehicle) = self.mobs.iter_mut().find(|m| m.id == v) {
vehicle.extra.rider = remaining;
}
let (width, yaw) = (f64::from(self.mobs[i].width()), self.mobs[i].yaw);
let at = if let Some(b) = self.boats.iter().position(|b| b.id == v) {
self.boat_dismount_target(b, width, yaw)
} else if let Some(at) = self.horse_dismount_point(v, i) {
at
} else if let Some(at) = self.cart_dismount_point(v, i) {
at
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == v) {
m.extra.rider = None;
// Entity.getDismountLocationForPassenger: on top of the vehicle.
let p = m.position;
horizon_core::geometry::Vec3::new(p.x, p.y + f64::from(m.height()), p.z)
@@ -161,6 +161,7 @@ impl Game<'_> {
assets: &self.pack.assets,
collision_binding: self.collision,
resources: self.motion,
health: &self.health_resources,
}
.no_player_collision(bounds)
.unwrap_or(false);
@@ -303,8 +303,14 @@ impl Game<'_> {
if name == "minecraft:shears" && self.golem_shearable(index) {
// CopperGolem.shear: the sound, the antenna slot emptied (an
// unequip) and the flower dropped 1.5 up.
self.audio
.play("minecraft:entity.copper_golem.shear", Some(at), 1.0, 1.0);
self.play_mob_bound_sound(
index,
"minecraft:entity.copper_golem.shear",
7,
1.0,
1.0,
false,
);
let antenna = self.mobs[index].saddle_item();
self.mobs[index].set_saddle(None);
let id = self.mobs[index].id;
@@ -331,8 +337,7 @@ impl Game<'_> {
}
if is_axe && waxed {
self.mobs[index].extra.vector[0] = -1.0;
self.audio
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
self.play_mob_bound_sound(index, "minecraft:item.axe.scrape", 6, 1.0, 1.0, false);
self.level_event_at(3004, block, 0);
self.damage_held_tool();
return true;
@@ -341,8 +346,7 @@ impl Game<'_> {
let m = &mut self.mobs[index];
m.extra.vector[0] = -1.0;
m.extra.variant = horizon_client::mobs::copper_golem::weather_previous(m.extra.variant);
self.audio
.play("minecraft:item.axe.scrape", Some(at), 1.0, 1.0);
self.play_mob_bound_sound(index, "minecraft:item.axe.scrape", 6, 1.0, 1.0, false);
self.level_event_at(3005, block, 0);
self.damage_held_tool();
return true;
+107 -54
View File
@@ -566,11 +566,10 @@ impl Game<'_> {
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
.motion
.health
.health_resources
.damage
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
.is_some_and(|d| self.health_resources.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
// `doHurtEquipment` starts from the item's `equippable` component
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
@@ -1152,7 +1151,9 @@ impl Game<'_> {
..base
};
w.controlling_player_movement = controlling_player_movement;
let kept = m.tick(&w, &mut scratch, &mut events);
let kept =
horizon_client::mobs::riders::tick(&mut mobs, i, &w, &mut scratch, &mut events);
let m = &mut mobs[i];
if seen_guest.is_none() {
base.player = w.player;
}
@@ -1489,15 +1490,10 @@ impl Game<'_> {
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
let source = match d.attacker {
// Its callers are KilledTrigger's: the source as their killing_blow sees it.
Some(Who::Player) => Some(
self.player_entity_looking(
self.adv.tree.may_look("minecraft:player_killed_entity")
|| self
.adv
.tree
.may_look("minecraft:kill_mob_near_sculk_catalyst"),
),
),
Some(Who::Player) => Some(self.player_entity_for_any(&[
"minecraft:player_killed_entity",
"minecraft:kill_mob_near_sculk_catalyst",
])),
Some(Who::Mob(id)) => self
.mobs
.iter()
@@ -1851,8 +1847,8 @@ impl Game<'_> {
/// `LivingEntity.dropFromGiftLootTable`: the gift table from its random
/// sequence with the mob as `this` (a chicken's egg depends on its
/// variant); the loot data of the game's resources decides, and without it
/// the items' table engine (no entity) rolls it.
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) {
/// the items' table engine (no entity) rolls it. Whether anything dropped.
fn drop_gift_loot(&mut self, mob: u32, table: &'static str, at: [f64; 3]) -> bool {
use horizon_client::mobs::drops;
use horizon_world::loot::EntityLootParams;
let loot = horizon_client::structure_blocks::loot_data(
@@ -1888,11 +1884,13 @@ impl Game<'_> {
.items()
.loot_table(table, false, None, &mut self.world.random),
};
let dropped = !stacks.is_empty();
for d in stacks {
// The ItemEntity constructor's motion, from the entity's own
// random (not the level's).
self.spawn_item(d, at, dispenser::new_item_motion(), 10);
}
dropped
}
/// Adds a projectile, giving it an id from the entity counter
/// (`Entity.id`, the same counter as the rest) when it has none.
@@ -2046,6 +2044,29 @@ impl Game<'_> {
self.particles
.send_particles(&options, at, spread, speed, count);
}
MobEvent::EntitySound {
mob,
name,
at,
volume,
pitch,
source,
} => {
let silent = self
.mobs
.iter()
.find(|m| m.id == mob)
.is_some_and(|m| m.silent);
let entity = horizon_client::sound_engine::SoundEntity::new(mob, at, silent);
self.audio.play_bound(
&format!("minecraft:{name}"),
source,
volume,
pitch,
entity,
None,
);
}
MobEvent::Sound(name, at, volume, pitch)
| MobEvent::LocalSound(name, at, volume, pitch) => {
self.audio
@@ -2968,7 +2989,10 @@ impl Game<'_> {
}
}
MobEvent::StartRiding { rider, vehicle } => {
horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle);
if horizon_client::mobs::riders::start_riding(&mut self.mobs, rider, vehicle) {
// sendEventAndTriggers: the server players already on it.
self.note_started_riding(vehicle);
}
}
// Applied after the fish's tick (`apply_brain_events`).
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
@@ -3055,6 +3079,16 @@ impl Game<'_> {
let ctx = self.who_context(Some(Who::Mob(mob)), None);
self.post_game_event("minecraft:entity_place", at, ctx);
}
MobEvent::LayEgg { mob, at, pitch } => {
// Chicken.aiStep: `dropFromGiftLootTable` says whether
// anything dropped; only then the sound and the game event.
if self.drop_gift_loot(mob, "minecraft:gameplay/chicken_lay", at) {
self.audio
.play("minecraft:entity.chicken.egg", Some(at), 1.0, pitch);
let ctx = self.who_context(Some(Who::Mob(mob)), None);
self.post_game_event("minecraft:entity_place", at, ctx);
}
}
MobEvent::GiftLoot { mob, table, at } => {
// Turtle.ageBoundaryReached and Panda.afterSneeze: only
// with mob loot on.
@@ -3257,6 +3291,8 @@ impl Game<'_> {
.unwrap_or_else(|| parent.clone());
let mut child = parent.clone();
child.baby = true;
// Not born yet: none of the parent's saved data is its own.
child.nbt = None;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = matches!(p.species(), Species::Turtle | Species::Frog);
if matches!(
@@ -3631,9 +3667,7 @@ impl Game<'_> {
// A projectile entity dealt it: `#is_projectile`, or a firework
// rocket (its `fireworks` type is not in the tag).
let projectile = rocket
|| self
.res
.stations()
|| world_stations!(self)
.enchantments
.damage_tags
.get("minecraft:is_projectile")
@@ -3688,9 +3722,9 @@ impl Game<'_> {
// runs `applyItemBlocking` before `dealDefaultKnockback`).
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Some(id) = self.health_resources.damage.id(damage_type)
&& let Ok(o) = player.hurt_caused_by(
&self.motion.health,
&self.health_resources,
self.world.health_rules,
id,
amount,
@@ -3831,9 +3865,9 @@ impl Game<'_> {
let mut canceled = false;
if !self.creative
&& let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Some(id) = self.health_resources.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&self.motion.health,
&self.health_resources,
self.world.health_rules,
id,
amount,
@@ -3916,7 +3950,7 @@ impl Game<'_> {
}
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
let mut hits = Vec::new();
let mut deflections: Vec<[f64; 3]> = Vec::new();
let mut deflections: Vec<u32> = Vec::new();
let mut arrow_events = Vec::new();
let mut shoot_events = Vec::new();
let mut trails = Vec::new();
@@ -4094,8 +4128,7 @@ impl Game<'_> {
);
// Entity.deflection: a breeze turns every projectile but wind charges away.
if let Hit::Entity {
who: Who::Mob(id),
at,
who: Who::Mob(id), ..
} = hit
&& !wind
&& breezes.contains(&id)
@@ -4106,7 +4139,7 @@ impl Game<'_> {
let a = p.arrow.as_mut().expect("an arrow");
arrow_events.extend(a.events.drain(..).map(|e| (p.position, e)));
}
deflections.push([at.x, at.y, at.z]);
deflections.push(id);
continue;
}
// AbstractWindCharge.tick: out of the world's top it bursts.
@@ -4191,9 +4224,17 @@ impl Game<'_> {
for pos in bubble_tops {
self.bubble_column_particles(pos);
}
for at in deflections {
self.audio
.play("minecraft:entity.breeze.deflect", Some(at), 1.0, 1.0);
for id in deflections {
if let Some(index) = self.mobs.iter().position(|m| m.id == id) {
self.play_mob_bound_sound(
index,
"minecraft:entity.breeze.deflect",
5,
1.0,
1.0,
false,
);
}
}
for (at, event) in arrow_events {
self.arrow_event(at, event);
@@ -5632,6 +5673,12 @@ impl Game<'_> {
self.swing_arm();
return Ok(true);
}
if !self.mobs[index].kind.food.contains(&item)
&& self.interact_sheep_dye(index, self.hand, false)
{
self.swing_arm();
return Ok(true);
}
if self.interact_allay(index, held.as_ref()) {
self.swing_arm();
return Ok(true);
@@ -5801,6 +5848,14 @@ impl Game<'_> {
[m.position.x, m.position.y, m.position.z],
f64::from(m.height()),
));
self.audio.play_bound(
"minecraft:entity.mooshroom.shear",
7,
1.0,
1.0,
horizon_client::sound_engine::SoundEntity::new(m.id, feet, m.silent),
None,
);
m.removed = true;
let setup = SpawnSetup {
yaw: m.yaw,
@@ -5808,7 +5863,6 @@ impl Game<'_> {
..Default::default()
};
let pos = [m.position.x, m.position.y, m.position.z];
sound = Some("entity.mooshroom.shear");
self.handle_mob_events(vec![MobEvent::SpawnKind {
kind: "minecraft:cow".into(),
at: pos,
@@ -5830,6 +5884,14 @@ impl Game<'_> {
}
}
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
self.audio.play_bound(
"minecraft:entity.sheep.shear",
7,
1.0,
1.0,
horizon_client::sound_engine::SoundEntity::new(m.id, feet, m.silent),
None,
);
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops_table = format!("minecraft:shearing/sheep/{color}");
@@ -5841,26 +5903,8 @@ impl Game<'_> {
);
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
wool = true;
sound = Some("entity.sheep.shear");
self.damage_held_tool();
}
Species::Sheep if item.ends_with("_dye") => {
let name = item
.trim_start_matches("minecraft:")
.trim_end_matches("_dye");
if let Some(c) = horizon_client::mobs::render::DYE_NAMES
.iter()
.position(|d| *d == name)
&& m.extra.color as usize != c
&& !m.extra.sheared
{
m.extra.color = c as u8;
consume = true;
sound = Some("item.dye.use");
} else {
handled = false;
}
}
// Entity.interact: shears take a tamed wolf's armor off for its owner.
Species::Wolf
if item == "minecraft:shears"
@@ -6308,8 +6352,15 @@ impl Game<'_> {
.play(&format!("minecraft:{s}"), Some(center), 1.0, pitch);
}
if let Some((s, volume, pitch)) = eat {
self.audio
.play(&format!("minecraft:{s}"), Some(feet), volume, pitch);
let event = format!("minecraft:{s}");
if matches!(
self.mobs[index].species(),
Species::Frog | Species::Goat | Species::Sniffer
) {
self.play_mob_bound_sound(index, &event, 6, volume, pitch, false);
} else {
self.audio.play(&event, Some(feet), volume, pitch);
}
}
if consume
&& !creative
@@ -6417,7 +6468,7 @@ impl Game<'_> {
let at = m.position;
let mob = (m.id, super::native_hooks::mob_kind(m));
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.play_mob_bound_sound(index, sound, 7, 1.0, 1.0, false);
self.mobs[index].extra.sheared = true;
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let origin = [at.x, at.y, at.z];
@@ -7206,8 +7257,10 @@ impl Game<'_> {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0 && self.entity_glowing(a.id) {
glowing = Some((out.len(), super::glowing::team_color(self.mob_team(a.id))));
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
+4 -4
View File
@@ -468,8 +468,8 @@ mod tests {
let shot = game.audio.capture.take().unwrap_or_default();
let at: Vec<_> = shot
.iter()
.filter(|s| s.0 == "minecraft:entity.arrow.shoot")
.map(|s| s.1)
.filter(|s| s.event == "minecraft:entity.arrow.shoot")
.map(|s| s.position)
.collect();
assert_eq!(at, vec![feet]);
@@ -481,8 +481,8 @@ mod tests {
let shot = game.audio.capture.take().unwrap_or_default();
let at: Vec<_> = shot
.iter()
.filter(|s| s.0 == "minecraft:item.crossbow.shoot")
.map(|s| s.1)
.filter(|s| s.event == "minecraft:item.crossbow.shoot")
.map(|s| s.position)
.collect();
assert_eq!(at, vec![feet]);
}
+1 -1
View File
@@ -347,7 +347,7 @@ impl Game<'_> {
if self.riding.is_some() {
return true;
}
self.riding = Some(id);
self.start_riding(id);
let p = self.cushions[i].position;
self.audio.play(
"minecraft:entity.cushion.sit",
@@ -62,6 +62,22 @@ pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
}
}
/// The `S` line for a level event whose sound `LevelEventHandler` plays on the
/// client (`playLocalSound`), so Horizon plays it unshared and never captures
/// it: the lava fizz (1501, #7652) and `ComposterBlock.handleFill` (1500,
/// `data > 0` = success, #9069).
fn event_sound(line: &str) -> Option<String> {
let mut parts = line.strip_prefix("L ")?.split(' ');
let (id, pos, data) = (parts.next()?, parts.next()?, parts.next()?);
let sound = match id {
"1501" => "minecraft:block.lava.extinguish",
"1500" if data.parse::<i32>().ok()? > 0 => "minecraft:block.composter.fill_success",
"1500" => "minecraft:block.composter.fill",
_ => return None,
};
Some(format!("S {pos} {sound}"))
}
/// Java's `String.format("%.6f")`: the exact value rounded half up (away
/// from zero) where Rust rounds a tie to even.
fn num(v: f64) -> String {
@@ -431,18 +447,11 @@ impl Game<'_> {
out.push(self.fixture_describe(i, t));
}
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
// LevelEventHandler's lava fizz (1501): the client plays it from the
// level event, so it is not captured as a shared sound (#7652).
let fizzes: Vec<String> = log
.iter()
.filter_map(|line| line.strip_prefix("L 1501 "))
.filter_map(|rest| rest.split(' ').next())
.map(|pos| format!("S {pos} minecraft:block.lava.extinguish"))
.collect();
let event_sounds: Vec<String> = log.iter().filter_map(|l| event_sound(l)).collect();
out.extend(log);
out.extend(fizzes);
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
out.push(format!("S {} {sound}", floor_pos(at)));
out.extend(event_sounds);
for sound in self.audio.capture.take().unwrap_or_default() {
out.push(format!("S {} {}", floor_pos(sound.position), sound.event));
}
out.extend(used);
out.push(format!("rng {}", self.world.random.next_long()));
+134 -16
View File
@@ -111,6 +111,12 @@ pub(super) struct Fight {
needs_scan: bool,
pub killed: bool,
pub previously_killed: bool,
/// `dragonUUID`: the fight's own dragon. Only its death wins the fight
/// (`setDragonKilled`); a dragon summoned after the fight is won is not it.
dragon_uuid: Option<[i32; 4]>,
/// `ticksSinceDragonSeen`: ticks since the fight's dragon last ticked
/// (`updateDragon`); at 1200 the fight looks for another one.
dragon_unseen: i32,
gateways: Vec<i32>,
exit_portal: Option<[i32; 3]>,
alive_crystals: i32,
@@ -838,6 +844,10 @@ impl Game<'_> {
Some(Tag::IntArray(v)) if v.len() == 3 => Some([v[0], v[1], v[2]]),
_ => None,
},
dragon_uuid: match data.and_then(|d| d.get("dragon_uuid")) {
Some(Tag::IntArray(v)) if v.len() == 4 => Some([v[0], v[1], v[2], v[3]]),
_ => None,
},
// The first tick scans for players.
player_scan: 21,
..Default::default()
@@ -889,6 +899,9 @@ impl Game<'_> {
if let Some(p) = f.exit_portal {
data.push(("exit_portal_location", Tag::IntArray(p.to_vec())));
}
if let Some(u) = f.dragon_uuid {
data.push(("dragon_uuid", Tag::IntArray(u.to_vec())));
}
if let Some((stage, _)) = f.respawn {
data.push(("respawn_stage", Tag::String(stage.name().into())));
data.push((
@@ -1021,11 +1034,7 @@ impl Game<'_> {
}
if self.fight.players {
if self.fight.needs_scan {
// scanState: a missing exit portal means a fight in progress.
self.fight.previously_killed = self.end_portal_active();
if !self.fight.previously_killed {
self.fight.killed = false;
}
self.scan_state();
self.fight.needs_scan = false;
self.fight.dirty = true;
}
@@ -1033,8 +1042,19 @@ impl Game<'_> {
self.tick_respawn();
}
if !self.fight.killed {
if self.dragon.is_none() {
self.create_new_dragon();
// Without a dragon of its own, or 1200 ticks after it last
// ticked, the fight takes the End's dragon or makes one
// (`findOrCreateDragon`).
let look = match self.fight.dragon_uuid {
None => true,
Some(_) => {
self.fight.dragon_unseen += 1;
self.fight.dragon_unseen >= 1200
}
};
if look {
self.find_or_create_dragon();
self.fight.dragon_unseen = 0;
}
self.fight.crystal_scan += 1;
if self.fight.crystal_scan >= 100 {
@@ -1056,6 +1076,12 @@ impl Game<'_> {
self.next_entity += 1;
c.id = self.next_entity;
}
// updateDragon, from the fight's dragon's aiStep (not with NoAI) and
// tickDeath: it has been seen.
let ticks = self.dragon.as_ref().is_some_and(|d| !d.alive() || !d.no_ai);
if ticks && self.is_fight_dragon() {
self.fight.dragon_unseen = 0;
}
self.tick_dragon();
}
/// EndSpikeFeature's crystals (with fire under them on the bedrock).
@@ -2389,6 +2415,16 @@ impl Game<'_> {
d.in_wall = in_wall;
}
}
/// `Entity.isSilent` of the dragon: its `Silent`, kept with the fields
/// Horizon models nowhere (`remember_tags`: `/summon`, a load).
fn dragon_silent(&self) -> bool {
let raw = self
.dragon
.as_ref()
.filter(|d| d.id != 0)
.and_then(|d| self.persist.raw.get(&d.id));
saved_silent(raw)
}
/// `tickDeath`: rising for 200 ticks, explosions, experience, then the
/// exit portal, the egg and a gateway.
fn tick_dragon_death(&mut self) {
@@ -2414,18 +2450,19 @@ impl Game<'_> {
[0.0; 3],
);
}
// ServerLevel.getDragonFight: only the End has a fight; elsewhere
// (`/summon`) a dragon is worth 500 and its death ends nothing.
let fight = self.dim == Dim::End;
// Only the fight's own dragon has a fight (`dragonFight`): elsewhere
// (`/summon`), or summoned in the End after the fight is won, a
// dragon is worth 500 and its death ends nothing.
let fight = self.is_fight_dragon();
let first_kill = fight && !self.fight.previously_killed;
let xp = if first_kill { 12000 } else { 500 };
let drops = self.ui.rules.bool("mob_drops");
if t > 150 && t % 5 == 0 && drops {
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.08) as u32);
}
if t == 1 {
if t == 1 && !self.dragon_silent() {
// EnderDragon.tickDeath: global level event 1028, the death
// roar, heard everywhere.
// roar, heard everywhere, unless the dragon is silent.
let at = [p.x, p.y, p.z].map(|v| v.floor() as i32);
self.global_level_event(1028, at, 0);
}
@@ -2715,7 +2752,7 @@ impl Game<'_> {
if hurts {
self.note_dragon_attacker("minecraft:mob_attack");
}
let (health, rules) = (&self.motion.health, self.world.health_rules);
let (health, rules) = (&self.health_resources, self.world.health_rules);
let id = health.damage.id("minecraft:mob_attack");
let mut canceled = false;
if let Some(p) = self.player.as_mut() {
@@ -3254,6 +3291,11 @@ impl Game<'_> {
}
}
}
/// `Entity.isSilent` from an entity's saved compound: `Silent`, written only
/// when set.
fn saved_silent(raw: Option<&Tag>) -> bool {
matches!(raw.and_then(|t| t.get("Silent")), Some(Tag::Byte(b)) if *b != 0)
}
/// Saving and loading the dragon and the crystals as chunk entities.
impl Dragon {
pub(super) fn to_nbt(&self, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
@@ -3550,9 +3592,10 @@ impl Game<'_> {
/// drops it, and a killed dragon never gets there.
pub(super) fn kill_dragon(&mut self, id: u32) {
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
// Only the fight's own dragon (`dragonFight`) ends it.
let fight = self.is_fight_dragon();
self.dragon = None;
// ServerLevel.getDragonFight: only the End has a fight.
if self.dim == Dim::End {
if fight {
self.dragon_killed();
}
}
@@ -3847,10 +3890,67 @@ impl Game<'_> {
}
}
/// `EnderDragonFight.createNewDragon`: over the podium, holding pattern, a random yaw.
/// It becomes the fight's dragon (`dragonUUID = dragon.getUUID()`).
fn create_new_dragon(&mut self) {
let yaw = self.world.random.next_float() * 360.0;
let seed = self.world.random.next_long();
self.dragon = Some(Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed));
let mut d = Dragon::new(Vec3::new(0.0, 128.0, 0.0), yaw, seed);
// EnderDragon's constructor takes its id and its parts' from the
// entity counter.
d.id = self.next_entity + 1;
self.next_entity += 9;
let id = d.id;
self.dragon = Some(d);
self.fight.dragon_uuid = Some(self.uuid_for(id));
self.fight.dirty = true;
}
/// `EnderDragonFight.findOrCreateDragon`: the End's dragon becomes the
/// fight's, or a new one is made.
fn find_or_create_dragon(&mut self) {
match self.dragon.as_ref().map(|d| (d.id, d.alive())) {
None => self.create_new_dragon(),
Some((id, true)) => {
self.fight.dragon_uuid = Some(self.uuid_for(id));
self.fight.dirty = true;
}
// `ServerLevel.getDragons` takes only living dragons, so vanilla
// makes a new one beside a dying one; with one dragon to a level,
// the new one comes once the dying one is gone.
Some((_, false)) => {}
}
}
/// `EnderDragonFight.scanState`, for a world from before the fight was
/// saved: an active exit portal means the dragon was killed before. A
/// living dragon there is the fight's, unless there is no active portal:
/// then it is removed for a new one.
fn scan_state(&mut self) {
let active = self.end_portal_active();
self.fight.previously_killed = active;
match self.dragon.as_ref().filter(|d| d.alive()).map(|d| d.id) {
None => self.fight.killed = true,
Some(id) => {
self.fight.dragon_uuid = Some(self.uuid_for(id));
self.fight.killed = false;
if !active {
self.dragon = None;
self.fight.dragon_uuid = None;
}
}
}
if !self.fight.previously_killed && self.fight.killed {
self.fight.killed = false;
}
}
/// The dragon is the End fight's own (`EnderDragon.dragonFight`, set
/// only for the dragon whose UUID is the fight's `dragonUUID`).
fn is_fight_dragon(&mut self) -> bool {
if self.dim != Dim::End {
return false;
}
match (self.dragon.as_ref().map(|d| d.id), self.fight.dragon_uuid) {
(Some(id), Some(uuid)) if id != 0 => self.uuid_for(id) == uuid,
_ => false,
}
}
/// The END stage: the portal crystals blow up (no block damage) and a
/// new dragon takes over.
@@ -4344,6 +4444,24 @@ mod tests {
)
}
/// `EnderDragon.tickDeath` roars (`globalLevelEvent(1028)`) only when
/// `!isSilent()`; `Silent` is saved only when set, as a byte.
#[test]
fn a_dragon_is_silent_only_with_a_set_silent_byte() {
use horizon_world::nbt::{Compound, Tag};
let with = |v: Option<i8>| {
let mut map = Compound::new();
if let Some(b) = v {
map.insert("Silent".into(), Tag::Byte(b));
}
Tag::Compound(map)
};
assert!(!super::saved_silent(None));
assert!(!super::saved_silent(Some(&with(None))));
assert!(!super::saved_silent(Some(&with(Some(0)))));
assert!(super::saved_silent(Some(&with(Some(1)))));
}
#[test]
fn breath_lifetime_excludes_the_wait() {
assert!(!breath_expired(219, 200));
+68 -26
View File
@@ -68,6 +68,8 @@ enum Arriving {
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
/// A vehicle remains in this level while its destination loads.
Vehicle(u32),
}
impl GatewayJob {
@@ -1361,6 +1363,32 @@ impl Game<'_> {
.iter()
.filter(|m| self.mob_can_use_portal(m))
.map(|m| (m.id, m.position, m.width(), m.height()))
.chain(
self.boats
.iter()
.filter(|b| self.vehicle_can_use_portal(b.id))
.map(|b| {
(
b.id,
b.position,
super::boats::WIDTH as f32,
super::boats::HEIGHT as f32,
)
}),
)
.chain(
self.carts
.iter()
.filter(|c| self.vehicle_can_use_portal(c.id))
.map(|c| {
(
c.id,
c.position,
super::minecarts::WIDTH as f32,
super::minecarts::HEIGHT as f32,
)
}),
)
.collect();
let mut inside: Vec<(u32, BlockPos)> = Vec::new();
for (id, to, width, height) in items.into_iter().chain(mobs) {
@@ -1428,6 +1456,16 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.boats
.iter()
.map(|b| (b.id, self.vehicle_can_use_portal(b.id))),
)
.chain(
self.carts
.iter()
.map(|c| (c.id, self.vehicle_can_use_portal(c.id))),
)
.chain(
self.creatures
.projectiles
@@ -1440,6 +1478,7 @@ impl Game<'_> {
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Vehicle(id) => Some(*id),
Arriving::Saved(_) => None,
}));
self.end.gateways.retain(|id, _| present.contains(id));
@@ -1462,8 +1501,27 @@ impl Game<'_> {
}
/// `Entity.teleportToPortalDestination` for an entity that goes through an
/// End gateway: it is saved and waits for the exit's chunks.
/// End gateway: a same-level vehicle stays live while the exit loads.
fn send_through_gateway(&mut self, id: u32, gateway: BlockPos) {
if matches!(
self.level_entity(id as i32),
Some(
super::commands::Target::Mob(_)
| super::commands::Target::Boat(_)
| super::commands::Target::Cart(_)
)
) {
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Vehicle(id),
job,
target,
};
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
}
let tag = if let Some(i) = self.dropped.iter().position(|e| e.id == id) {
let uuid = self.uuid_for(id);
let tag = horizon_client::entity_nbt::item_entity_to_nbt(
@@ -1477,31 +1535,6 @@ impl Game<'_> {
self.native_leave_skip(id);
self.detach_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
// Entity.removeAfterChangingDimensions drops the leash.
if let Tag::Compound(c) = &mut tag {
c.remove(&"leash".into());
}
// The mobs riding it travel with it (their tags are in its compound).
let mut gone = vec![id];
let mut k = 0;
while k < gone.len() {
let v = gone[k];
gone.extend(
self.mobs
.iter()
.filter(|m| m.extra.mount == Some(v))
.map(|m| m.id),
);
k += 1;
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.native_leave_skip(g);
self.detach_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
self.native_leave_skip(id);
@@ -1564,6 +1597,15 @@ impl Game<'_> {
};
let key = chunk(at);
match arrival.traveller {
Arriving::Vehicle(id) => {
if let Some(target) = self.level_entity(id as i32) {
// Entity.teleportSameDimension keeps the live entity and
// its motion, and moves every passenger in the same operation.
self.teleport_to_in_level(target, [at.x, at.y, at.z], None, false);
self.set_portal_cooldown(id);
self.place_portal_ticket(key);
}
}
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
@@ -0,0 +1,106 @@
//! The entity overload of level sounds, shared by mob and player call sites.
use super::Game;
use horizon_client::sound_engine::{SoundEntity, SoundEntityState};
use horizon_world::nbt::Tag;
fn saved_silent(extra: Option<&Tag>) -> bool {
horizon_client::save::number(extra.and_then(|tag| tag.get("Silent"))).unwrap_or(0) != 0
}
impl Game<'_> {
/// `Level.playSound(except, mob, ...)`: the category and actor exclusion
/// belong to the call site, not to the event's name.
pub(super) fn play_mob_bound_sound(
&mut self,
index: usize,
event: &str,
source: usize,
volume: f32,
pitch: f32,
except_actor: bool,
) {
let mob = &self.mobs[index];
let entity = horizon_client::sound_engine::SoundEntity::new(
mob.id,
[mob.position.x, mob.position.y, mob.position.z],
mob.silent,
);
let except = except_actor.then(|| self.native_acting_entity());
self.audio
.play_bound(event, source, volume, pitch, entity, except);
}
fn sound_player_id(&self) -> u32 {
self.remote
.as_ref()
.map_or_else(|| self.native_acting_entity(), |r| r.entity_id as u32)
}
/// The player in place can be a guest while `as_guest` runs. Its entity
/// id and saved metadata travel with that action, even while the hub is out.
pub(super) fn play_player_bound_sound(
&mut self,
event: &str,
source: usize,
volume: f32,
pitch: f32,
except_actor: bool,
) {
let id = self.sound_player_id();
let Some(state) = self.sound_entity_state(id) else {
return;
};
let entity = SoundEntity::new(id, state.position, state.silent);
entity.update(state);
self.audio.play_bound(
event,
source,
volume,
pitch,
entity,
except_actor.then_some(id),
);
}
pub(super) fn sound_entity_state(&self, id: u32) -> Option<SoundEntityState> {
if self.remote.is_some() {
return self.remote_sound_entity_state(id as i32);
}
if id == self.sound_player_id()
&& let Some(player) = self.player.as_ref()
{
return Some(SoundEntityState {
position: [player.position.x, player.position.y, player.position.z],
silent: saved_silent(self.level.player_extra.as_ref()),
removed: false,
});
}
if let Some(guest) = self.lan.as_ref().and_then(|host| {
host.hub
.guests
.iter()
.find(|g| g.entity_id as u32 == id && g.placed && g.dimension == self.dim.id())
}) {
return Some(SoundEntityState {
position: guest.position,
silent: saved_silent(guest.saved.as_ref()),
removed: false,
});
}
if let Some(t) = self.tridents.iter().find(|t| t.id == id) {
return Some(SoundEntityState {
position: [t.position.x, t.position.y, t.position.z],
silent: false,
removed: false,
});
}
self.mobs
.iter()
.find(|m| m.id == id)
.map(|m| SoundEntityState {
position: [m.position.x, m.position.y, m.position.z],
silent: m.silent,
removed: m.removed,
})
}
}
+2 -2
View File
@@ -454,14 +454,14 @@ impl Game<'_> {
self.note_named_attacker(damage_type, *id, name.clone());
}
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Some(id) = self.health_resources.damage.id(damage_type)
{
// Creative players are invulnerable to explosions.
if !creative
&& !spectator
&& hurtable
&& let Ok(o) = p.hurt_caused_by(
&self.motion.health,
&self.health_resources,
self.world.health_rules,
id,
amount,
+3 -1
View File
@@ -62,6 +62,7 @@ impl Game<'_> {
let now = self.extras_now();
self.extras.fps.record(now, elapsed.as_secs_f32() * 1000.0);
self.native_frame(elapsed);
self.pvp_frame();
self.recipes_received_now();
}
/// The frame counter for mods: fps, min, 1% low.
@@ -259,13 +260,14 @@ impl Game<'_> {
};
extras_hud::draw_minimap(batch, atlas, &mut corners, &look);
}
self.draw_pvp_hud(batch, &mut corners);
self.native_hud(batch, &mut corners);
}
/// The height the status effect icons take at the top right
/// (`Hud.extractEffects`: beneficial at y 1, harmful at y 26).
fn effect_rows_height(&self) -> f32 {
let effects = &self.potions.effects.list;
if effects.is_empty() || self.screen != Screen::Playing {
if effects.is_empty() || self.screen != Screen::Playing || self.pvp_effects_shown() {
return 0.0;
}
let harmful = effects.iter().any(|e| {
@@ -197,9 +197,9 @@ impl Game<'_> {
} else {
amount
};
let hurt = match (self.player.as_mut(), self.motion.health.damage.id(kind)) {
let hurt = match (self.player.as_mut(), self.health_resources.damage.id(kind)) {
(Some(p), Some(id)) => p
.hurt(&self.motion.health, self.world.health_rules, id, amount)
.hurt(&self.health_resources, self.world.health_rules, id, amount)
.unwrap_or(false),
_ => false,
};
+2 -2
View File
@@ -119,7 +119,7 @@ pub(super) fn overlay_files(
for (path, bytes) in contributed {
// One bad file would fail its whole table and drop the world's pack
// content with it, so it is skipped alone: every kind is JSON.
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
if let Err(e) = serde_json::from_slice::<Box<serde_json::value::RawValue>>(&bytes) {
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
continue;
}
@@ -999,7 +999,7 @@ impl Game<'_> {
.ok_or_else(|| err("argument.predicate.unknown", &[&id]))?
};
let source = self.command_source.unwrap_or_else(|| self.player_source());
let view = source.entity.map(|t| self.entity_view_of(t));
let view = source.entity.map(|t| self.entity_view_asked(t, &json));
Ok(self.conditions_hold(&json, view.as_ref(), source.pos))
}
}
+14 -6
View File
@@ -99,13 +99,12 @@ impl Game<'_> {
// timeout.
self.mobs[index].extra.counter = 0;
self.yaw = self.mobs[index].yaw;
self.riding = Some(id);
self.mount.reset_jump();
if was_empty {
let down = happy_ghast::goggles(true, &self.mobs[index]);
let _ = self.handle_mob_events(vec![down]);
}
self.trigger_player("minecraft:started_riding");
self.start_riding(id);
return true;
}
false
@@ -116,7 +115,7 @@ impl Game<'_> {
// aboard the ghast forgets its home.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.extra.counter = 10;
m.ai.home = None;
m.ai.clear_home();
}
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
@@ -164,9 +163,18 @@ impl Game<'_> {
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
let at = m.center();
self.audio
.play("minecraft:entity.ghastling.spawn", Some(at), 1.0, 1.0);
self.audio.play_bound(
"minecraft:entity.ghastling.spawn",
4,
1.0,
1.0,
horizon_client::sound_engine::SoundEntity::new(
m.id,
[m.position.x, m.position.y, m.position.z],
m.silent,
),
None,
);
}
}
}
+27 -11
View File
@@ -22,20 +22,36 @@ impl Game<'_> {
})
}
/// The outline an entity is drawn with: vanilla's (`Some` team colour when it glows),
/// then what mods made of it (`native_entity_outline`).
pub(super) fn entity_outline(&self, id: u32, kind: &str, vanilla: Option<u32>) -> Option<u32> {
let mut outline = vanilla;
self.native_entity_outline(id, kind, &mut outline);
outline.map(|rgb| rgb & 0xFF_FFFF)
}
/// The outline of a dropped item or projectile: its `Glowing` flag, in its team's colour.
pub(super) fn thing_outline(&self, id: u32, kind: &str) -> Option<u32> {
let vanilla = self
.entity_glowing(id)
.then(|| team_color(self.mob_team(id)));
self.entity_outline(id, kind, vanilla)
}
/// `Minecraft.shouldEntityAppearGlowing` of a mob or another player, with its outline
/// colour (`getTeamColor`; another player's team goes by name).
pub(super) fn mob_outline(&self, m: &Mob) -> Option<u32> {
if !m.is_currently_glowing() {
return None;
}
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team_of(m)
};
Some(team_color(team))
let vanilla = m.is_currently_glowing().then(|| {
let team = if &*m.kind.id == "minecraft:player" {
let names = self.remote_player_names();
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team_of(m)
};
team_color(team)
});
self.entity_outline(m.id, &m.kind.id, vanilla)
}
/// Adds one glowing entity's geometry to the outline pass in `rgb`, the textures'
Loaded 100 of 441 files, more files were not shown because too many files have changed in this diff. Show more