Merge remote-tracking branch 'origin/main' into cg42b/9421

# Conflicts:
#	launcher/src/wardrobe/tests.rs
This commit is contained in:
Horizon agent committed 2026-11-05 04:07:33 +01:00
commit 95124d4a75
521 files changed
+35004 -2509

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same t
## Download
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
@@ -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");
+216 -14
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>,
@@ -693,17 +705,27 @@ fn open_device(
) -> Result<(cpal::Stream, cpal::SupportedStreamConfig)> {
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
let build = |stream_config: &cpal::StreamConfig| match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, stream_config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, stream_config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, stream_config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, stream_config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, stream_config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, stream_config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, stream_config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, stream_config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
};
let short = audio_stream_config(&config);
let stream = match build(&short) {
Ok(stream) => stream,
// A device that refuses the short fixed buffer still plays with cpal's default one.
Err(e) if short != config.config() => {
log::info!("Horizon: audio output refused a short buffer ({e}); using the default");
build(&config.config())?
}
Err(e) => return Err(e),
};
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
@@ -784,10 +806,35 @@ fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
_ => true,
}
}
/// The cpal `StreamConfig` to open an output stream with. On Linux the ALSA
/// backend's `BufferSize::Default` means a ~100 ms buffer, so sound effects
/// come out about 100 ms late: request a short fixed buffer there. Other
/// platforms keep cpal's default behaviour.
#[cfg(feature = "audio")]
fn audio_stream_config(config: &cpal::SupportedStreamConfig) -> cpal::StreamConfig {
#[cfg(target_os = "linux")]
{
let range = match config.buffer_size() {
cpal::SupportedBufferSize::Range { min, max } => (*min, *max),
cpal::SupportedBufferSize::Unknown => (256, 4096),
};
let target = 1024u32.clamp(range.0, range.1);
let mut sc = config.config();
sc.buffer_size = cpal::BufferSize::Fixed(target);
sc
}
#[cfg(not(target_os = "linux"))]
{
config.config()
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
config: &cpal::SupportedStreamConfig,
stream_config: &cpal::StreamConfig,
mixer: Arc<Mutex<Mixer>>,
) -> Result<cpal::Stream> {
let rate = config.sample_rate().0;
@@ -795,7 +842,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
stream_config,
move |data: &mut [T], _| {
scratch.resize(data.len(), 0.0f32);
if let Ok(mut m) = mixer.lock() {
@@ -1262,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();
@@ -1301,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,
@@ -1311,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;
@@ -1451,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 =
@@ -89,6 +89,7 @@ pub struct BlockEntities {
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
/// Like `mods`, one taken out stays listed until `put` or `remove`.
boxes: FxHashSet<Pos>,
asleep: FxHashSet<Pos>,
}
/// A structure or test instance block: the block entities whose box the frame may draw.
@@ -229,6 +230,7 @@ impl BlockEntities {
}
/// `removeBlockEntityTicker`: no slot, made or pending.
fn cancel(&mut self, pos: &Pos) {
self.asleep.remove(pos);
self.freed |= self.live.remove(pos).is_some();
self.pending.remove(pos);
}
@@ -297,14 +299,25 @@ impl BlockEntities {
self.map.get(pos)
}
pub fn get_mut(&mut self, pos: &Pos) -> Option<&mut BlockEntity> {
self.asleep.remove(pos);
self.map.get_mut(pos)
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (&Pos, &mut BlockEntity)> {
self.asleep.clear();
self.map.iter_mut()
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut BlockEntity> {
self.asleep.clear();
self.map.values_mut()
}
pub fn sleep(&mut self, pos: Pos) {
if self.map.contains_key(&pos) {
self.asleep.insert(pos);
}
}
pub fn is_asleep(&self, pos: &Pos) -> bool {
self.asleep.contains(pos)
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
@@ -320,6 +333,7 @@ impl BlockEntities {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
self.asleep.remove(&pos);
let old = self.map.insert(pos, entity);
if old.is_none() {
self.add(pos);
@@ -346,6 +360,7 @@ impl BlockEntities {
if absent {
self.add(pos);
}
self.asleep.remove(&pos);
let entity = self.map.entry(pos).or_insert_with(make);
if absent && matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
@@ -358,6 +373,7 @@ impl BlockEntities {
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.asleep.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
@@ -369,6 +385,7 @@ impl BlockEntities {
/// The block entity at `pos`, out of the map for a while: its slot stays
/// for `put` to bring it back (a tick working on it).
pub fn take(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.asleep.remove(pos);
let taken = self.map.remove(pos);
if taken.is_some() {
self.out.insert(*pos);
@@ -378,6 +395,7 @@ impl BlockEntities {
/// Brings back what `take` took, in its slot.
pub fn put(&mut self, pos: Pos, entity: BlockEntity) {
self.out.remove(&pos);
self.asleep.remove(&pos);
if matches!(entity, BlockEntity::Mod(_)) {
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
}
@@ -399,6 +417,7 @@ impl BlockEntities {
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.asleep.clear();
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
@@ -416,6 +435,7 @@ impl BlockEntities {
self.map.clear();
self.mods.clear();
self.boxes.clear();
self.asleep.clear();
self.tickers = Arc::default();
self.live.clear();
self.added.clear();
@@ -433,6 +453,7 @@ impl BlockEntities {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
self.asleep.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
@@ -452,6 +473,7 @@ impl BlockEntities {
/// `LevelChunk.setBlockState` on a block entity's position, from state
/// `old` to `new`: a ticker made, kept or dropped.
pub fn state_changed(&mut self, pos: Pos, old: u32, new: u32) {
self.asleep.remove(&pos);
if self.states.is_empty() || !self.map.contains_key(&pos) {
return;
}
@@ -542,6 +564,51 @@ mod tests {
found
}
#[test]
fn a_sleeping_block_entity_wakes_on_any_change() {
let p = (1, 64, 1);
let mut b = level();
b.sleep(p);
assert!(!b.is_asleep(&p));
b.insert(p, hopper());
let asleep = |b: &mut BlockEntities, wake: &dyn Fn(&mut BlockEntities)| {
b.sleep(p);
assert!(b.is_asleep(&p));
wake(b);
b.is_asleep(&p)
};
assert!(asleep(&mut b, &|_| {}));
assert!(!asleep(&mut b, &|b| {
b.get_mut(&p);
}));
assert!(!asleep(&mut b, &|b| {
b.insert(p, hopper());
}));
assert!(!asleep(&mut b, &|b| {
b.get_or_insert_with(p, hopper);
}));
assert!(!asleep(&mut b, &|b| {
let held = b.take(&p).unwrap();
b.put(p, held);
}));
assert!(!asleep(&mut b, &|b| b.state_changed(p, 1, 1)));
assert!(!asleep(&mut b, &|b| b.forget(&p)));
assert!(!asleep(&mut b, &|b| {
b.iter_mut().count();
}));
assert!(!asleep(&mut b, &|b| {
b.values_mut().count();
}));
assert!(!asleep(&mut b, &|b| b.retain(|_, _| true)));
assert!(!asleep(&mut b, &|b| b.load_chunk(vec![(p, hopper())])));
assert!(!asleep(&mut b, &|b| {
b.remove(&p);
b.insert(p, hopper());
}));
b.sleep(p);
b.clear();
assert!(!b.is_asleep(&p));
}
#[test]
fn mod_block_entities_are_listed_by_chunk_until_they_go() {
let mut b = level();
let modded = || BlockEntity::Mod(Box::default());
@@ -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).
@@ -340,6 +340,17 @@ impl BrewingStand {
}
out
}
pub fn idle(&self) -> bool {
let bottles = [
self.items[0].is_some(),
self.items[1].is_some(),
self.items[2].is_some(),
];
self.brew_time <= 0
&& self.items[3].is_none()
&& (self.fuel > 0 || self.items[4].is_none())
&& self.last_bottles == Some(bottles)
}
fn brew(&mut self, brewing: &Brewing, items: &Items, out: &mut BrewTick) {
let Some(ingredient) = self.items[3].clone() else {
return;
@@ -455,6 +466,27 @@ mod tests {
assert_eq!(stand.brew_time, 0);
}
#[test]
fn a_brewing_stand_is_idle_only_while_its_tick_changes_nothing() {
let mut stand = BrewingStand::new();
assert!(!stand.idle());
stand.last_bottles = Some([false; 3]);
assert!(stand.idle());
stand.items[0] = Some(ItemStack::new("minecraft:potion", 1));
assert!(!stand.idle());
stand.last_bottles = Some([true, false, false]);
assert!(stand.idle());
stand.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
assert!(!stand.idle());
stand.fuel = 3;
assert!(stand.idle());
stand.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
assert!(!stand.idle());
stand.items[3] = None;
stand.brew_time = 5;
assert!(!stand.idle());
}
/// #3312: `PotionContents.getPotionDescription` names the effects with
/// translatable components, so a Russian game reads them from ru_ru.json
/// (the prepared one; skipped without it), as the tooltip's other lines.
+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"))
}
@@ -215,6 +215,13 @@ impl Campfire {
}
changed
}
pub fn idle(&self, lit: bool) -> bool {
if lit {
self.items.iter().all(Option::is_none)
} else {
self.progress.iter().all(|&p| p <= 0)
}
}
}
impl Skull {
pub fn new(
@@ -729,6 +736,19 @@ mod tests {
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn a_campfire_is_idle_only_while_its_tick_changes_nothing() {
let mut c = Campfire::default();
assert!(c.idle(true) && c.idle(false));
assert!(c.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
assert!(!c.idle(true));
assert!(c.idle(false));
c.cook_tick();
assert!(!c.idle(false));
c.cooldown_tick();
assert!(c.idle(false));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
+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;
}
@@ -1689,6 +1689,9 @@ impl GraphicsUi {
crate::widgets::widget_cursor(hovered(rect), true);
crate::widgets::target(rect);
let primary = target == Target::Done;
if primary {
crate::widgets::declare_action(crate::widgets::Action::Done, rect, true);
}
style::button(batch, atlas, rect, &label, (hover, true, primary));
}
}
@@ -207,6 +207,16 @@ impl Calibration {
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
let action = if rect == l.cancel {
Some(crate::widgets::Action::Cancel)
} else if rect == l.back {
Some(crate::widgets::Action::Back)
} else {
None
};
if let Some(action) = action {
crate::widgets::declare_action(action, [rect.0, rect.1, rect.2, 20.0], active);
}
ui::button(
batch,
atlas,
+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;
+49 -4
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))
@@ -3603,9 +3624,10 @@ impl OptionsUi {
true,
);
}
ui::button(
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
w / 2.0 - 100.0,
h - 28.0,
200.0,
@@ -3759,9 +3781,24 @@ impl OptionsUi {
tooltip_rect = (!pointer_over).then_some([wd.x, wd.y, wd.w, wd.h]);
}
match &wd.kind {
WKind::Button { label, active, .. } => {
WKind::Button { label, active, act } => {
let label = fitted.as_ref().map_or(label, |f| &f.0);
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
if matches!(act, Act::Done) {
ui::action_button(
batch,
atlas,
crate::widgets::Action::Done,
wd.x,
wd.y,
wd.w,
wd.h,
label,
*active,
hover,
);
} else {
ui::button(batch, atlas, wd.x, wd.y, wd.w, wd.h, label, *active, hover);
}
crate::widgets::last_narratable(|n| {
narrate_widget(n, s, wd, hover, list_key, framebuffer);
});
@@ -4299,6 +4336,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 +5281,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) {
@@ -1161,6 +1161,11 @@ impl PackScreen {
d::fill(out, 0, 0, self.width, TOPBAR_H, SIDEBAR_BG);
let (bx, by) = self.back_button();
let back_hover = d::inside(mouse, bx, by, BACK_W, BACK_H);
crate::widgets::declare_action(
crate::widgets::Action::Back,
[bx as f32, by as f32, BACK_W as f32, BACK_H as f32],
true,
);
d::button(
out,
m,
@@ -1377,6 +1382,13 @@ impl PackScreen {
let hover = d::inside(mouse, bx, by, 150, 20);
// The cursor follows the mouse; the highlight also keyboard focus.
crate::widgets::widget_cursor(hover, true);
if i == 1 && self.browser.is_none() {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[bx as f32, by as f32, 150.0, 20.0],
true,
);
}
d::button(
out,
m,
+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);
}
}
@@ -351,6 +351,14 @@ impl SplitProfiles {
if matches!(target, Target::Forget) {
text = text.with_color(0xFF5555);
}
let action = match target {
Target::Keep | Target::Cancel => Some(crate::widgets::Action::Cancel),
Target::Back => Some(crate::widgets::Action::Back),
_ => None,
};
if let Some(action) = action {
crate::widgets::declare_action(action, [*x, *y, *bw, *bh], *enabled);
}
ui::button(batch, atlas, *x, *y, *bw, *bh, &text, *enabled, hover);
if remove {
crate::widgets::record(label, [*x, *y, *bw, *bh], *enabled);
@@ -691,6 +691,16 @@ impl SplitUi {
Target::Three => Some(layout_glyph(true, d.three)),
_ => None,
});
let action = match target {
Target::Apply => Some(crate::widgets::Action::Apply),
Target::Done => Some(crate::widgets::Action::Done),
_ => None,
};
if let Some(action) = action {
// Off during a hold: every button goes to the held player.
let enabled = *active && self.held.is_none();
crate::widgets::declare_action(action, [x, y, bw, bh], enabled);
}
// The selected player's name in yellow: picked, not just under the pointer.
let text = if glyph.is_some() {
Text::literal("")
@@ -1499,6 +1509,30 @@ mod tests {
assert!(ui.give_up_pad);
}
#[test]
fn done_is_declared_off_while_a_player_is_held() {
use crate::widgets::{Action, end_frame, last_actions, set_action_screen};
let atlas =
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
let mut ui = screen(2);
ui.size = SIZE;
let done = |ui: &SplitUi, frame: u64| {
let mut batch = HudBatch::new([427, 300]);
ui.draw(&mut batch, &atlas, (0.0, 0.0), false);
set_action_screen(9);
end_frame(frame);
last_actions(9)
.into_iter()
.find(|a| a.action == Action::Done)
.map(|a| a.enabled)
};
assert_eq!(done(&ui, 9001), Some(true));
assert!(ui.pad_pick_up(centre(&ui, "P2")));
assert_eq!(done(&ui, 9002), Some(false));
assert!(ui.pad_button(PadButton::East));
assert_eq!(done(&ui, 9003), Some(true));
}
#[test]
fn b_puts_a_held_player_back() {
let mut ui = screen(2);
+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 = [
+49 -7
View File
@@ -502,6 +502,10 @@ pub struct Streaming {
pub radius: i32,
/// `/forceload`ed chunks: kept loaded whatever the distance.
pub forced: HashSet<(i32, i32)>,
/// Chunks a native mod's tickets hold (NeoForge's `TicketController`):
/// kept loaded and ticking as forced ones are, but neither saved with
/// them nor listed by `/forceload`.
pub mod_forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// Dimension flags: sky light, Nether face shading.
@@ -569,6 +573,7 @@ struct GenerationScan {
radius: i32,
extra: Vec<((i32, i32), i32)>,
forced: HashSet<(i32, i32)>,
mod_forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
@@ -584,6 +589,7 @@ impl GenerationScan {
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.mod_forced == *ranges.mod_forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
@@ -622,6 +628,7 @@ impl GenerationScan {
self.extra.clear();
self.extra.extend_from_slice(ranges.extra);
self.forced.clone_from(ranges.forced);
self.mod_forced.clone_from(ranges.mod_forced);
self.band = ranges.band.copied();
self.chunk_revision = world.chunks.membership_revision();
}
@@ -719,7 +726,7 @@ impl GenerationScan {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -reach..=reach {
for dx in -reach..=reach {
let (x, z) = (fx + dx, fz + dz);
@@ -739,6 +746,7 @@ impl GenerationScan {
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
mod_forced: input.ranges.mod_forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
@@ -1063,6 +1071,7 @@ impl Streaming {
center: (0, 0),
radius: 8,
forced: HashSet::new(),
mod_forced: HashSet::new(),
paused: false,
sky_light: true,
nether_shading: false,
@@ -1737,7 +1746,7 @@ impl Streaming {
> r4 * r4)
&& !ranges.in_extra(x, z, 4)
&& !ranges.in_band(x, z, 4)
&& !ranges.forced.iter().any(|f| {
&& !ranges.forced_chunks().any(|f| {
(x - f.0).abs() <= FORCED_TERRAIN_RING
&& (z - f.1).abs() <= FORCED_TERRAIN_RING
})
@@ -1808,6 +1817,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -1974,7 +1984,11 @@ impl Streaming {
// vanilla draws). Sections set aside past it come back
// when the player moves: a column that loads with its light gets no
// other request (#228).
let area = (self.center, self.radius, self.forced.len());
let area = (
self.center,
self.radius,
self.forced.len() + self.mod_forced.len(),
);
if self.waiting_area != area {
self.waiting_area = area;
let ranges = Ranges {
@@ -1982,6 +1996,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
};
@@ -2077,6 +2092,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
},
@@ -2210,6 +2226,7 @@ impl Streaming {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &self.mod_forced,
cover: ExtraCover::matching(&self.cover, &self.extra),
band: self.band.as_ref(),
}
@@ -2486,6 +2503,8 @@ struct Ranges<'a> {
radius: i32,
extra: &'a [((i32, i32), i32)],
forced: &'a HashSet<(i32, i32)>,
/// The chunks native mods' tickets hold, kept as forced ones are.
mod_forced: &'a HashSet<(i32, i32)>,
/// `extra` indexed by a grid or buckets: coverage queries without a
/// scan of every area.
cover: Option<&'a ExtraCover>,
@@ -2830,10 +2849,20 @@ impl Ranges<'_> {
.iter()
.any(|&(c, radius)| (x - c.0).abs().max((z - c.1).abs()) <= radius + margin)
}
/// Whether a `/forceload` or a native mod's ticket holds the chunk.
fn is_forced(&self, x: i32, z: i32) -> bool {
self.forced.contains(&(x, z)) || self.mod_forced.contains(&(x, z))
}
/// Every chunk a `/forceload` or a native mod's ticket holds, once each.
fn forced_chunks(&self) -> impl Iterator<Item = &(i32, i32)> + '_ {
self.forced
.iter()
.chain(self.mod_forced.iter().filter(|c| !self.forced.contains(c)))
}
/// The columns meshed for drawing: those vanilla draws around the
/// player (`in_view_distance`), and forced ones.
fn in_view(&self, x: i32, z: i32) -> bool {
in_view_distance(self.center, self.radius, x, z) || self.forced.contains(&(x, z))
in_view_distance(self.center, self.radius, x, z) || self.is_forced(x, z)
}
fn in_main_range(&self, x: i32, z: i32, margin: i32) -> bool {
let r = i64::from(self.radius + margin);
@@ -2851,8 +2880,7 @@ impl Ranges<'_> {
3 => FORCED_TERRAIN_RING,
_ => margin + 1,
};
self.forced
.iter()
self.forced_chunks()
.any(|f| (x - f.0).abs() <= reach && (z - f.1).abs() <= reach)
}
}
@@ -3075,6 +3103,10 @@ fn forget_pending_sections(
}
}
/// No chunk held by a native mod's ticket, for the tests' ranges.
#[cfg(test)]
static NO_CHUNKS: std::sync::LazyLock<HashSet<(i32, i32)>> = std::sync::LazyLock::new(HashSet::new);
#[cfg(test)]
mod tests {
use super::*;
@@ -3291,6 +3323,7 @@ mod tests {
radius: 32,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3561,6 +3594,7 @@ mod tests {
radius: self.radius,
extra: &self.extra,
forced: &self.forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
},
@@ -3637,7 +3671,7 @@ mod tests {
} else {
FORCED_FEATURES_RING
};
for &(fx, fz) in input.ranges.forced {
for &(fx, fz) in input.ranges.forced_chunks() {
for dz in -forced_reach..=forced_reach {
for dx in -forced_reach..=forced_reach {
let (x, z) = (fx + dx, fz + dz);
@@ -3943,6 +3977,7 @@ mod tests {
radius,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -3999,6 +4034,7 @@ mod tests {
radius,
extra: &[((radius * 2 + 9, 3), 4)],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4035,6 +4071,7 @@ mod tests {
radius: -1,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4068,6 +4105,7 @@ mod tests {
radius: 2,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4122,6 +4160,7 @@ mod tests {
radius: 2,
extra: &[],
forced: &forced,
mod_forced: &NO_CHUNKS,
cover: None,
band: None,
};
@@ -4193,6 +4232,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4235,6 +4275,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -4300,6 +4341,7 @@ mod cover_tests {
radius: 10,
extra: &extra,
forced: &forced,
mod_forced: &NO_CHUNKS,
cover,
band: None,
};
@@ -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);
+85 -2
View File
@@ -543,6 +543,38 @@ pub fn button(
crate::widgets::Narratable::button(label.plain(), rect, active)
});
}
/// A [`button`] with a job on its screen: its controller button and glyph come from `action`, so
/// a new screen gets them by drawing this for its Done, Back, Cancel or Apply.
#[allow(clippy::too_many_arguments)]
pub fn action_button(
batch: &mut HudBatch,
atlas: &HudAtlas,
action: crate::widgets::Action,
x: f32,
y: f32,
w: f32,
h: f32,
label: &Text,
active: bool,
hover: bool,
) {
crate::widgets::declare_action(action, [x, y, w, h], active);
let room = if active {
crate::widgets::action_glyph_room()
} else {
0.0
};
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
room,
|rect| crate::widgets::Narratable::button(label.plain(), rect, active),
);
}
/// A [`button`] that vanilla narrates as another widget (a `CycleButton`, a
/// `LockIconButton`): `narration` builds what it records from its rectangle,
/// and only while the narrator records.
@@ -558,6 +590,29 @@ pub fn button_as(
active: bool,
hover: bool,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
button_with_room(
batch,
atlas,
[x, y, w, h],
label,
active,
hover,
0.0,
narration,
);
}
/// A button whose label keeps `room` free at its left.
#[allow(clippy::too_many_arguments)]
fn button_with_room(
batch: &mut HudBatch,
atlas: &HudAtlas,
[x, y, w, h]: [f32; 4],
label: &Text,
active: bool,
hover: bool,
room: f32,
narration: impl FnOnce([f32; 4]) -> crate::widgets::Narratable,
) {
crate::widgets::record(&label.plain(), [x, y, w, h], active);
crate::widgets::narratable(|| narration([x, y, w, h]).hovered(hover));
@@ -575,15 +630,18 @@ pub fn button_as(
} else {
rgba(160, 160, 160, 255)
};
scrolling_text(
// Centred on the button, as the title's are, and only moved out from under a glyph.
scrolling_text_around_at(
batch,
atlas,
label,
x + 2.0,
x + (w / 2.0).floor(),
x + 2.0 + room,
x + w - 2.0,
y,
y + h,
y + ((h - 8.0) / 2.0).floor(),
crate::hud_overlays::now_ms(),
color,
);
}
@@ -1740,6 +1798,31 @@ mod tests {
/// The label of a widget it does not fit is clipped to the widget's box
/// instead of being drawn past it (the bug: `so_so`'s 590 px Create World
/// label ran over the button beside it).
#[test]
fn a_glyph_leaves_a_label_that_fits_centred() {
let atlas = atlas();
crate::widgets::set_action_glyphs(true);
let label = Text::literal("Done");
let mut plain = HudBatch::new([640, 480]);
button(
&mut plain, &atlas, 10.0, 20.0, 150.0, 20.0, &label, true, false,
);
let mut declared = HudBatch::new([640, 480]);
action_button(
&mut declared,
&atlas,
crate::widgets::Action::Done,
10.0,
20.0,
150.0,
20.0,
&label,
true,
false,
);
assert_eq!(quads(&plain), quads(&declared));
}
#[test]
fn a_label_wider_than_its_widget_stays_inside_it() {
let atlas = atlas();
+114 -2
View File
@@ -96,6 +96,12 @@ thread_local! {
/// on-screen keyboard's keys), labelled or not: where a controller can move focus. Drawn since
/// `end_frame`, the last frame's, and how many frames rotated.
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
/// The buttons drawn since the last `end_frame` that declared their job, and the last frame's.
static ACTIONS: RefCell<(Vec<DeclaredAction>, Vec<DeclaredAction>)> = const { RefCell::new((Vec::new(), Vec::new())) };
/// The pad drives, so declared buttons show their glyph (labels leave it room).
static ACTION_GLYPHS: Cell<bool> = const { Cell::new(false) };
/// The screen the buttons in `ACTIONS` were declared on, the same two frames.
static ACTION_SCREENS: Cell<(u64, u64)> = const { Cell::new((0, 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
@@ -104,17 +110,94 @@ thread_local! {
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
(Vec<DeclaredAction>, Vec<DeclaredAction>),
bool,
(u64, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
Saved(
WIDGETS.take(),
TARGETS.take(),
ACTIONS.take(),
ACTION_GLYPHS.take(),
ACTION_SCREENS.take(),
)
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
ACTIONS.set(saved.2);
ACTION_GLYPHS.set(saved.3);
ACTION_SCREENS.set(saved.4);
}
/// A button's job on its screen. A screen declares it ([`crate::ui::action_button`]) and the
/// controller's button, its glyph and its place on every pad follow (docs/controllers.md,
/// "Adding a screen").
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Action {
/// Leaves the screen, keeping what was set.
Done,
/// Goes back one screen.
Back,
/// Leaves the screen, dropping what was set.
Cancel,
/// Applies what was set and stays or leaves as the screen does.
Apply,
/// Opens the options.
Options,
/// Leaves the game or the world: the pad moves the focus here, and A confirms.
Exit,
}
/// A button that declared its job, as last drawn.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct DeclaredAction {
pub action: Action,
pub rect: [f32; 4],
pub enabled: bool,
}
/// `rect` is the button doing `action` on this screen; see [`crate::ui::action_button`], for a
/// screen that draws its buttons itself.
pub fn declare_action(action: Action, rect: [f32; 4], enabled: bool) {
ACTIONS.with_borrow_mut(|(current, _)| {
if current.len() < 16 {
current.push(DeclaredAction {
action,
rect,
enabled,
});
}
});
}
/// The buttons of the last drawn frame that declared their job, if it drew `screen`: until the
/// screen just opened is drawn, the one it replaced can't be pressed.
pub fn last_actions(screen: u64) -> Vec<DeclaredAction> {
if ACTION_SCREENS.get().1 != screen {
return Vec::new();
}
ACTIONS.with_borrow(|(_, last)| last.clone())
}
/// The frame being drawn shows `screen` (the controller's own key for it).
pub fn set_action_screen(screen: u64) {
ACTION_SCREENS.set((screen, ACTION_SCREENS.get().1));
}
/// Whether declared buttons are showing their glyph: only while the pad drives.
pub fn set_action_glyphs(on: bool) {
ACTION_GLYPHS.set(on);
}
/// How much of a declared button's left the glyph takes from its label.
pub fn action_glyph_room() -> f32 {
if ACTION_GLYPHS.get() { 14.0 } else { 0.0 }
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
@@ -225,6 +308,10 @@ pub fn end_frame(frames: u64) {
*last = std::mem::take(current);
*generation += 1;
});
ACTIONS.with_borrow_mut(|(current, last)| {
*last = std::mem::take(current);
});
ACTION_SCREENS.set((ACTION_SCREENS.get().0, ACTION_SCREENS.get().0));
NARRATION.with_borrow_mut(|(current, last)| {
*last = std::mem::replace(current, Narration::EMPTY);
});
@@ -700,8 +787,10 @@ pub fn narrating() -> bool {
/// The screen being drawn: what it narrates and which one it is. The
/// narratables recorded before it in the frame are dropped: the last screen
/// drawn (an overlay over the screen under it) is the one narrated.
/// drawn (an overlay over the screen under it) is the one narrated. So are the
/// jobs declared before it: a popup's buried buttons can't be pressed.
pub fn begin_screen(info: impl FnOnce() -> ScreenInfo) {
ACTIONS.with_borrow_mut(|(current, _)| current.clear());
if NARRATING.get() {
NARRATION.with_borrow_mut(|(current, _)| {
current.screen = Some(info());
@@ -1153,6 +1242,29 @@ mod tests {
assert_eq!(slug("Économie"), "économie");
}
#[test]
fn declared_buttons_are_only_those_of_the_screen_drawn() {
let done = |x| declare_action(Action::Done, [x, 0.0, 5.0, 5.0], true);
done(0.0);
set_action_screen(7);
end_frame(200);
assert_eq!(last_actions(7).len(), 1);
// Another screen opened and hasn't been drawn yet.
assert!(last_actions(8).is_empty());
}
#[test]
fn a_popup_drops_the_buttons_declared_under_it() {
declare_action(Action::Done, [0.0, 0.0, 5.0, 5.0], true);
begin_screen(|| ScreenInfo::new("popup", ""));
declare_action(Action::Cancel, [9.0, 0.0, 5.0, 5.0], true);
set_action_screen(7);
end_frame(300);
let actions = last_actions(7);
assert_eq!(actions.len(), 1);
assert_eq!(actions[0].action, Action::Cancel);
}
#[test]
fn last_lists_buttons_only() {
end_frame(100);
+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"]))?;
}
@@ -23,6 +23,9 @@ impl World for Empty {
fn dimension(&self) -> &str {
"minecraft:overworld"
}
fn weather(&self) -> (bool, bool) {
(false, false)
}
fn biome(&self, _: [i32; 3]) -> String {
"minecraft:plains".into()
}
@@ -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);
File diff suppressed because it is too large. Load diff
+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;
@@ -67,6 +67,11 @@ impl Confirm {
) {
self.draw_active(batch, atlas, cursor, labels, true);
}
/// The second button answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self) {
let (x, y) = self.buttons[1];
crate::widgets::declare_action(crate::widgets::Action::Cancel, [x, y, 150.0, 20.0], true);
}
/// `draw` with the buttons greyed out while `setDelay` runs (`active` false).
pub fn draw_active(
&self,
@@ -183,6 +188,16 @@ impl Choice {
.iter()
.position(|&(x, y)| ui::hovered(cursor, x, y, self.width, 20.0))
}
/// Button `i` answers no, as Escape does (`widgets::Action`).
pub fn declare_no(&self, i: usize) {
if let Some(&(x, y)) = self.buttons.get(i) {
crate::widgets::declare_action(
crate::widgets::Action::Cancel,
[x, y, self.width, 20.0],
true,
);
}
}
/// The lines and the buttons, over whatever is behind.
pub fn draw(
&self,
+55 -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()),
@@ -639,6 +642,19 @@ impl Dialog {
| Kind::DialogList { exit, .. } => exit.as_ref().and_then(|b| b.action.as_ref()),
}
}
/// Which of [`Self::buttons`] does what Escape does, if Escape does anything.
pub fn escape_button(&self) -> Option<usize> {
if !self.can_close_with_escape {
return None;
}
let last = self.buttons().len().checked_sub(1);
match &self.kind {
Kind::Notice(_) | Kind::Confirmation { .. } => last,
Kind::MultiAction { exit, .. }
| Kind::ServerLinks { exit, .. }
| Kind::DialogList { exit, .. } => exit.as_ref().and(last),
}
}
/// The buttons it draws, in order: the list, then the footer's.
pub fn buttons(&self) -> Vec<&Button> {
match &self.kind {
@@ -829,6 +845,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();
@@ -857,6 +895,22 @@ mod tests {
assert_eq!(command, Some(Click::RunCommand("say p\\\"w !".into())));
}
#[test]
fn the_button_that_does_what_escape_does() {
let notice = |escape: i8| {
Dialog::parse(&Tag::compound([
("type", s("notice")),
("title", s("Hi")),
("can_close_with_escape", Tag::Byte(escape)),
]))
.unwrap()
};
assert_eq!(notice(1).escape_button(), Some(0));
assert_eq!(notice(0).escape_button(), None);
// Multi-action with no exit button: Escape has no button to be.
assert_eq!(Dialog::parse(&register()).unwrap().escape_button(), None);
}
#[test]
fn a_notice_without_an_action_has_ok() {
let d = Dialog::parse(&Tag::compound([("type", s("notice")), ("title", s("Hi"))])).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()
);
}
}
}
}
}
@@ -244,6 +244,13 @@ pub fn draw(
}
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
crate::widgets::declare_action(
crate::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
}
ui::button(
batch,
atlas,
+27
View File
@@ -30,6 +30,7 @@ pub trait FurnaceCooking {
providers: &Providers,
drops: &mut Vec<ItemStack>,
) -> Option<bool>;
fn idle(&self) -> bool;
}
impl FurnaceCooking for Furnace {
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
@@ -171,6 +172,11 @@ impl FurnaceCooking for Furnace {
}
(was_lit != lit).then_some(lit)
}
fn idle(&self) -> bool {
self.lit_remaining <= 0
&& self.cooking_timer <= 0
&& !(self.items[0].is_some() && self.items[1].is_some())
}
}
fn total_time(time: u32, speed: f32) -> i32 {
if speed > 0.0 {
@@ -187,6 +193,27 @@ mod tests {
use horizon_world::loot::PackOverlay;
use std::{collections::HashMap, fs};
#[test]
fn a_furnace_is_idle_only_while_its_tick_changes_nothing() {
let stack = |id: &str| Some(ItemStack::new(id, 1));
let furnace = |input, fuel, lit_remaining, cooking_timer| Furnace {
kind: FurnaceKind::Furnace,
items: [input, fuel, stack("minecraft:iron_ingot")],
lit_remaining,
lit_total: 0,
cooking_timer,
cooking_total: 200,
speed: 1.0,
recipes_used: HashMap::new(),
};
assert!(furnace(None, None, 0, 0).idle());
assert!(furnace(stack("minecraft:iron_ore"), None, 0, 0).idle());
assert!(furnace(None, stack("minecraft:coal"), 0, 0).idle());
assert!(!furnace(stack("minecraft:iron_ore"), stack("minecraft:coal"), 0, 0).idle());
assert!(!furnace(None, None, 1, 0).idle());
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
}
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
/// data root holding that one recipe and an item table with two fuels: `test:coal`
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
+250 -81
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;
@@ -204,7 +205,9 @@ mod narrator;
mod native_biome_colors;
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;
@@ -216,6 +219,7 @@ mod native_entity_refs;
mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_host_bridge;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
@@ -223,6 +227,8 @@ mod native_item_fluids;
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;
@@ -235,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;
@@ -252,6 +259,7 @@ mod options;
mod ordered_tick;
mod packs;
mod pad;
mod pad_actions;
mod pad_menu;
mod paintings;
mod pandas;
@@ -301,6 +309,7 @@ mod server_links;
mod server_packs;
mod server_travel;
mod shader_packs;
mod sheep_dye;
mod shelf;
mod shield;
mod shoulders;
@@ -352,6 +361,7 @@ mod win;
mod wind;
mod wither;
mod world_loading;
mod world_primitives;
mod zombie_reinforcements;
mod zombie_villagers;
use event_loop::EventLoopCtl;
@@ -416,19 +426,23 @@ 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};
pub use native_components::NativeComponentError;
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, 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;
pub use native_server::NativeExperienceOrb;
pub use native_world_columns::NativeColumnBlocks;
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
pub use native_world_read::NativeBlockReadError;
use std::{
cell::RefCell,
collections::{HashMap, HashSet},
@@ -991,6 +1005,12 @@ struct GameLevel<'w> {
debug: bool,
native_sounds: &'w mut Vec<creatures::NativeSound>,
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
/// `Block.dropResources` rolls the loot with it instead of with none.
drop_tool: Option<ItemStack>,
/// Where `Block.dropResources` puts the drops instead of popping them,
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
drop_into: Option<Vec<ItemStack>>,
}
struct PlayerCollision {
y: f64,
@@ -1815,6 +1835,14 @@ impl LevelAccess for GameLevel<'_> {
Some(BlockEntity::Mod(_))
)
}
/// `ShulkerBoxBlockEntity.isClosed`: the lid state the block-entity tick
/// copied from `be_anims` (`ShulkerBoxBlock.getBlockSupportShape`).
fn shulker_is_closed(&self, pos: BlockPos) -> Option<bool> {
self.world
.shulkers
.get(&[pos.x, pos.y, pos.z])
.map(|s| s.status == horizon_client::block_entity_anim::ShulkerStatus::Closed)
}
fn remove_block_entity(&mut self, pos: BlockPos, old: BlockStateId, side_effects: bool) -> u8 {
self.remove_entity(pos, old, side_effects)
}
@@ -2351,6 +2379,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).
@@ -2871,6 +2902,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,
@@ -2919,6 +2953,9 @@ struct Game<'a> {
mod_screen: Option<native_hooks::OpenNativeScreen>,
/// The players with each mod container open, by level (`tick_mod_container_viewers`).
mod_container_viewers: HashMap<(&'static str, (i32, i32, i32)), u32>,
/// The owners of each chunk a native mod's ticket holds, by level
/// (`native_force_chunk`); `streaming.mod_forced` is the level's chunks.
mod_chunk_tickets: HashMap<Dim, HashMap<(i32, i32), std::collections::BTreeSet<String>>>,
/// Where item entities are, while the block entities tick (hoppers).
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
@@ -3053,6 +3090,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.
@@ -4333,9 +4371,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,
@@ -4674,6 +4710,8 @@ impl Game<'_> {
debug: self.world_debug,
native_sounds: &mut self.creatures.lan_sounds,
native_particles: &mut self.creatures.lan_particles,
drop_tool: None,
drop_into: None,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -4865,7 +4903,20 @@ impl Game<'_> {
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
.map(|(chunk, _)| chunk)
.collect(),
forced: self.streaming.forced.iter().copied().collect(),
// A native mod's ticket holds its chunk as `/forceload` does
// (NeoForge's `TicketController` uses the same `FORCED` level).
forced: self
.streaming
.forced
.iter()
.chain(
self.streaming
.mod_forced
.iter()
.filter(|c| !self.streaming.forced.contains(c)),
)
.copied()
.collect(),
// Filled from the `PORTAL` tickets by `update`.
portal: Vec::new(),
// EnderDragonFight.tick: while a player watches the fight, the
@@ -5140,15 +5191,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.
@@ -7034,12 +7089,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,
@@ -7852,6 +7902,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())?;
@@ -8555,11 +8606,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)
@@ -8711,7 +8762,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| {
@@ -11608,6 +11662,35 @@ impl Game<'_> {
}
}
/// `ServerLevel.destroyBlockProgress`: breaker `breaker`'s crack on `pos`
/// (0..=9, anything else takes it away), for every player within 64 blocks.
pub(super) fn server_destroy_block_progress(
&mut self,
breaker: i32,
pos: [i32; 3],
progress: i32,
) {
self.destroy_block_progress(breaker, pos, progress);
if let Some(host) = self.lan.as_ref() {
host.hub.broadcast_near(
[f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])],
64.0,
&horizon_protocol::packets::play::Clientbound::BlockDestruction(
horizon_protocol::packets::play::BlockDestruction {
id: breaker,
pos,
progress: if (0..10).contains(&progress) {
progress as u8
} else {
255
},
},
),
None,
);
}
}
/// The cracks to draw and their stage sprite's atlas origin: our own mining and the
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
@@ -11626,6 +11709,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),
@@ -11651,7 +11753,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]))
})
@@ -12207,7 +12309,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,
@@ -12392,16 +12494,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(
@@ -12690,6 +12795,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() {
@@ -12897,15 +13006,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;
@@ -13549,9 +13656,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);
}
@@ -14343,8 +14455,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 {
(
@@ -14566,7 +14683,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();
}
@@ -15894,8 +16011,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") {
@@ -15930,6 +16047,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();
@@ -16009,8 +16131,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.
@@ -16029,8 +16149,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 {
@@ -16041,7 +16159,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 =
@@ -16072,6 +16189,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()
@@ -16153,6 +16278,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 {
@@ -16240,6 +16366,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,
@@ -16278,6 +16405,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
@@ -16441,7 +16569,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(
@@ -18876,6 +19006,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() {
@@ -18984,7 +19122,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(),
@@ -19003,6 +19141,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(),
@@ -19313,6 +19453,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,
@@ -19339,6 +19480,7 @@ impl<'a> Game<'a> {
hoppers: HashMap::new(),
mod_screen: None,
mod_container_viewers: HashMap::new(),
mod_chunk_tickets: HashMap::new(),
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
@@ -19413,6 +19555,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,
@@ -19484,7 +19627,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)
}
}
@@ -19825,7 +19972,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();
@@ -19834,31 +19985,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);
@@ -19875,24 +20022,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`).
@@ -19900,7 +20044,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.
@@ -19923,7 +20077,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(
@@ -20955,6 +21115,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: &[],
@@ -20977,6 +21138,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
world_primitives: Default::default(),
shadow_only: &[],
celestial: &[],
stars: None,
@@ -21109,6 +21271,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 {
@@ -21408,6 +21576,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();
+222 -77
View File
@@ -137,6 +137,11 @@ impl adv::World for View<'_, '_> {
fn dimension(&self) -> &str {
self.0.dim.id()
}
/// `WeatherCheck.test`'s level queries, using dimension-aware effective levels.
fn weather(&self) -> (bool, bool) {
let (rain, thunder) = self.0.weather_levels(1.0);
(f64::from(rain) > 0.2, f64::from(thunder) > 0.9)
}
fn biome(&self, p: [i32; 3]) -> String {
self.0
.world
@@ -200,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();
@@ -219,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,
}
}
@@ -439,17 +436,101 @@ impl Game<'_> {
&View(self),
)
}
/// The player as a command's predicates see it: with what it looks at.
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
/// each tick otherwise).
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`.
fn player_entity_looking(&self, looking: bool) -> EntityView {
pub(super) fn player_entity_looking(&self, looking: bool) -> EntityView {
let Some(p) = self.player.as_ref() else {
return EntityView::default();
};
@@ -497,7 +578,7 @@ impl Game<'_> {
.collect(),
gamemode: Some(self.mode.name().to_owned()),
// Vanilla looks for it inside PlayerPredicate.matches whatever the
// trigger; skipped for a tick or location whose criteria can't ask.
// trigger; skipped for a trigger whose criteria can't ask.
looking_at: looking
.then(|| self.looking_at_entity().map(Box::new))
.flatten(),
@@ -559,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],
@@ -570,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()
}
}
@@ -891,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| {
@@ -911,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.
@@ -969,7 +1079,7 @@ impl Game<'_> {
}
/// A player condition plus one entity (`player`, `entity` keys).
pub(super) fn trigger_entity(&mut self, name: &str, key: &str, target: &EntityView) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let target = target.clone();
let key = key.to_owned();
self.trigger(name, move |c, w| {
@@ -1002,27 +1112,11 @@ impl Game<'_> {
target: &EntityView,
blow: Option<&adv::DamageView>,
) {
let me = self.player_entity();
let me = self.player_entity_for(name);
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.
@@ -1031,7 +1125,7 @@ impl Game<'_> {
victims: &[EntityView],
weapon: Option<&ItemStack>,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:killed_by_arrow");
let victims = victims.to_vec();
let weapon = weapon.map(item_view);
self.trigger("minecraft:killed_by_arrow", move |c, w| {
@@ -1041,7 +1135,7 @@ impl Game<'_> {
/// `LevitationTrigger`: how far the player rose from where it started
/// levitating, and for how long.
fn trigger_levitation(&mut self, start: [f64; 3], duration: i64) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:levitation");
self.trigger("minecraft:levitation", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1132,7 +1226,7 @@ impl Game<'_> {
/// `DistanceTrigger`: the player's `player` condition, `start_position`
/// at `start`, and the `distance` from `start` to the player.
fn trigger_distance(&mut self, name: &str, start: [f64; 3]) {
let me = self.player_entity();
let me = self.player_entity_for(name);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1150,7 +1244,7 @@ impl Game<'_> {
/// player is now. `matches` tests `cause` only when the instance has one,
/// and a missing cause fails it.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3], cause: Option<EntityView>) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:fall_after_explosion");
self.trigger("minecraft:fall_after_explosion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1176,7 +1270,7 @@ impl Game<'_> {
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
/// `source` gave, that entity (`source`).
fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:effects_changed");
self.trigger("minecraft:effects_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1213,7 +1307,7 @@ impl Game<'_> {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let me = self.player_entity_for("minecraft:item_durability_changed");
let item = item_view(stack);
let max = self
.res
@@ -1241,7 +1335,7 @@ impl Game<'_> {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let me = self.player_entity_for("minecraft:using_item");
let it = item_view(stack);
self.trigger("minecraft:using_item", move |c, w| {
let pctx = adv::Entities {
@@ -1314,7 +1408,7 @@ impl Game<'_> {
victim: &EntityView,
damage: &adv::DamageView,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_hurt_entity");
let (victim, damage) = (victim.clone(), damage.clone());
self.trigger("minecraft:player_hurt_entity", move |c, w| {
let pctx = adv::Entities {
@@ -1342,7 +1436,7 @@ impl Game<'_> {
direct: Option<EntityView>,
amount: f32,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_hurt_entity");
self.trigger_player_hurt_entity(
victim,
&adv::DamageView {
@@ -1358,7 +1452,7 @@ impl Game<'_> {
}
/// `EntityHurtPlayerTrigger`: the player and the damage it took.
pub(super) fn trigger_entity_hurt_player(&mut self, damage: &adv::DamageView) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:entity_hurt_player");
let damage = damage.clone();
self.trigger("minecraft:entity_hurt_player", move |c, w| {
let pctx = adv::Entities {
@@ -1373,7 +1467,7 @@ impl Game<'_> {
}
/// A player condition plus one item (`item` key).
pub(super) fn trigger_item(&mut self, name: &str, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
@@ -1391,7 +1485,7 @@ impl Game<'_> {
stack: &ItemStack,
target: &EntityView,
) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, |c, w| adv::item_entity(c, &me, target, &it, w));
}
@@ -1407,7 +1501,11 @@ impl Game<'_> {
thrower: [i32; 4],
) {
let target = if thrower == self.acting_uuid() {
let me = self.player_entity();
// The player is the entity of both triggers.
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) {
@@ -1422,7 +1520,7 @@ impl Game<'_> {
}
/// `ConstructBeaconTrigger`: the pyramid levels of the new beacon.
pub(super) fn trigger_construct_beacon(&mut self, levels: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:construct_beacon");
self.trigger("minecraft:construct_beacon", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1435,7 +1533,7 @@ impl Game<'_> {
}
/// `EnchantedItemTrigger`: the enchanted item and the levels it cost.
pub(super) fn trigger_enchanted_item(&mut self, stack: &ItemStack, levels: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:enchanted_item");
let it = item_view(stack);
self.trigger("minecraft:enchanted_item", move |c, w| {
let pctx = adv::Entities {
@@ -1452,7 +1550,7 @@ impl Game<'_> {
/// `BrewedPotionTrigger`: the potion contents of a stack taken from a
/// brewing stand.
pub(super) fn trigger_brewed_potion(&mut self, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:brewed_potion");
let potion = stack.potion().map(str::to_owned);
self.trigger("minecraft:brewed_potion", move |c, w| {
let pctx = adv::Entities {
@@ -1467,7 +1565,7 @@ impl Game<'_> {
}
/// `TradeTrigger`: the villager and the item the trade gave.
pub(super) fn trigger_trade(&mut self, villager: &EntityView, stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:villager_trade");
let villager = villager.clone();
let it = item_view(stack);
self.trigger("minecraft:villager_trade", move |c, w| {
@@ -1494,7 +1592,7 @@ impl Game<'_> {
hooked: &EntityView,
items: &[ItemStack],
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:fishing_rod_hooked");
let rod = item_view(rod);
let hooked = hooked.clone();
let items: Vec<ItemView> = items.iter().map(item_view).collect();
@@ -1520,7 +1618,7 @@ impl Game<'_> {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let me = self.player_entity_for("minecraft:enter_block");
self.trigger("minecraft:enter_block", move |c, w| {
let block_ok = adv::id_matches(&c["blocks"], "block", &definition.name, w);
let state_ok = c["state"]
@@ -1539,7 +1637,7 @@ impl Game<'_> {
}
/// `SlideDownBlockTrigger`: match the actual block and its state.
pub(super) fn trigger_honey_slide(&mut self, pos: [i32; 3]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:slide_down_block");
self.trigger("minecraft:slide_down_block", move |c, w| {
let ctx = adv::Entities {
this: Some(&me),
@@ -1554,7 +1652,7 @@ impl Game<'_> {
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:target_hit");
let projectile = projectile.clone();
self.trigger("minecraft:target_hit", move |c, w| {
let player = adv::Entities {
@@ -1575,7 +1673,7 @@ impl Game<'_> {
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = item_view(stack);
self.trigger(name, move |c, w| {
let ctx = adv::Entities {
@@ -1599,7 +1697,7 @@ impl Game<'_> {
self.trigger_block_use("minecraft:any_block_use", pos, Some(stack));
}
fn trigger_block_use(&mut self, name: &str, pos: [i32; 3], stack: Option<&ItemStack>) {
let me = self.player_entity();
let me = self.player_entity_for(name);
let it = stack.map(item_view);
let centre = pos.map(|c| f64::from(c) + 0.5);
self.trigger(name, move |c, w| {
@@ -1622,7 +1720,7 @@ impl Game<'_> {
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:lightning_strike");
let (bolt, viewers) = (bolt.clone(), viewers.to_vec());
self.trigger("minecraft:lightning_strike", move |c, w| {
let pctx = adv::Entities {
@@ -1653,7 +1751,7 @@ impl Game<'_> {
/// `ChanneledLightningTrigger`: each victim condition matched by some
/// mob the bolt hit, from the player who channelled it.
pub(super) fn trigger_channeled_lightning(&mut self, victims: &[EntityView]) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:channeled_lightning");
let victims = victims.to_vec();
self.trigger("minecraft:channeled_lightning", move |c, w| {
let pctx = adv::Entities {
@@ -1679,7 +1777,7 @@ impl Game<'_> {
}
/// `SpearMobsTrigger`: the living entities the spear stabbed in this use.
pub(super) fn trigger_spear_mobs(&mut self, stabbed: usize) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:spear_mobs");
self.trigger("minecraft:spear_mobs", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1702,7 +1800,7 @@ impl Game<'_> {
let Some(definition) = self.pack.assets.registry.state(state).cloned() else {
return;
};
let me = self.player_entity();
let me = self.player_entity_for("minecraft:bee_nest_destroyed");
let tool = tool.map(item_view).unwrap_or_default();
self.trigger("minecraft:bee_nest_destroyed", move |c, w| {
let pctx = adv::Entities {
@@ -1723,9 +1821,21 @@ 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();
let me = self.player_entity_for(name);
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -1743,7 +1853,7 @@ impl Game<'_> {
recipe: &str,
ingredients: &[Option<ItemStack>],
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:recipe_crafted");
let used: Vec<ItemView> = ingredients
.iter()
.map(|s| {
@@ -1772,7 +1882,7 @@ impl Game<'_> {
/// `LootTableTrigger`: a container's loot table unpacked for this player
/// (`RandomizableContainer.unpackLootTable`).
pub(super) fn trigger_loot_table(&mut self, table: &str) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:player_generates_container_loot");
let table = table.to_owned();
self.trigger("minecraft:player_generates_container_loot", move |c, w| {
let pctx = adv::Entities {
@@ -1792,7 +1902,7 @@ impl Game<'_> {
partner: &EntityView,
child: Option<&EntityView>,
) {
let me = self.player_entity();
let me = self.player_entity_for("minecraft:bred_animals");
let (parent, partner, child) = (parent.clone(), partner.clone(), child.cloned());
self.trigger("minecraft:bred_animals", move |c, w| {
let ctx = |e: &EntityView| {
@@ -2229,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).
@@ -518,9 +518,10 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (bx, by) = ((w / 2.0 - 100.0).floor(), h - FOOTER + 6.0);
let hover = ui::hovered(self.cursor, bx, by, 200.0, 20.0);
ui::button(
ui::action_button(
batch,
atlas,
horizon_client::widgets::Action::Done,
bx,
by,
200.0,
+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 {
@@ -22,6 +22,20 @@ pub(super) type Pos = (i32, i32, i32);
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
/// The job of an edit screen's footer button `id`, given the ids of its Done and Cancel.
pub(super) fn footer_action(
id: u8,
done: u8,
cancel: u8,
) -> Option<horizon_client::widgets::Action> {
use horizon_client::widgets::Action;
match id {
_ if id == done => Some(Action::Done),
_ if id == cancel => Some(Action::Cancel),
_ => None,
}
}
/// A widget of an edit screen at GUI pixels.
pub(super) enum Item {
Label {
@@ -48,6 +62,8 @@ pub(super) enum Item {
w: f32,
label: Text,
active: bool,
/// Its job on the screen, for the controller.
action: Option<horizon_client::widgets::Action>,
},
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
/// and cut to what fits `h`.
@@ -271,10 +287,14 @@ impl Game<'_> {
w: bw,
label,
active,
action,
..
} = item
{
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
if let Some(action) = *action {
horizon_client::widgets::declare_action(action, [*x, *y, *bw, 20.0], *active);
}
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
}
}
@@ -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,
@@ -43,7 +43,8 @@ impl Game<'_> {
for slot in self.world.block_entity_tickers().iter().copied() {
let pos = slot.0;
// Ticks before this one can remove it (`TickingBlockEntity.isRemoved`).
if !self.world.block_entities.is_live(slot)
if self.world.block_entities.is_asleep(&pos)
|| !self.world.block_entities.is_live(slot)
|| !ticking(pos.0, pos.2)
|| !self.border.contains(f64::from(pos.0), f64::from(pos.2))
{
@@ -264,6 +265,10 @@ impl Game<'_> {
self.campfire_drop(pos, stack);
}
let Some(lit) = lit else {
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Furnace(f)) if f.idle())
{
self.world.block_entities.sleep(pos);
}
return Ok(());
};
let state = self.world.block(pos.0, pos.1, pos.2);
@@ -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])
}
}
+32 -6
View File
@@ -1278,6 +1278,20 @@ impl Game<'_> {
hover(x, 196.0, bw, 20.0),
);
};
let action_button = |batch: &mut HudBatch, action, x: f32, bw: f32, label: &str| {
ui::action_button(
batch,
atlas,
action,
x,
196.0,
bw,
20.0,
&Text::literal(label),
true,
hover(x, 196.0, bw, 20.0),
);
};
match book {
BookScreen::Edit {
signing: true,
@@ -1381,12 +1395,12 @@ impl Game<'_> {
&tr("book.finalizeButton", "Sign and Close"),
!title.trim().is_empty(),
);
button(
action_button(
batch,
widgets::Action::Cancel,
w / 2.0 + 2.0,
98.0,
&tr("gui.cancel", "Cancel"),
true,
);
}
BookScreen::Edit {
@@ -1464,7 +1478,13 @@ impl Game<'_> {
&tr("book.signButton", "Sign"),
true,
);
button(batch, w / 2.0 + 2.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 + 2.0,
98.0,
&tr("gui.done", "Done"),
);
}
BookScreen::View {
pages,
@@ -1495,7 +1515,13 @@ impl Game<'_> {
// LecternScreen.createMenuControls: Done and Take Book only with
// build permission, else BookViewScreen's wide Done.
if lectern.is_some() && self.may_build() {
button(batch, w / 2.0 - 100.0, 98.0, &tr("gui.done", "Done"), true);
action_button(
batch,
widgets::Action::Done,
w / 2.0 - 100.0,
98.0,
&tr("gui.done", "Done"),
);
button(
batch,
w / 2.0 + 2.0,
@@ -1504,12 +1530,12 @@ impl Game<'_> {
true,
);
} else {
button(
action_button(
batch,
widgets::Action::Done,
(w - 200.0) / 2.0,
200.0,
&tr("gui.done", "Done"),
true,
);
}
// HoveredTextEffects.TOOLTIP_AND_CURSOR: the hovered words'
@@ -91,6 +91,7 @@ impl Game<'_> {
self.world.modified.insert((pos.0 >> 4, pos.2 >> 4));
}
if !lit {
self.sleep_idle_campfire(pos, false);
return Ok(());
}
let Some(BlockEntity::Campfire(c)) = self.world.block_entities.get_mut(&pos) else {
@@ -113,9 +114,17 @@ impl Game<'_> {
Some(state),
);
}
self.sleep_idle_campfire(pos, true);
Ok(())
}
fn sleep_idle_campfire(&mut self, pos: Pos, lit: bool) {
if matches!(self.world.block_entities.get(&pos), Some(BlockEntity::Campfire(c)) if c.idle(lit))
{
self.world.block_entities.sleep(pos);
}
}
/// `CampfireRenderer`: each cooking item lies on the fire, slot `i`
/// turned to `Direction.from2DDataValue((i + facing) % 4)`, laid flat
/// (90° about X), at a corner and three eighths of its size, drawn as a
+193 -26
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
@@ -1163,8 +1239,9 @@ impl Game<'_> {
Text::translate("chat.copy", &[]),
Text::translate("gui.no", &[]),
];
self.link_prompt_layout(url, (w, h))
.draw(batch, atlas, self.cursor, &labels);
let layout = self.link_prompt_layout(url, (w, h));
layout.declare_no(2);
layout.draw(batch, atlas, self.cursor, &labels);
}
/// The prompt's answer: the link opens on yes; either way the chat is back.
pub(super) fn answer_link_prompt(&mut self, yes: bool) {
@@ -1206,9 +1283,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 +1405,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 +1995,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;
@@ -673,6 +673,14 @@ impl Game<'_> {
}
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
let action = match b {
Button::Done => Some(horizon_client::widgets::Action::Done),
Button::Cancel => Some(horizon_client::widgets::Action::Cancel),
_ => None,
};
if let Some(action) = action {
horizon_client::widgets::declare_action(action, [x, y, bw, 20.0], true);
}
ui::button_as(
batch,
atlas,
@@ -50,8 +50,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()
@@ -126,7 +145,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 {
@@ -136,8 +155,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):
@@ -181,7 +215,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() {
@@ -211,6 +245,96 @@ 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| {
let w = g.world.weather;
Ok([
i32::from(w.raining),
i32::from(w.thundering),
w.clear_time,
w.rain_time,
w.thunder_time,
])
})?;
let record = json!({
"cmd": format!("#weather_state {}", op["label"].as_str().unwrap_or("")),
"messages": [],
"results": state.iter().map(|value| json!([true, value])).collect::<Vec<_>>(),
});
out.push_str(&record.to_string());
out.push('\n');
}
"mobs" => {
// Observe authoritative AI state without a control-socket race.
let record = self.harness_owner(|g| Ok(g.control_mobs(128.0)))?;
@@ -219,7 +343,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.
@@ -230,6 +355,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) {
@@ -251,7 +389,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" => {
// 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(())
})?;
}
"use_block" => {
// Exercise the connected owner's normal server packet route.
Loaded 100 of 521 files, more files were not shown because too many files have changed in this diff. Show more