From eb0a6f74e6d5911cc63e637b6dcd14076c28ac0e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:05 +0200 Subject: [PATCH 01/50] Shader packs, clean room 2: macros and #if conditions The integer arithmetic of #if/#elif that a pack's .properties files and, later, its GLSL preprocessor share: defined X and defined(X), decimal, hexadecimal and octal literals, the C operators with C precedence, macros expanded to their values (a macro is not expanded inside its own expansion), and an identifier that is not a macro read as 0. A bad condition says why. The standard macros that need no renderer: MC_VERSION, from the pinned version by the documented rule (26.3 is 260300), and the system's MC_OS_*. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/horizon-shaders/Cargo.toml | 1 + crates/horizon-shaders/src/condition.rs | 265 ++++++++++++++++++++++++ crates/horizon-shaders/src/lib.rs | 5 + crates/horizon-shaders/src/macros.rs | 131 ++++++++++++ 5 files changed, 403 insertions(+) create mode 100644 crates/horizon-shaders/src/condition.rs create mode 100644 crates/horizon-shaders/src/macros.rs diff --git a/Cargo.lock b/Cargo.lock index 6c7279aa7..7036ea5ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1434,6 +1434,7 @@ dependencies = [ name = "horizon-shaders" version = "0.1.0" dependencies = [ + "horizon-core", "zip", ] diff --git a/crates/horizon-shaders/Cargo.toml b/crates/horizon-shaders/Cargo.toml index ed3a24266..750949e83 100644 --- a/crates/horizon-shaders/Cargo.toml +++ b/crates/horizon-shaders/Cargo.toml @@ -7,6 +7,7 @@ rust-version = "1.85" description = "Shader packs (the shaders/ folder format): reading packs, their .properties and .lang files, and later their GLSL" [dependencies] +horizon-core = { path = "../horizon-core" } zip = { version = "2", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/horizon-shaders/src/condition.rs b/crates/horizon-shaders/src/condition.rs new file mode 100644 index 000000000..b911eb985 --- /dev/null +++ b/crates/horizon-shaders/src/condition.rs @@ -0,0 +1,265 @@ +//! The integer arithmetic of `#if` and `#elif` (spec 1.5, GLSL 4.60 §3.3): `defined`, integer +//! literals, macros expanded to their values, and the C operators with C precedence. +//! An identifier that is not a macro counts as 0, as in C: GLSL would reject the line, and a +//! pack would then lose a whole section of its settings over one unknown name. + +use crate::Macros; +use crate::macros::{is_ident, is_ident_start}; + +/// The value of a condition; `Err` says what is wrong with it. +pub fn eval_condition(expr: &str, macros: &Macros) -> Result { + let tokens = tokens(&resolve_defined(expr, macros)?, macros)?; + let mut parser = Parser { + tokens: &tokens, + at: 0, + }; + let value = parser.binary(0)?; + match parser.tokens.get(parser.at) { + None => Ok(value), + Some(t) => Err(format!("unexpected `{}`", t.text())), + } +} + +/// `defined X` and `defined(X)` become `1` or `0` before anything is expanded. +fn resolve_defined(expr: &str, macros: &Macros) -> Result { + let mut out = String::with_capacity(expr.len()); + let mut rest = expr; + while let Some(at) = find_word(rest, "defined") { + out.push_str(&rest[..at]); + let after = rest[at + "defined".len()..].trim_start(); + let (inner, tail) = match after.strip_prefix('(') { + Some(open) => { + let close = open.find(')').ok_or("`defined(` without `)`")?; + (open[..close].trim(), &open[close + 1..]) + } + None => { + let len = after.find(|c: char| !is_ident(c)).unwrap_or(after.len()); + (&after[..len], &after[len..]) + } + }; + if inner.is_empty() || !inner.starts_with(is_ident_start) || !inner.chars().all(is_ident) { + return Err("`defined` needs a macro name".into()); + } + out.push_str(if macros.is_defined(inner) { + " 1 " + } else { + " 0 " + }); + rest = tail; + } + out.push_str(rest); + Ok(out) +} + +/// Where `word` starts in `text` as a whole identifier. +fn find_word(text: &str, word: &str) -> Option { + let mut from = 0; + while let Some(i) = text[from..].find(word).map(|i| i + from) { + let before = text[..i].chars().next_back().is_some_and(is_ident); + let after = text[i + word.len()..].chars().next().is_some_and(is_ident); + if !before && !after { + return Some(i); + } + from = i + word.len(); + } + None +} + +#[derive(Clone, Copy, Debug, PartialEq)] +enum Token { + Num(i64), + Op(&'static str), +} + +impl Token { + fn text(&self) -> String { + match self { + Token::Num(n) => n.to_string(), + Token::Op(op) => (*op).to_owned(), + } + } +} + +const OPS: [&str; 22] = [ + "<<", ">>", "<=", ">=", "==", "!=", "&&", "||", "(", ")", "!", "~", "+", "-", "*", "/", "%", + "<", ">", "&", "^", "|", +]; + +fn tokens(expr: &str, macros: &Macros) -> Result, String> { + let expanded = macros.expand(expr); + let mut out = Vec::new(); + let mut rest = expanded.trim_start(); + while let Some(c) = rest.chars().next() { + if c.is_ascii_digit() { + let len = rest + .find(|c: char| !c.is_ascii_alphanumeric()) + .unwrap_or(rest.len()); + if rest[len..].starts_with('.') { + return Err(format!("`{}`: `#if` takes integers only", &rest[..len + 1])); + } + out.push(Token::Num(int_literal(&rest[..len])?)); + rest = &rest[len..]; + } else if is_ident_start(c) { + // Not a macro: 0, as the C preprocessor counts it. + let len = rest.find(|c: char| !is_ident(c)).unwrap_or(rest.len()); + out.push(Token::Num(0)); + rest = &rest[len..]; + } else { + let op = OPS + .iter() + .find(|op| rest.starts_with(**op)) + .ok_or_else(|| format!("unexpected `{c}`"))?; + out.push(Token::Op(op)); + rest = &rest[op.len()..]; + } + rest = rest.trim_start(); + } + Ok(out) +} + +/// Decimal, `0x` hexadecimal or leading-zero octal, with an optional `u` suffix. +fn int_literal(text: &str) -> Result { + let digits = text.strip_suffix(['u', 'U']).unwrap_or(text); + let parsed = if let Some(hex) = digits.strip_prefix("0x").or(digits.strip_prefix("0X")) { + i64::from_str_radix(hex, 16) + } else if digits.len() > 1 && digits.starts_with('0') { + i64::from_str_radix(&digits[1..], 8) + } else { + digits.parse() + }; + parsed.map_err(|_| format!("`{text}` is not an integer")) +} + +struct Parser<'a> { + tokens: &'a [Token], + at: usize, +} + +/// Binary operators from the loosest to the tightest (GLSL §5.1 / §3.3). +const LEVELS: [&[&str]; 10] = [ + &["||"], + &["&&"], + &["|"], + &["^"], + &["&"], + &["==", "!="], + &["<", ">", "<=", ">="], + &["<<", ">>"], + &["+", "-"], + &["*", "/", "%"], +]; + +impl Parser<'_> { + fn next(&mut self) -> Option { + let t = self.tokens.get(self.at).copied(); + self.at += 1; + t + } + + fn binary(&mut self, level: usize) -> Result { + if level == LEVELS.len() { + return self.unary(); + } + let mut left = self.binary(level + 1)?; + while let Some(&Token::Op(op)) = self.tokens.get(self.at) { + if !LEVELS[level].contains(&op) { + break; + } + self.at += 1; + let right = self.binary(level + 1)?; + left = apply(op, left, right)?; + } + Ok(left) + } + + fn unary(&mut self) -> Result { + match self.next() { + Some(Token::Num(n)) => Ok(n), + Some(Token::Op("(")) => { + let value = self.binary(0)?; + match self.next() { + Some(Token::Op(")")) => Ok(value), + _ => Err("missing `)`".into()), + } + } + Some(Token::Op("-")) => Ok(self.unary()?.wrapping_neg()), + Some(Token::Op("+")) => self.unary(), + Some(Token::Op("!")) => Ok((self.unary()? == 0) as i64), + Some(Token::Op("~")) => Ok(!self.unary()?), + Some(t) => Err(format!("unexpected `{}`", t.text())), + None => Err("the condition ends too early".into()), + } + } +} + +fn apply(op: &str, a: i64, b: i64) -> Result { + Ok(match op { + "||" => (a != 0 || b != 0) as i64, + "&&" => (a != 0 && b != 0) as i64, + "|" => a | b, + "^" => a ^ b, + "&" => a & b, + "==" => (a == b) as i64, + "!=" => (a != b) as i64, + "<" => (a < b) as i64, + ">" => (a > b) as i64, + "<=" => (a <= b) as i64, + ">=" => (a >= b) as i64, + "<<" => a.wrapping_shl(b as u32), + ">>" => a.wrapping_shr(b as u32), + "+" => a.wrapping_add(b), + "-" => a.wrapping_sub(b), + "*" => a.wrapping_mul(b), + "/" | "%" if b == 0 => return Err("division by zero".into()), + "/" => a.wrapping_div(b), + _ => a.wrapping_rem(b), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn eval(expr: &str) -> Result { + let mut m = Macros::standard(); + m.define("SHADOW_RES", "2048"); + m.define("WAVES", ""); + m.define("QUALITY", "SHADOW_RES / 1024"); + eval_condition(expr, &m) + } + + #[test] + fn precedence_and_operators_follow_c() { + assert_eq!(eval("1 + 2 * 3"), Ok(7)); + assert_eq!(eval("(1 + 2) * 3"), Ok(9)); + assert_eq!(eval("1 << 2 + 1"), Ok(8)); + assert_eq!(eval("6 & 3 == 3"), Ok(0)); + assert_eq!(eval("1 || 0 && 0"), Ok(1)); + assert_eq!(eval("-7 / 2"), Ok(-3)); + assert_eq!(eval("-7 % 2"), Ok(-1)); + assert_eq!(eval("!0 + ~0"), Ok(0)); + assert_eq!(eval("0x10 + 010 + 3u"), Ok(27)); + } + + #[test] + fn macros_and_defined() { + assert_eq!(eval("MC_VERSION >= 11300"), Ok(1)); + assert_eq!(eval("MC_VERSION <= 12111"), Ok(0)); + assert_eq!(eval("QUALITY == 2"), Ok(1)); + assert_eq!(eval("defined WAVES && defined(SHADOW_RES)"), Ok(1)); + assert_eq!(eval("!defined NOPE"), Ok(1)); + assert_eq!(eval("NOPE == 0"), Ok(1)); + assert_eq!(eval("undefinedFeature"), Ok(0)); + } + + #[test] + fn bad_conditions_say_why() { + assert!(eval("1 / 0").is_err()); + assert!(eval("SHADOW_RES == 0.5").is_err()); + assert!(eval("(1 + 2").is_err()); + assert!(eval("1 2").is_err()); + assert!(eval("").is_err()); + assert!(eval("defined").is_err()); + assert!(eval("1 @ 2").is_err()); + } +} diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 1c9b46dd1..e7ccb26d6 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -5,11 +5,16 @@ //! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1). //! - [`discover`]: the packs in a `shaderpacks` folder. //! - [`Lang`]: the pack's translated names (8.4). +//! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). +mod condition; mod discover; mod lang; +mod macros; mod pack; +pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; +pub use macros::{MC_VERSION, Macros}; pub use pack::{Pack, PackError}; diff --git a/crates/horizon-shaders/src/macros.rs b/crates/horizon-shaders/src/macros.rs new file mode 100644 index 000000000..f3a53722f --- /dev/null +++ b/crates/horizon-shaders/src/macros.rs @@ -0,0 +1,131 @@ +//! Object-like macros: the ones packs test in `#if` lines and use inside property values +//! (spec 1.5, 1.5.1). The GLSL preprocessor's function-like macros are not needed here. + +use std::collections::BTreeMap; + +/// `MC_VERSION` of the version Horizon plays: 26.3 → 260300. +pub const MC_VERSION: u32 = mc_version(horizon_core::version::VERSION_ID); + +/// The documented rule, `major * 10000 + minor * 100 + patch` (spec 1.5.1, OQ-1): `1.21` → 12100, +/// `1.7.10` → 10710. Parts that are not digits count as 0. +pub const fn mc_version(id: &str) -> u32 { + let bytes = id.as_bytes(); + let (mut parts, mut part, mut i) = ([0u32; 3], 0, 0); + while i < bytes.len() && part < 3 { + match bytes[i] { + b'.' => part += 1, + b @ b'0'..=b'9' => parts[part] = parts[part] * 10 + (b - b'0') as u32, + _ => break, + } + i += 1; + } + parts[0] * 10000 + parts[1] * 100 + parts[2] +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Macros(BTreeMap); + +impl Macros { + /// The standard macros that need no renderer: `MC_VERSION` and this system's `MC_OS_*`. + /// The graphics ones arrive with the renderer. + pub fn standard() -> Self { + let mut m = Self::default(); + m.define("MC_VERSION", MC_VERSION.to_string()); + let os = match std::env::consts::OS { + "windows" => "MC_OS_WINDOWS", + "macos" => "MC_OS_MAC", + "linux" => "MC_OS_LINUX", + _ => "MC_OS_OTHER", + }; + m.define(os, ""); + m + } + + pub fn define(&mut self, name: impl Into, value: impl Into) { + self.0.insert(name.into(), value.into()); + } + + pub fn undef(&mut self, name: &str) { + self.0.remove(name); + } + + pub fn is_defined(&self, name: &str) -> bool { + self.0.contains_key(name) + } + + pub fn get(&self, name: &str) -> Option<&str> { + self.0.get(name).map(String::as_str) + } + + /// `text` with every identifier that names a macro replaced by its value, repeatedly; a macro + /// is not expanded again inside its own expansion, so `A` defined as `A + 1` stops. + pub fn expand(&self, text: &str) -> String { + self.expand_hiding(text, &mut Vec::new()) + } + + fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while let Some(start) = rest.find(is_ident_start) { + out.push_str(&rest[..start]); + // A digit run glued to letters (`1e5`, `0x1F`) is a number, not a name. + let glued = out + .chars() + .next_back() + .is_some_and(|c| c.is_ascii_alphanumeric() || c == '.'); + let len = rest[start..] + .find(|c: char| !is_ident(c)) + .unwrap_or(rest.len() - start); + let word = &rest[start..start + len]; + match self.0.get_key_value(word) { + Some((name, value)) if !glued && !hidden.contains(&name.as_str()) => { + hidden.push(name); + out.push_str(&self.expand_hiding(value, hidden)); + hidden.pop(); + } + _ => out.push_str(word), + } + rest = &rest[start + len..]; + } + out.push_str(rest); + out + } +} + +pub(crate) fn is_ident_start(c: char) -> bool { + c.is_ascii_alphabetic() || c == '_' +} + +pub(crate) fn is_ident(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '_' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn versions_follow_the_documented_rule() { + assert_eq!(mc_version("1.21"), 12100); + assert_eq!(mc_version("1.21.0"), 12100); + assert_eq!(mc_version("1.7.10"), 10710); + assert_eq!(mc_version("26.3"), 260300); + assert_eq!(MC_VERSION, 260300); + assert_eq!(Macros::standard().get("MC_VERSION"), Some("260300")); + } + + #[test] + fn expansion_replaces_whole_words_and_stops_on_recursion() { + let mut m = Macros::default(); + m.define("RES", "SIZE"); + m.define("SIZE", "512"); + m.define("LOOP", "LOOP + 1"); + assert_eq!(m.expand("RES RES 0.5"), "512 512 0.5"); + assert_eq!( + m.expand("RESOLUTION colortex4 LOOP"), + "RESOLUTION colortex4 LOOP + 1" + ); + m.define("e5", "x"); + assert_eq!(m.expand("1e5"), "1e5"); + } +} From 397d529db72a17a1def83e1d7c50e9f866beb69e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:42:33 +0200 Subject: [PATCH 02/50] Shader packs, clean room 3: .properties files with conditional directives shaders.properties and the id files (block, item, entity, dimension): key=value lines in file order with their line numbers, # comments, a value continued on the next line by a trailing backslash, and the conditional directives evaluated with the standard and option macros. A # line is a directive only when its first word is one, so a commented-out key such as #ifdefault=1 stays a comment. #define and #undef apply to the lines after them in every one of these files: a public pack defines a macro inside #ifdef MC_OS_MAC in its shaders.properties and tests it further down. A later duplicate key replaces the earlier one; broken conditionals, lines that are not key=value and duplicates are kept as notes for the pack's log. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/properties.rs | 279 +++++++++++++++++++++ crates/horizon-shaders/tests/real_packs.rs | 30 ++- 3 files changed, 305 insertions(+), 7 deletions(-) create mode 100644 crates/horizon-shaders/src/properties.rs diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index e7ccb26d6..bdfef7067 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -4,6 +4,7 @@ //! //! - [`Pack`]: a pack as a folder or a zip, with paths relative to its `shaders` folder (spec 1.1). //! - [`discover`]: the packs in a `shaderpacks` folder. +//! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). @@ -12,9 +13,11 @@ mod discover; mod lang; mod macros; mod pack; +mod properties; pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros}; pub use pack::{Pack, PackError}; +pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/properties.rs b/crates/horizon-shaders/src/properties.rs new file mode 100644 index 000000000..fc3f00c81 --- /dev/null +++ b/crates/horizon-shaders/src/properties.rs @@ -0,0 +1,279 @@ +//! `.properties` files: `shaders.properties` (spec 3.1) and the id files `block.properties`, +//! `item.properties`, `entity.properties` (4.1). `key=value` lines, `#` comments, a trailing `\` +//! continuing a value on the next line, and conditional directives evaluated with the +//! standard and option macros (1.5 item 2). `#define` and `#undef` work in every one of them: +//! a public pack defines a macro in its `shaders.properties` and tests it further down. +//! No other escape is read: values are names, numbers and expressions. A later duplicate key +//! replaces the earlier one, with a note. + +use crate::{Macros, eval_condition}; + +/// One `key=value`, with the line it starts on (from 1). +#[derive(Clone, Debug, PartialEq)] +pub struct Entry { + pub key: String, + pub value: String, + pub line: u32, +} + +/// Something wrong with a line, for the pack's log. +#[derive(Clone, Debug, PartialEq)] +pub struct Note { + pub line: u32, + pub message: String, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Properties { + /// In file order; a key appears once (a later duplicate replaces the earlier one). + pub entries: Vec, + pub notes: Vec, +} + +/// One branch level of `#if` ... `#endif`. +struct Branch { + /// This branch's lines are read. + on: bool, + /// A branch of this `#if` was already taken (the rest are skipped). + taken: bool, + /// The enclosing level is read. + outer: bool, + seen_else: bool, +} + +impl Properties { + /// Reads `text`; `#define`/`#undef` change the macros for the lines after them only. + pub fn parse(text: &str, macros: &Macros) -> Self { + let mut out = Self::default(); + let mut macros = macros.clone(); + let mut branches: Vec = Vec::new(); + let mut lines = text.lines().zip(1u32..); + while let Some((raw, number)) = lines.next() { + let line = raw.trim(); + let on = branches.last().is_none_or(|b| b.on); + if let Some(directive) = line.strip_prefix('#') { + out.directive(directive.trim_start(), number, &mut branches, &mut macros); + continue; + } + // Continuation lines belong to their entry even inside a skipped branch. + let mut logical = line.to_owned(); + while ends_with_continuation(&logical) { + logical.pop(); + match lines.next() { + Some((next, _)) => logical.push_str(next.trim()), + None => break, + } + } + if !on || logical.is_empty() { + continue; + } + match logical.split_once('=') { + Some((key, value)) if !key.trim().is_empty() => out.insert(Entry { + key: key.trim().to_owned(), + value: value.trim().to_owned(), + line: number, + }), + _ => out.note(number, "not a key=value line"), + } + } + if !branches.is_empty() { + out.note(text.lines().count() as u32, "`#if` without `#endif`"); + } + out + } + + pub fn get(&self, key: &str) -> Option<&str> { + self.entry(key).map(|e| e.value.as_str()) + } + + pub fn entry(&self, key: &str) -> Option<&Entry> { + self.entries.iter().find(|e| e.key == key) + } + + fn insert(&mut self, entry: Entry) { + if let Some(at) = self.entries.iter().position(|e| e.key == entry.key) { + let first = self.entries.remove(at).line; + self.note( + entry.line, + &format!( + "`{}` is set again (first on line {first}); this one counts", + entry.key + ), + ); + } + self.entries.push(entry); + } + + fn note(&mut self, line: u32, message: &str) { + self.notes.push(Note { + line, + message: message.to_owned(), + }); + } + + /// A line starting with `#`. It is a directive only when its first word is one; anything + /// else (`# text`, `#commented.key=1`, `#ifdefault`) is a comment. + fn directive( + &mut self, + line: &str, + number: u32, + branches: &mut Vec, + macros: &mut Macros, + ) { + let word_len = line + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(line.len()); + let (word, rest) = line.split_at(word_len); + if !(rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//")) { + return; + } + let rest = rest.split("//").next().unwrap_or("").trim(); + let on = branches.last().is_none_or(|b| b.on); + match word { + "if" | "ifdef" | "ifndef" => { + let value = on && self.condition(word, rest, number, macros); + branches.push(Branch { + on: value, + taken: value, + outer: on, + seen_else: false, + }); + } + "elif" | "else" => { + let Some(b) = branches.last_mut() else { + return self.note(number, &format!("`#{word}` without `#if`")); + }; + if b.seen_else { + return self.note(number, &format!("`#{word}` after `#else`")); + } + let take = b.outer && !b.taken; + b.seen_else = word == "else"; + b.on = take && (word == "else" || self.condition("if", rest, number, macros)); + b.taken |= b.on; + } + "endif" => { + if branches.pop().is_none() { + self.note(number, "`#endif` without `#if`"); + } + } + "define" if on => { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + macros.define(name, value.trim()); + } + "undef" if on => macros.undef(rest), + _ => {} + } + } + + fn condition(&mut self, word: &str, rest: &str, number: u32, macros: &Macros) -> bool { + let name = rest.split_whitespace().next().unwrap_or(""); + let result = match word { + "ifdef" => Ok(macros.is_defined(name) as i64), + "ifndef" => Ok(!macros.is_defined(name) as i64), + _ => eval_condition(rest, macros), + }; + result.unwrap_or_else(|e| { + self.note(number, &format!("`#{word} {rest}`: {e}; read as false")); + 0 + }) != 0 + } +} + +/// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal). +fn ends_with_continuation(line: &str) -> bool { + line.bytes().rev().take_while(|&b| b == b'\\').count() % 2 == 1 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(text: &str) -> Properties { + let mut m = Macros::standard(); + m.define("SHADOWS", ""); + m.define("QUALITY", "2"); + Properties::parse(text, &m) + } + + fn pairs(p: &Properties) -> Vec<(&str, &str)> { + p.entries + .iter() + .map(|e| (e.key.as_str(), e.value.as_str())) + .collect() + } + + #[test] + fn keys_values_and_comments() { + let p = + read("# a comment\n\n sun = false\n#commented.key=1\nscreen=A B [C]\r\nbad line\n"); + assert_eq!(pairs(&p), [("sun", "false"), ("screen", "A B [C]")]); + assert_eq!(p.entry("screen").unwrap().line, 5); + assert_eq!( + p.notes, + [Note { + line: 6, + message: "not a key=value line".into() + }] + ); + } + + #[test] + fn continuation_lines_join() { + let p = read("screen=A B \\\n C D\\\n E\nsliders=X\nliteral=a\\\\\nnext=1"); + assert_eq!( + pairs(&p), + [ + ("screen", "A B C DE"), + ("sliders", "X"), + ("literal", "a\\\\"), + ("next", "1") + ] + ); + assert_eq!(p.entry("sliders").unwrap().line, 4); + } + + #[test] + fn conditionals_pick_one_branch() { + let p = read( + "#if MC_VERSION >= 11300\nnew=1\n#else\nold=1\n#endif\n\ + #ifdef SHADOWS\n#if QUALITY == 1\nq=1\n#elif QUALITY == 2\nq=2\n#elif QUALITY >= 2\nq=3\n#endif\n#endif\n\ + #ifndef SHADOWS\nnone=1\n#elif 0\nnever=1\n#else\nfallback=1\n#endif\n# ifdef SHADOWS // trailing comment\nspaced=1\n#endif", + ); + assert_eq!( + pairs(&p), + [("new", "1"), ("q", "2"), ("fallback", "1"), ("spaced", "1")] + ); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn comment_lines_are_not_directives() { + let p = read("#ifdefault=1\n#iffy\n#endiff\n#else-where\nkey=1"); + assert_eq!(pairs(&p), [("key", "1")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn defines_apply_to_the_lines_after_them() { + let text = "#ifdef MC_OS_NONE\n#define WATER 0\n#endif\n#define WATER 8\n\ + #if WATER == 8\nblock.8=minecraft:water\n#endif\n#undef WATER\n#ifdef WATER\nno=1\n#endif"; + let p = Properties::parse(text, &Macros::default()); + assert_eq!(pairs(&p), [("block.8", "minecraft:water")]); + assert!(p.notes.is_empty(), "{:?}", p.notes); + } + + #[test] + fn duplicate_keys_keep_the_last() { + let p = read("a=1\nb=2\na=3"); + assert_eq!(pairs(&p), [("b", "2"), ("a", "3")]); + assert_eq!(p.notes[0].line, 3); + } + + #[test] + fn broken_conditionals_are_noted() { + let p = read("#endif\n#else\n#if 1 +\nx=1\n#endif\n#if 1\n#else\n#else\n"); + let lines: Vec = p.notes.iter().map(|n| n.line).collect(); + assert_eq!(lines, [1, 2, 3, 8, 8]); + assert!(p.entries.is_empty()); + } +} diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index b35aef519..e0ad38c25 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Pack, discover}; +use horizon_shaders::{Lang, Macros, Pack, Properties, discover}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -22,21 +22,37 @@ fn real_packs_read() { for f in found.iter().filter(|f| f.problem.is_none()) { let start = Instant::now(); let pack = Pack::open(&f.path).unwrap(); - let properties = pack.read_text("shaders.properties"); + let macros = Macros::standard(); + let shaders = Properties::parse( + &pack.read_text("shaders.properties").unwrap_or_default(), + ¯os, + ); + let ids: Vec = ["block", "item", "entity", "dimension"] + .iter() + .filter_map(|n| pack.read_text(&format!("{n}.properties"))) + .map(|t| Properties::parse(&t, ¯os)) + .collect(); let lang = Lang::load(&pack, "en_us"); + let elapsed = start.elapsed(); println!( - "{}: {} files at the root, shaders.properties {} bytes, lang {}, {:.2} ms", + "{}: shaders.properties {} keys ({} notes), {} id files {} keys ({} notes), lang {}, {:.2} ms", f.name, - pack.list("").len(), - properties.as_ref().map_or(0, String::len), + shaders.entries.len(), + shaders.notes.len(), + ids.len(), + ids.iter().map(|p| p.entries.len()).sum::(), + ids.iter().map(|p| p.notes.len()).sum::(), if lang == Lang::default() { "none" } else { "read" }, - start.elapsed().as_secs_f64() * 1000.0 + elapsed.as_secs_f64() * 1000.0 ); - assert!(properties.is_some(), "{}: no shaders.properties", f.name); + for n in &shaders.notes { + println!(" shaders.properties:{}: {}", n.line, n.message); + } + assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } assert!(read > 0, "no packs under {}", dir.display()); From 4c65ed2f5c6c4e089cf4f0bec4b32ab97004e4bc Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:09:07 +0200 Subject: [PATCH 03/50] Shader packs, clean room 4: function-like macros and the standard macros The macro engine the GLSL preprocessor needs, shared with the .properties reader: function-like macros take their arguments (expanded first, raw beside ##, which pastes) when a ( follows their name; a macro is still not expanded inside its own expansion. Text is cut into preprocessing tokens, so numbers such as 1e5 or 0x1F are never mistaken for names. The #if/#elif/#else/#endif state moves out of the .properties reader so the preprocessor uses the same code. Standard macros: MC_GL_VERSION and MC_GLSL_VERSION 460, the 24 MC_RENDER_STAGE_* as ascending integers in draw order, render and shadow quality 1.0, MC_HAND_DEPTH 0.125, MAX_COLOR_BUFFERS 16. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/condition.rs | 104 ++++++++++ crates/horizon-shaders/src/lib.rs | 2 +- crates/horizon-shaders/src/macros.rs | 242 ++++++++++++++++++++--- crates/horizon-shaders/src/properties.rs | 98 +++------ 4 files changed, 343 insertions(+), 103 deletions(-) diff --git a/crates/horizon-shaders/src/condition.rs b/crates/horizon-shaders/src/condition.rs index b911eb985..c476bb663 100644 --- a/crates/horizon-shaders/src/condition.rs +++ b/crates/horizon-shaders/src/condition.rs @@ -20,6 +20,110 @@ pub fn eval_condition(expr: &str, macros: &Macros) -> Result { } } +/// The text after a line's `#`, split into the directive word and the rest without its `//` +/// comment. `None` when the first word is followed by something other than white space, `//` or +/// the end of the line (`#ifdefault=1` is a comment, not `#ifdef`). +pub(crate) fn split_directive(after_hash: &str) -> Option<(&str, &str)> { + let line = after_hash.trim_start(); + let len = line + .find(|c: char| !c.is_ascii_alphabetic()) + .unwrap_or(line.len()); + let (word, rest) = line.split_at(len); + let ok = rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//"); + ok.then(|| (word, rest.split("//").next().unwrap_or("").trim())) +} + +/// One level of `#if` ... `#endif`. +struct Branch { + /// This branch's lines are read. + on: bool, + /// A branch of this `#if` was already taken (the rest are skipped). + taken: bool, + /// The enclosing level is read. + outer: bool, + seen_else: bool, +} + +/// The state of nested conditional directives. +#[derive(Default)] +pub(crate) struct Conditionals(Vec); + +impl Conditionals { + /// Lines here are read. + pub(crate) fn active(&self) -> bool { + self.0.last().is_none_or(|b| b.on) + } + + /// An `#if` is still open. + pub(crate) fn open(&self) -> bool { + !self.0.is_empty() + } + + /// Applies `#word rest` when it is a conditional directive (`Ok(false)` when it is not). A + /// condition that can't be evaluated reads as false and returns why. + pub(crate) fn apply( + &mut self, + word: &str, + rest: &str, + macros: &Macros, + ) -> Result { + let on = self.active(); + match word { + "if" | "ifdef" | "ifndef" => { + let value = if on { + test(word, rest, macros) + } else { + Ok(false) + }; + let v = *value.as_ref().unwrap_or(&false); + self.0.push(Branch { + on: v, + taken: v, + outer: on, + seen_else: false, + }); + value.map(|_| true) + } + "elif" | "else" => { + let b = self + .0 + .last_mut() + .ok_or_else(|| format!("`#{word}` without `#if`"))?; + if b.seen_else { + return Err(format!("`#{word}` after `#else`")); + } + b.seen_else = word == "else"; + let take = b.outer && !b.taken; + let value = match word { + "else" => Ok(take), + _ if take => test("if", rest, macros), + _ => Ok(false), + }; + b.on = *value.as_ref().unwrap_or(&false); + b.taken |= b.on; + value.map(|_| true) + } + "endif" => self + .0 + .pop() + .map(|_| true) + .ok_or_else(|| "`#endif` without `#if`".into()), + _ => Ok(false), + } + } +} + +fn test(word: &str, rest: &str, macros: &Macros) -> Result { + let name = rest.split_whitespace().next().unwrap_or(""); + match word { + "ifdef" => Ok(macros.is_defined(name)), + "ifndef" => Ok(!macros.is_defined(name)), + _ => eval_condition(rest, macros) + .map(|v| v != 0) + .map_err(|e| format!("`#{word} {rest}`: {e}; read as false")), + } +} + /// `defined X` and `defined(X)` become `1` or `0` before anything is expanded. fn resolve_defined(expr: &str, macros: &Macros) -> Result { let mut out = String::with_capacity(expr.len()); diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index bdfef7067..05e51a53d 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -18,6 +18,6 @@ mod properties; pub use condition::eval_condition; pub use discover::{Found, discover}; pub use lang::Lang; -pub use macros::{MC_VERSION, Macros}; +pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/macros.rs b/crates/horizon-shaders/src/macros.rs index f3a53722f..d8a184f41 100644 --- a/crates/horizon-shaders/src/macros.rs +++ b/crates/horizon-shaders/src/macros.rs @@ -22,12 +22,48 @@ pub const fn mc_version(id: &str) -> u32 { parts[0] * 10000 + parts[1] * 100 + parts[2] } +#[derive(Clone, Debug, PartialEq)] +struct Macro { + /// `None` for an object-like macro; the parameter names of a function-like one. + params: Option>, + body: String, +} + #[derive(Clone, Debug, Default, PartialEq)] -pub struct Macros(BTreeMap); +pub struct Macros(BTreeMap); + +/// `MC_RENDER_STAGE_*` in the order the stages draw (spec 5.4); each is its index (OQ-7). +pub const RENDER_STAGES: [&str; 24] = [ + "NONE", + "SKY", + "SUNSET", + "CUSTOM_SKY", + "SUN", + "MOON", + "STARS", + "VOID", + "TERRAIN_SOLID", + "TERRAIN_CUTOUT_MIPPED", + "TERRAIN_CUTOUT", + "ENTITIES", + "BLOCK_ENTITIES", + "DESTROY", + "OUTLINE", + "DEBUG", + "HAND_SOLID", + "TERRAIN_TRANSLUCENT", + "TRIPWIRE", + "PARTICLES", + "CLOUDS", + "RAIN_SNOW", + "WORLD_BORDER", + "HAND_TRANSLUCENT", +]; impl Macros { - /// The standard macros that need no renderer: `MC_VERSION` and this system's `MC_OS_*`. - /// The graphics ones arrive with the renderer. + /// The standard macros that need no renderer (spec 1.5.1): `MC_VERSION`, this system's + /// `MC_OS_*`, the GL and GLSL versions Horizon's translation accepts, the render stages and + /// the host settings at their defaults. The GPU vendor and family arrive with the renderer. pub fn standard() -> Self { let mut m = Self::default(); m.define("MC_VERSION", MC_VERSION.to_string()); @@ -38,11 +74,32 @@ impl Macros { _ => "MC_OS_OTHER", }; m.define(os, ""); + m.define("MC_GL_VERSION", "460"); + m.define("MC_GLSL_VERSION", "460"); + for (i, stage) in RENDER_STAGES.iter().enumerate() { + m.define(format!("MC_RENDER_STAGE_{stage}"), i.to_string()); + } + m.define("MC_RENDER_QUALITY", "1.0"); + m.define("MC_SHADOW_QUALITY", "1.0"); + m.define("MC_HAND_DEPTH", "0.125"); + m.define("MAX_COLOR_BUFFERS", "16"); m } pub fn define(&mut self, name: impl Into, value: impl Into) { - self.0.insert(name.into(), value.into()); + let body = value.into(); + self.0.insert(name.into(), Macro { params: None, body }); + } + + pub fn define_function(&mut self, name: &str, params: Vec, body: &str) { + let body = body.to_owned(); + self.0.insert( + name.to_owned(), + Macro { + params: Some(params), + body, + }, + ); } pub fn undef(&mut self, name: &str) { @@ -53,43 +110,158 @@ impl Macros { self.0.contains_key(name) } + /// The value of an object-like macro. pub fn get(&self, name: &str) -> Option<&str> { - self.0.get(name).map(String::as_str) + self.0 + .get(name) + .filter(|m| m.params.is_none()) + .map(|m| m.body.as_str()) } - /// `text` with every identifier that names a macro replaced by its value, repeatedly; a macro - /// is not expanded again inside its own expansion, so `A` defined as `A + 1` stops. + /// `text` with every macro expanded, repeatedly; a macro is not expanded again inside its own + /// expansion, so `A` defined as `A + 1` stops. Function-like macros take their arguments + /// (expanded first, raw beside `##`, which pastes) when a `(` follows their name. pub fn expand(&self, text: &str) -> String { self.expand_hiding(text, &mut Vec::new()) } fn expand_hiding<'a>(&'a self, text: &str, hidden: &mut Vec<&'a str>) -> String { let mut out = String::with_capacity(text.len()); - let mut rest = text; - while let Some(start) = rest.find(is_ident_start) { - out.push_str(&rest[..start]); - // A digit run glued to letters (`1e5`, `0x1F`) is a number, not a name. - let glued = out - .chars() - .next_back() - .is_some_and(|c| c.is_ascii_alphanumeric() || c == '.'); - let len = rest[start..] - .find(|c: char| !is_ident(c)) - .unwrap_or(rest.len() - start); - let word = &rest[start..start + len]; - match self.0.get_key_value(word) { - Some((name, value)) if !glued && !hidden.contains(&name.as_str()) => { - hidden.push(name); - out.push_str(&self.expand_hiding(value, hidden)); - hidden.pop(); - } - _ => out.push_str(word), + let tokens = lex(text); + let mut i = 0; + while i < tokens.len() { + let word = tokens[i]; + i += 1; + let Some((name, m)) = self.0.get_key_value(word) else { + out.push_str(word); + continue; + }; + if hidden.contains(&name.as_str()) { + out.push_str(word); + continue; } - rest = &rest[start + len..]; + let body = match &m.params { + None => m.body.clone(), + Some(params) => { + let Some((args, next)) = arguments(&tokens, i) else { + out.push_str(word); + continue; + }; + i = next; + self.substitute(params, &args, &m.body, hidden) + } + }; + hidden.push(name); + out.push_str(&self.expand_hiding(&body, hidden)); + hidden.pop(); } - out.push_str(rest); out } + + /// A function-like macro's body with its parameters replaced. + fn substitute<'a>( + &'a self, + params: &[String], + args: &[String], + body: &str, + hidden: &mut Vec<&'a str>, + ) -> String { + let tokens = lex(body); + let pasted = |at: usize, step: isize| { + let mut j = at as isize + step; + while (0..tokens.len() as isize).contains(&j) && tokens[j as usize].trim().is_empty() { + j += step; + } + (0..tokens.len() as isize).contains(&j) && tokens[j as usize] == "##" + }; + let mut out = String::new(); + for (at, token) in tokens.iter().enumerate() { + match params.iter().position(|p| p == token) { + Some(k) => { + let arg = args.get(k).map_or("", |a| a.trim()); + if pasted(at, -1) || pasted(at, 1) { + out.push_str(arg); + } else { + out.push_str(&self.expand_hiding(arg, hidden)); + } + } + None => out.push_str(token), + } + } + // `a ## b` → `ab`. + let mut joined = String::with_capacity(out.len()); + for (k, part) in out.split("##").enumerate() { + joined.push_str(if k == 0 { part.trim_end() } else { part.trim() }); + } + if out.contains("##") { joined } else { out } + } +} + +/// The arguments of a call whose `(` is the next non-blank token from `i`, split at top-level +/// commas, and the index after its `)`. `None` when no `(` follows or it never closes. +fn arguments(tokens: &[&str], mut i: usize) -> Option<(Vec, usize)> { + while tokens.get(i)?.trim().is_empty() { + i += 1; + } + if tokens[i] != "(" { + return None; + } + let (mut depth, mut args, mut from) = (0, Vec::new(), i + 1); + for (j, t) in tokens.iter().enumerate().skip(i + 1) { + match *t { + "(" => depth += 1, + ")" if depth == 0 => { + args.push(tokens[from..j].concat()); + return Some((args, j + 1)); + } + ")" => depth -= 1, + "," if depth == 0 => { + args.push(tokens[from..j].concat()); + from = j + 1; + } + _ => {} + } + } + None +} + +/// Preprocessing tokens: identifiers, numbers (with their suffixes and exponents), runs of +/// white space, `##`, and single characters. Concatenated they give back `text`. +pub(crate) fn lex(text: &str) -> Vec<&str> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let c = bytes[i] as char; + let start = i; + i += 1; + if is_ident_start(c) { + while i < bytes.len() && is_ident(bytes[i] as char) { + i += 1; + } + } else if c.is_ascii_digit() || (c == '.' && bytes.get(i).is_some_and(u8::is_ascii_digit)) { + while i < bytes.len() { + let d = bytes[i] as char; + let exp_sign = (d == '+' || d == '-') && matches!(bytes[i - 1], b'e' | b'E'); + if !(is_ident(d) || d == '.' || exp_sign) { + break; + } + i += 1; + } + } else if c.is_whitespace() { + while i < bytes.len() && (bytes[i] as char).is_whitespace() { + i += 1; + } + } else if c == '#' && bytes.get(i) == Some(&b'#') { + i += 1; + } else { + while !text.is_char_boundary(i) { + i += 1; + } + } + out.push(&text[start..i]); + } + out } pub(crate) fn is_ident_start(c: char) -> bool { @@ -128,4 +300,18 @@ mod tests { m.define("e5", "x"); assert_eq!(m.expand("1e5"), "1e5"); } + + #[test] + fn function_like_macros_take_arguments_and_paste() { + let mut m = Macros::default(); + m.define("N", "4"); + m.define_function("SQ", vec!["x".into()], "((x) * (x))"); + m.define_function("CAT", vec!["a".into(), "b".into()], "a ## b"); + m.define_function("PI", vec![], "3.14"); + assert_eq!(m.expand("SQ(N + 1)"), "((4 + 1) * (4 + 1))"); + assert_eq!(m.expand("SQ(min(N, 2))"), "((min(4, 2)) * (min(4, 2)))"); + assert_eq!(m.expand("CAT(N, 2) CAT(col, ortex4)"), "N2 colortex4"); + assert_eq!(m.expand("PI() SQ SQ(1"), "3.14 SQ SQ(1"); + assert_eq!(m.get("SQ"), None); + } } diff --git a/crates/horizon-shaders/src/properties.rs b/crates/horizon-shaders/src/properties.rs index fc3f00c81..b9d27f3e3 100644 --- a/crates/horizon-shaders/src/properties.rs +++ b/crates/horizon-shaders/src/properties.rs @@ -6,7 +6,8 @@ //! No other escape is read: values are names, numbers and expressions. A later duplicate key //! replaces the earlier one, with a note. -use crate::{Macros, eval_condition}; +use crate::Macros; +use crate::condition::{Conditionals, split_directive}; /// One `key=value`, with the line it starts on (from 1). #[derive(Clone, Debug, PartialEq)] @@ -30,29 +31,20 @@ pub struct Properties { pub notes: Vec, } -/// One branch level of `#if` ... `#endif`. -struct Branch { - /// This branch's lines are read. - on: bool, - /// A branch of this `#if` was already taken (the rest are skipped). - taken: bool, - /// The enclosing level is read. - outer: bool, - seen_else: bool, -} - impl Properties { /// Reads `text`; `#define`/`#undef` change the macros for the lines after them only. pub fn parse(text: &str, macros: &Macros) -> Self { let mut out = Self::default(); let mut macros = macros.clone(); - let mut branches: Vec = Vec::new(); + let mut conditionals = Conditionals::default(); let mut lines = text.lines().zip(1u32..); while let Some((raw, number)) = lines.next() { let line = raw.trim(); - let on = branches.last().is_none_or(|b| b.on); + let on = conditionals.active(); if let Some(directive) = line.strip_prefix('#') { - out.directive(directive.trim_start(), number, &mut branches, &mut macros); + if let Some((word, rest)) = split_directive(directive) { + out.directive(word, rest, number, &mut conditionals, &mut macros); + } continue; } // Continuation lines belong to their entry even inside a skipped branch. @@ -76,7 +68,7 @@ impl Properties { _ => out.note(number, "not a key=value line"), } } - if !branches.is_empty() { + if conditionals.open() { out.note(text.lines().count() as u32, "`#if` without `#endif`"); } out @@ -111,72 +103,30 @@ impl Properties { }); } - /// A line starting with `#`. It is a directive only when its first word is one; anything - /// else (`# text`, `#commented.key=1`, `#ifdefault`) is a comment. + /// A line starting with `#` whose first word may be a directive; any other word makes it a + /// comment. fn directive( &mut self, - line: &str, + word: &str, + rest: &str, number: u32, - branches: &mut Vec, + conditionals: &mut Conditionals, macros: &mut Macros, ) { - let word_len = line - .find(|c: char| !c.is_ascii_alphabetic()) - .unwrap_or(line.len()); - let (word, rest) = line.split_at(word_len); - if !(rest.is_empty() || rest.starts_with(char::is_whitespace) || rest.starts_with("//")) { - return; - } - let rest = rest.split("//").next().unwrap_or("").trim(); - let on = branches.last().is_none_or(|b| b.on); - match word { - "if" | "ifdef" | "ifndef" => { - let value = on && self.condition(word, rest, number, macros); - branches.push(Branch { - on: value, - taken: value, - outer: on, - seen_else: false, - }); - } - "elif" | "else" => { - let Some(b) = branches.last_mut() else { - return self.note(number, &format!("`#{word}` without `#if`")); - }; - if b.seen_else { - return self.note(number, &format!("`#{word}` after `#else`")); + match conditionals.apply(word, rest, macros) { + Err(e) => self.note(number, &e), + Ok(true) => {} + Ok(false) if conditionals.active() => match word { + "define" => { + let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); + macros.define(name, value.trim()); } - let take = b.outer && !b.taken; - b.seen_else = word == "else"; - b.on = take && (word == "else" || self.condition("if", rest, number, macros)); - b.taken |= b.on; - } - "endif" => { - if branches.pop().is_none() { - self.note(number, "`#endif` without `#if`"); - } - } - "define" if on => { - let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, "")); - macros.define(name, value.trim()); - } - "undef" if on => macros.undef(rest), - _ => {} + "undef" => macros.undef(rest), + _ => {} + }, + Ok(false) => {} } } - - fn condition(&mut self, word: &str, rest: &str, number: u32, macros: &Macros) -> bool { - let name = rest.split_whitespace().next().unwrap_or(""); - let result = match word { - "ifdef" => Ok(macros.is_defined(name) as i64), - "ifndef" => Ok(!macros.is_defined(name) as i64), - _ => eval_condition(rest, macros), - }; - result.unwrap_or_else(|e| { - self.note(number, &format!("`#{word} {rest}`: {e}; read as false")); - 0 - }) != 0 - } } /// An odd number of trailing backslashes: the last one joins the next line (`\\` is a literal). From f2399559979c1fd1a0e62c578a83ae4e5a29cd75 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:09:29 +0200 Subject: [PATCH 04/50] Shader packs, clean room 5: the GLSL preprocessor and #include A stage file goes through comment removal, #include, conditionals and macro expansion, and every output line remembers the pack file and line it came from, so later errors point at the pack's own code. - #include "x" from the including file's folder, "/x" from the shaders folder, expanded in live code only (an include inside a false #if is not read), ten levels at most, paths that leave the pack refused; a file is read once per run however often it is included. - #define, #undef, line continuations, __LINE__ and __VERSION__; #version and #extension kept apart for the translation's own header; the block comments of live code kept for the directives packs write in them (DRAWBUFFERS, buffer formats); #error and unknown directives diagnosed. Black box over the public test packs: every stage file of the base and dimension folders (126 to 219 per pack) preprocesses with no diagnostic. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/pack.rs | 2 +- crates/horizon-shaders/src/preprocess.rs | 374 +++++++++++++++++++++ crates/horizon-shaders/tests/real_packs.rs | 36 +- 4 files changed, 413 insertions(+), 2 deletions(-) create mode 100644 crates/horizon-shaders/src/preprocess.rs diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 05e51a53d..14f85b758 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -6,6 +6,7 @@ //! - [`discover`]: the packs in a `shaderpacks` folder. //! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). +//! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). mod condition; @@ -13,6 +14,7 @@ mod discover; mod lang; mod macros; mod pack; +mod preprocess; mod properties; pub use condition::eval_condition; @@ -20,4 +22,5 @@ pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; +pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; pub use properties::{Entry, Note, Properties}; diff --git a/crates/horizon-shaders/src/pack.rs b/crates/horizon-shaders/src/pack.rs index 113db9ac9..ae2230618 100644 --- a/crates/horizon-shaders/src/pack.rs +++ b/crates/horizon-shaders/src/pack.rs @@ -170,7 +170,7 @@ impl Pack { } /// `a/./b/../c` → `a/c`; `None` for a path that climbs out of `shaders`. -fn normalize(path: &str) -> Option { +pub(crate) fn normalize(path: &str) -> Option { let mut parts: Vec<&str> = Vec::new(); for part in path.split(['/', '\\']) { match part { diff --git a/crates/horizon-shaders/src/preprocess.rs b/crates/horizon-shaders/src/preprocess.rs new file mode 100644 index 000000000..4884f28f7 --- /dev/null +++ b/crates/horizon-shaders/src/preprocess.rs @@ -0,0 +1,374 @@ +//! The GLSL preprocessor a program's stage file goes through before translation (spec 1.4, +//! 1.5, 1.6): comments removed, `#include` expanded, conditionals evaluated, macros expanded. +//! Every output line remembers the pack file and line it came from, so errors found later point +//! at the pack's own code. +//! +//! `#include` is expanded where it stands in live code only: an include inside a false `#if` +//! is not read, so it can't fail (OQ-14). Ten levels deep at most, which also stops a file that +//! includes itself. `#version` and `#extension` are taken out of the code and kept apart: the +//! translation writes its own header. + +use std::collections::HashMap; + +use crate::Macros; +use crate::Pack; +use crate::condition::{Conditionals, split_directive}; +use crate::pack::normalize; + +const MAX_DEPTH: usize = 10; + +/// Where a line came from: an index into [`Preprocessed::files`] and a line number from 1. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Origin { + pub file: u16, + pub line: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Diagnostic { + pub origin: Origin, + pub message: String, +} + +#[derive(Debug, Default)] +pub struct Preprocessed { + /// The code, one line per entry of `lines`. + pub code: String, + pub lines: Vec, + /// The pack paths of the files read, the stage file first. + pub files: Vec, + /// `#version` of the stage file: the number and the profile word, if any. + pub version: Option<(u32, Option)>, + /// `#extension` lines of live code, as written after `#extension`. + pub extensions: Vec, + /// The text of every block comment in live code (`DRAWBUFFERS:`, buffer formats). + pub comments: Vec<(Origin, String)>, + pub diagnostics: Vec, +} + +impl Preprocessed { + /// `file:line` of an output line (from 0), for messages. + pub fn locate(&self, line: usize) -> String { + let o = self.lines.get(line).copied().unwrap_or_default(); + let file = self.files.get(o.file as usize).map_or("?", String::as_str); + format!("{file}:{}", o.line) + } +} + +/// Runs the preprocessor on the stage file `path` of `pack` with `macros` defined. +pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed { + let mut run = Run { + pack, + macros: macros.clone(), + conditionals: Conditionals::default(), + out: Preprocessed::default(), + code: Vec::new(), + texts: HashMap::new(), + }; + run.file(path, 0, Origin::default()); + if run.conditionals.open() { + run.diagnose(Origin::default(), "`#if` without `#endif`"); + } + run.out.code = run.code.join("\n"); + run.out +} + +struct Run<'p> { + pack: &'p Pack, + macros: Macros, + conditionals: Conditionals, + out: Preprocessed, + code: Vec, + /// Files already read: a guarded header is included many times. + texts: HashMap>, +} + +impl Run<'_> { + fn file(&mut self, path: &str, depth: usize, from: Origin) { + let cached = self + .texts + .entry(path.to_owned()) + .or_insert_with(|| self.pack.read_text(path)); + let Some(text) = cached.clone() else { + return self.diagnose(from, &format!("can't find `{path}`")); + }; + let file = match self.out.files.iter().position(|f| f == path) { + Some(i) => i, + None => { + self.out.files.push(path.to_owned()); + self.out.files.len() - 1 + } + } as u16; + let (text, comments) = strip_comments(&text); + let lines: Vec<&str> = text.split('\n').map(|l| l.trim_end_matches('\r')).collect(); + let mut comments = comments.into_iter().peekable(); + let mut n = 0; + while n < lines.len() { + let first = n; + let mut line = lines[n].to_owned(); + while line.ends_with('\\') && n + 1 < lines.len() { + line.pop(); + n += 1; + line.push_str(lines[n]); + } + n += 1; + let origin = Origin { + file, + line: first as u32 + 1, + }; + self.line(&line, origin, path, depth); + for k in first + 1..n { + self.emit( + String::new(), + Origin { + file, + line: k as u32 + 1, + }, + ); + } + while let Some((_, comment)) = comments.next_if(|(at, _)| *at < n) { + if self.conditionals.active() { + self.out.comments.push((origin, comment)); + } + } + } + } + + fn line(&mut self, line: &str, origin: Origin, path: &str, depth: usize) { + let Some(after) = line.trim_start().strip_prefix('#') else { + let code = if self.conditionals.active() { + self.macros.define("__LINE__", origin.line.to_string()); + self.macros.expand(line) + } else { + String::new() + }; + return self.emit(code, origin); + }; + self.emit(String::new(), origin); + let Some((word, rest)) = split_directive(after) else { + return self.diagnose(origin, &format!("unknown directive `#{}`", after.trim())); + }; + match self.conditionals.apply(word, rest, &self.macros) { + Err(e) => return self.diagnose(origin, &e), + Ok(true) => return, + Ok(false) if !self.conditionals.active() => return, + Ok(false) => {} + } + match word { + "define" => self.define(rest, origin), + "undef" => self.macros.undef(rest), + "include" => { + let target = rest.trim_matches(|c| c == '"' || c == '<' || c == '>'); + match resolve(path, target) { + _ if depth + 1 >= MAX_DEPTH => self.diagnose( + origin, + &format!("`{target}`: includes nest more than {MAX_DEPTH} deep"), + ), + Some(file) => { + // The include line's own (empty) output line stays; the file follows. + self.file(&file, depth + 1, origin); + } + None => self.diagnose(origin, &format!("`{target}` is outside the pack")), + } + } + "version" if depth == 0 && self.out.version.is_none() => { + let mut words = rest.split_whitespace(); + let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110); + self.out.version = Some((number, words.next().map(str::to_owned))); + self.macros.define("__VERSION__", number.to_string()); + } + "extension" => self.out.extensions.push(rest.to_owned()), + "error" => self.diagnose(origin, &format!("#error {rest}")), + "version" | "pragma" | "line" | "" => {} + _ => self.diagnose(origin, &format!("unknown directive `#{word}`")), + } + } + + /// `#define NAME body` or `#define NAME(a, b) body` (no space before the `(`). + fn define(&mut self, rest: &str, origin: Origin) { + let len = rest + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + let (name, tail) = rest.split_at(len); + if name.is_empty() { + return self.diagnose(origin, "`#define` without a name"); + } + if let Some(params) = tail.strip_prefix('(') { + let Some((params, body)) = params.split_once(')') else { + return self.diagnose(origin, &format!("`#define {name}(` without `)`")); + }; + let params = params + .split(',') + .map(|p| p.trim().to_owned()) + .filter(|p| !p.is_empty()) + .collect(); + self.macros.define_function(name, params, body.trim()); + } else { + self.macros.define(name, tail.trim()); + } + } + + fn emit(&mut self, code: String, origin: Origin) { + self.code.push(code); + self.out.lines.push(origin); + } + + fn diagnose(&mut self, origin: Origin, message: &str) { + self.out.diagnostics.push(Diagnostic { + origin, + message: message.to_owned(), + }); + } +} + +/// An `#include` path: from the `shaders` folder with a leading `/`, else from the including +/// file's folder (spec 1.4). `None` when it climbs out of the pack. +fn resolve(from_file: &str, target: &str) -> Option { + match target.strip_prefix('/') { + Some(absolute) => normalize(absolute), + None => { + let dir = from_file.rsplit_once('/').map_or("", |(dir, _)| dir); + normalize(&format!("{dir}/{target}")) + } + } +} + +/// `text` with comments replaced by spaces (new lines kept, so line numbers hold), and the body of +/// each block comment with the index of the line it starts on. +fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) { + let mut out = String::with_capacity(text.len()); + let mut comments = Vec::new(); + let (mut rest, mut line) = (text, 0); + while let Some(at) = rest.find('/') { + let (before, from) = rest.split_at(at); + out.push_str(before); + line += before.matches('\n').count(); + if let Some(body) = from.strip_prefix("//") { + let end = body.find('\n').unwrap_or(body.len()); + rest = &body[end..]; + } else if let Some(body) = from.strip_prefix("/*") { + let end = body.find("*/").unwrap_or(body.len()); + let comment = &body[..end]; + comments.push((line, comment.to_owned())); + let newlines = comment.matches('\n').count(); + line += newlines; + out.push(' '); + out.extend(std::iter::repeat_n('\n', newlines)); + rest = body.get(end + 2..).unwrap_or(""); + } else { + out.push('/'); + rest = &from[1..]; + } + } + out.push_str(rest); + (out, comments) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + + fn run(files: &[(&str, &str)]) -> Preprocessed { + let files: Vec<(String, &str)> = files + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + preprocess(&pack, "program/final.fsh", &Macros::standard()) + } + + fn code_lines(p: &Preprocessed) -> Vec<(&str, String)> { + p.code + .lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| (l.trim(), p.locate(i))) + .collect() + } + + #[test] + fn includes_resolve_and_lines_map_back() { + let p = run(&[ + ( + "program/final.fsh", + "#version 330 compatibility\n#include \"/lib/a.glsl\"\nvoid main() {}\n", + ), + ( + "lib/a.glsl", + "#ifndef A\n#define A\n#include \"b.glsl\"\nfloat a;\n#endif\n", + ), + ("lib/b.glsl", "// b\nfloat b;\n#include \"a.glsl\"\n"), + ]); + assert_eq!(p.version, Some((330, Some("compatibility".into())))); + assert_eq!( + code_lines(&p), + [ + ("float b;", "lib/b.glsl:2".into()), + ("float a;", "lib/a.glsl:4".into()), + ("void main() {}", "program/final.fsh:3".into()), + ] + ); + assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics); + } + + #[test] + fn missing_and_runaway_includes_are_diagnosed_where_they_stand() { + let p = run(&[( + "program/final.fsh", + "#ifdef NOPE\n#include \"gone.glsl\"\n#endif\n#include \"gone.glsl\"\n#include \"final.fsh\"\n", + )]); + let messages: Vec = p + .diagnostics + .iter() + .map(|d| format!("{}: {}", d.origin.line, d.message)) + .collect(); + assert_eq!(messages[0], "4: can't find `program/gone.glsl`"); + assert!( + messages + .last() + .unwrap() + .ends_with("includes nest more than 10 deep") + ); + assert!( + run(&[("program/final.fsh", "#include \"../../x\"")]).diagnostics[0] + .message + .contains("outside") + ); + } + + #[test] + fn macros_expand_like_the_glsl_preprocessor() { + let p = run(&[( + "program/final.fsh", + "#define SQ(x) ((x) * (x))\n#define CAT(a, b) a ## b\n#define N 4\n#define \\\n LONG 1\n\ + float v = SQ(N + 1) + CAT(N, 2) + LONG;\nint l = __LINE__;\n#if MC_VERSION > 12100 && defined MC_RENDER_STAGE_SKY\nint ok;\n#else\nint bad;\n#endif\n", + )]); + let lines: Vec<&str> = code_lines(&p).into_iter().map(|(l, _)| l).collect(); + assert_eq!( + lines, + [ + "float v = ((4 + 1) * (4 + 1)) + N2 + 1;", + "int l = 7;", + "int ok;" + ] + ); + assert_eq!(p.lines.len(), p.code.split('\n').count()); + } + + #[test] + fn comments_extensions_and_errors() { + let p = run(&[( + "program/final.fsh", + "#version 120\n#extension GL_ARB_shader_texture_lod : enable\n/* DRAWBUFFERS:04 */\n#if 0\n/* RENDERTARGETS: 1 */\n#error dead\n#endif\n\ + /* multi\nline */ float x; // gone\n#error live\n#bogus\n", + )]); + assert_eq!(p.extensions, ["GL_ARB_shader_texture_lod : enable"]); + let comments: Vec<&str> = p.comments.iter().map(|(_, c)| c.trim()).collect(); + assert_eq!(comments, ["DRAWBUFFERS:04", "multi\nline"]); + assert_eq!(code_lines(&p), [("float x;", "program/final.fsh:9".into())]); + let messages: Vec<&str> = p.diagnostics.iter().map(|d| d.message.as_str()).collect(); + assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]); + } +} diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index e0ad38c25..c252df6e3 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,7 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Macros, Pack, Properties, discover}; +use horizon_shaders::{Lang, Macros, Pack, Properties, discover, preprocess}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -52,6 +52,40 @@ fn real_packs_read() { for n in &shaders.notes { println!(" shaders.properties:{}: {}", n.line, n.message); } + // Every stage file of the base and dimension folders through the preprocessor. + let start = Instant::now(); + let (mut stages, mut lines, mut diagnostics) = (0, 0, Vec::new()); + for dir in ["", "world0", "world-1", "world1"] { + for file in pack.list(dir) { + if [".vsh", ".fsh", ".gsh", ".csh"] + .iter() + .any(|e| file.ends_with(e)) + { + let path = if dir.is_empty() { + file + } else { + format!("{dir}/{file}") + }; + let p = preprocess(&pack, &path, ¯os); + stages += 1; + lines += p.lines.len(); + diagnostics.extend(p.diagnostics.iter().map(|d| { + let file = &p.files[d.origin.file as usize]; + format!("{file}:{}: {}", d.origin.line, d.message) + })); + } + } + } + println!( + " {stages} stage files preprocessed, {lines} lines out, {} diagnostics, {:.1} ms", + diagnostics.len(), + start.elapsed().as_secs_f64() * 1000.0 + ); + diagnostics.sort(); + diagnostics.dedup(); + for d in diagnostics.iter().take(8) { + println!(" {d}"); + } assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } From d742d725f4ac51a55d8748ef646522c7b574de1b Mon Sep 17 00:00:00 2001 From: socksprox Date: Sat, 24 Oct 2026 21:29:08 +0200 Subject: [PATCH 05/50] =?UTF-8?q?Client:=20the=20boss-overlay=20=E2=80=94?= =?UTF-8?q?=20BossEvent=20bars,=20wither=20darkening=20and=20world=20fog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clientbound boss_event packet (add/update/remove/style/progress), a BossOverlay that renders each bar, darkens the world under a wither, and suppresses the create-the-world fog — vanilla's BossHealthOverlay and GameRenderer behaviour on a real server. Co-Authored-By: Devin --- crates/horizon-client/src/boss_overlay.rs | 201 +++++++ crates/horizon-client/src/bossbar.rs | 108 ++-- crates/horizon-client/src/fog.rs | 29 + crates/horizon-client/src/game.rs | 15 +- crates/horizon-client/src/game/bossbars.rs | 553 ++++++++++++++++-- crates/horizon-client/src/game/dragon.rs | 46 +- crates/horizon-client/src/game/raids.rs | 74 ++- crates/horizon-client/src/game/remote.rs | 4 + crates/horizon-client/src/game/render_tick.rs | 5 +- crates/horizon-client/src/game/wither.rs | 41 -- crates/horizon-client/src/gfx.rs | 6 + crates/horizon-client/src/lib.rs | 1 + crates/horizon-protocol/src/packets/play.rs | 156 +++++ 13 files changed, 1055 insertions(+), 184 deletions(-) create mode 100644 crates/horizon-client/src/boss_overlay.rs diff --git a/crates/horizon-client/src/boss_overlay.rs b/crates/horizon-client/src/boss_overlay.rs new file mode 100644 index 000000000..e51a59668 --- /dev/null +++ b/crates/horizon-client/src/boss_overlay.rs @@ -0,0 +1,201 @@ +//! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is +//! showing, in the order their add packets arrived. +use crate::{hud_overlays::BossBar, text::Text}; + +/// `LerpingBossEvent.LERP_MILLISECONDS`. +const LERP_MS: u128 = 100; + +/// `Util.getMillis`. +pub fn now_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// One `LerpingBossEvent`. +#[derive(Clone, Debug)] +struct Event { + id: u128, + name: Text, + /// The progress the lerp started from (`progress`). + progress: f32, + /// `targetPercent`. + target: f32, + /// `setTime`. + set_at: u128, + color: u8, + overlay: u8, + darken: bool, + music: bool, + fog: bool, +} +impl Event { + /// `LerpingBossEvent.getProgress`. + fn displayed(&self, now: u128) -> f32 { + let t = ((now.saturating_sub(self.set_at)) as f32 / LERP_MS as f32).clamp(0.0, 1.0); + self.progress + (self.target - self.progress) * t + } + /// `LerpingBossEvent.setProgress`: the bar starts the next lerp from + /// whatever it is showing now. + fn set_progress(&mut self, progress: f32, now: u128) { + self.progress = self.displayed(now); + self.target = progress; + self.set_at = now; + } +} + +/// `BossHealthOverlay`: the events, plus `GameRenderer`'s world darkening. +#[derive(Clone, Debug, Default)] +pub struct BossOverlay { + events: Vec, + /// `bossOverlayWorldDarkening` and the previous tick's value. + darken: f32, + darken_o: f32, +} +impl BossOverlay { + pub fn reset(&mut self) { + *self = Self::default(); + } + /// `Handler.add`. An id already shown keeps its place. + pub fn add( + &mut self, + id: u128, + name: Text, + progress: f32, + color: u8, + overlay: u8, + darken: bool, + music: bool, + fog: bool, + now: u128, + ) { + let event = Event { + id, + name, + progress, + target: progress, + set_at: now, + color: color.min(6), + overlay: overlay.min(4), + darken, + music, + fog, + }; + if let Some(slot) = self.events.iter_mut().find(|e| e.id == id) { + *slot = event; + } else { + self.events.push(event); + } + } + pub fn remove(&mut self, id: u128) { + self.events.retain(|e| e.id != id); + } + pub fn set_progress(&mut self, id: u128, progress: f32, now: u128) { + if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { + e.set_progress(progress, now); + } + } + pub fn set_name(&mut self, id: u128, name: Text) { + if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { + e.name = name; + } + } + pub fn set_style(&mut self, id: u128, color: u8, overlay: u8) { + if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { + e.color = color.min(6); + e.overlay = overlay.min(4); + } + } + pub fn set_properties(&mut self, id: u128, darken: bool, music: bool, fog: bool) { + if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { + e.darken = darken; + e.music = music; + e.fog = fog; + } + } + /// `GameRenderer.tick` while the level runs. + pub fn tick_darken(&mut self, running: bool) { + if !running { + return; + } + self.darken_o = self.darken; + if self.should_darken() { + self.darken = (self.darken + 0.05).min(1.0); + } else if self.darken > 0.0 { + self.darken = (self.darken - 0.0125).max(0.0); + } + } + /// `GameRenderer.bossOverlayWorldDarkening`. + pub fn darken_amount(&self, partial: f32) -> f32 { + let t = partial.clamp(0.0, 1.0); + self.darken_o + (self.darken - self.darken_o) * t + } + pub fn should_darken(&self) -> bool { + self.events.iter().any(|e| e.darken) + } + pub fn should_play_music(&self) -> bool { + self.events.iter().any(|e| e.music) + } + pub fn should_create_world_fog(&self) -> bool { + self.events.iter().any(|e| e.fog) + } + /// The bars `extractRenderState` walks, with the lerped progress. + pub fn bars(&self, now: u128) -> Vec { + self.events + .iter() + .map(|e| BossBar { + name: e.name.clone(), + progress: e.displayed(now), + color: e.color as usize, + overlay: e.overlay as usize, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_lerps_over_a_tenth_of_a_second() { + let mut overlay = BossOverlay::default(); + overlay.add(1, Text::literal("W"), 0.0, 5, 0, true, false, false, 0); + overlay.set_progress(1, 1.0, 0); + assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5); + assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5); + // A new target starts from the value on screen. + overlay.set_progress(1, 0.0, 50); + assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5); + } + + #[test] + fn darken_ramps_and_fades() { + let mut overlay = BossOverlay::default(); + overlay.add(1, Text::literal("W"), 1.0, 5, 0, true, false, false, 0); + overlay.tick_darken(true); + assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5); + overlay.remove(1); + overlay.tick_darken(true); + assert!((overlay.darken - 0.0375).abs() < 1.0e-5); + assert!(!overlay.should_play_music()); + } + + #[test] + fn bars_keep_arrival_order() { + let mut overlay = BossOverlay::default(); + overlay.add(2, Text::literal("b"), 1.0, 0, 0, false, true, true, 0); + overlay.add(1, Text::literal("a"), 1.0, 0, 0, false, false, false, 0); + overlay.add(2, Text::literal("c"), 0.5, 1, 2, false, true, true, 0); + let names: Vec<_> = overlay + .bars(0) + .into_iter() + .map(|b| b.name.plain()) + .collect(); + assert_eq!(names, ["c", "a"]); + assert!(overlay.should_create_world_fog()); + overlay.remove(2); + assert!(!overlay.should_create_world_fog()); + } +} diff --git a/crates/horizon-client/src/bossbar.rs b/crates/horizon-client/src/bossbar.rs index eab31a5e5..2ecdb25e9 100644 --- a/crates/horizon-client/src/bossbar.rs +++ b/crates/horizon-client/src/bossbar.rs @@ -14,40 +14,64 @@ pub const STYLES: [&str; 5] = [ "notched_12", "notched_20", ]; -/// `BossBarColor.getFormatting`. +/// `BossBarColor.getFormatting`. Purple is dark blue in 26.3. pub fn color_rgb(color: &str) -> u32 { let name = match color { "pink" => "red", "red" => "dark_red", - "purple" => "dark_purple", + "purple" => "dark_blue", other => other, }; text::named_color(name).unwrap_or(text::WHITE) } +/// A version-4 shaped id from a resource id. Saved boss events do not keep +/// their UUID (`CustomBossEvents` mints a new one on load). +pub fn stable_uuid(id: &str) -> u128 { + let mut h = 0xcbf29ce484222325u64; + for b in id.as_bytes() { + h ^= u64::from(*b); + h = h.wrapping_mul(0x100000001b3); + } + let most = (h as i64) & -61441 | 16384; + let least = (h.rotate_left(17) as i64) & 4_611_686_018_427_387_903 | i64::MIN; + ((most as u64 as u128) << 64) | (least as u64 as u128) +} + #[derive(Clone, Debug, PartialEq)] pub struct BossBar { pub id: String, + /// The `ServerBossEvent` UUID. Not saved. + pub uuid: u128, pub name: Comp, pub color: String, pub style: String, pub value: i32, pub max: i32, + /// `ServerBossEvent.visible`. A new bar is visible; nobody sees it until + /// a player is added. pub visible: bool, - /// Whether the (single) local player is in the bar's player set. - pub player: bool, + pub darken: bool, + pub music: bool, + pub fog: bool, + /// `CustomBossEvent.players`, the UUIDs told about the bar. + pub players: Vec<[i32; 4]>, } impl BossBar { pub fn new(id: &str, name: Comp) -> Self { Self { id: id.to_owned(), + uuid: stable_uuid(id), name, color: "white".into(), style: "progress".into(), value: 0, max: 100, visible: true, - player: false, + darken: false, + music: false, + fog: false, + players: Vec::new(), } } /// `CustomBossEvent.getDisplayName`. @@ -91,31 +115,34 @@ impl BossBars { pub fn ids(&self) -> Vec { self.bars.iter().map(|b| b.id.clone()).collect() } - /// The saved compound; the player's UUID stands for "the local player". - pub fn pack(&self, player: Option<[i32; 4]>) -> Tag { + /// The saved compound (`CustomBossEvent.Packed`). + pub fn pack(&self) -> Tag { let mut c = Compound::new(); for b in &self.bars { - let mut e = vec![ - ("Name", json_to_tag(&b.name)), - ("Visible", Tag::Byte(i8::from(b.visible))), - ("Value", Tag::Int(b.value)), - ("Max", Tag::Int(b.max)), - ("Color", Tag::String(b.color.as_str().into())), - ("Overlay", Tag::String(b.style.as_str().into())), - ("DarkenScreen", Tag::Byte(0)), - ("PlayBossMusic", Tag::Byte(0)), - ("CreateWorldFog", Tag::Byte(0)), - ]; - let players = match (b.player, player) { - (true, Some(u)) => vec![Tag::IntArray(u.to_vec())], - _ => Vec::new(), - }; - e.push(("Players", Tag::List(players))); - c.insert(b.id.as_str().into(), Tag::compound(e)); + let players = b + .players + .iter() + .map(|u| Tag::IntArray(u.to_vec())) + .collect(); + c.insert( + b.id.as_str().into(), + Tag::compound([ + ("Name", json_to_tag(&b.name)), + ("Visible", Tag::Byte(i8::from(b.visible))), + ("Value", Tag::Int(b.value)), + ("Max", Tag::Int(b.max)), + ("Color", Tag::String(b.color.as_str().into())), + ("Overlay", Tag::String(b.style.as_str().into())), + ("DarkenScreen", Tag::Byte(i8::from(b.darken))), + ("PlayBossMusic", Tag::Byte(i8::from(b.music))), + ("CreateWorldFog", Tag::Byte(i8::from(b.fog))), + ("Players", Tag::List(players)), + ]), + ); } Tag::Compound(c) } - pub fn unpack(data: &Tag, player: Option<[i32; 4]>) -> Self { + pub fn unpack(data: &Tag) -> Self { let mut bars = Vec::new(); if let Tag::Compound(c) = data { for (id, e) in c { @@ -130,21 +157,30 @@ impl BossBars { Some(Tag::Byte(v)) => i32::from(*v), _ => d, }; - let in_players = match (get("Players"), player) { - (Some(Tag::List(l)), Some(u)) => l + let players = match get("Players") { + Some(Tag::List(l)) => l .iter() - .any(|t| matches!(t, Tag::IntArray(a) if a.as_slice() == u)), - _ => false, + .filter_map(|t| match t { + Tag::IntArray(a) if a.len() == 4 => Some([a[0], a[1], a[2], a[3]]), + _ => None, + }) + .collect(), + _ => Vec::new(), }; + let id = id.to_string_lossy(); bars.push(BossBar { - id: id.to_string_lossy(), + uuid: stable_uuid(&id), + id, name: get("Name").map_or_else(|| literal(""), tag_to_json), color: text("Color", "white"), style: text("Overlay", "progress"), value: num("Value", 0), max: num("Max", 100), visible: num("Visible", 0) != 0, - player: in_players, + darken: num("DarkenScreen", 0) != 0, + music: num("PlayBossMusic", 0) != 0, + fog: num("CreateWorldFog", 0) != 0, + players, }); } } @@ -167,17 +203,15 @@ mod tests { bar.value = 25; bar.max = 50; bar.visible = true; - bar.player = true; - let uuid = Some([1, 2, 3, 4]); - let back = BossBars::unpack(&b.pack(uuid), uuid); + bar.darken = true; + bar.players = vec![[1, 2, 3, 4]]; + let back = BossBars::unpack(&b.pack()); assert_eq!(back, b); - // Another player's UUID does not include the local player. - assert!(!BossBars::unpack(&b.pack(uuid), Some([9, 9, 9, 9])).bars[0].player); let bar = b.get("test:x").unwrap(); assert_eq!(bar.progress(), 0.5); assert_eq!(bar.display_name().plain(), "chat.square_brackets"); assert_eq!(color_rgb("pink"), text::RED); - assert_eq!(color_rgb("purple"), 0xAA00AA); + assert_eq!(color_rgb("purple"), text::named_color("dark_blue").unwrap()); b.remove("test:x"); assert!(b.bars.is_empty()); } diff --git a/crates/horizon-client/src/fog.rs b/crates/horizon-client/src/fog.rs index 942bae556..11ffda151 100644 --- a/crates/horizon-client/src/fog.rs +++ b/crates/horizon-client/src/fog.rs @@ -198,6 +198,8 @@ pub struct FogInputs<'a> { pub blindness: Option<(i32, bool)>, /// The darkness effect's blend factor, while the effect is on. pub darkness: Option, + /// `GameRenderer.bossOverlayWorldDarkening`: a wither's bar dims the fog. + pub darken_world: f32, /// `BossHealthOverlay.shouldCreateWorldFog`: a boss bar with /// `createWorldFog` is shown (the Ender Dragon's). pub boss_world_fog: bool, @@ -263,6 +265,13 @@ pub fn setup_fog(tables: &FogTables, state: &FogState, input: &FogInputs) -> Fog let brightness = (1.0 - darkness) * (1.0 - darkness); color = color.map(|c| c * brightness); } + // FogRenderer.computeFogColor: a boss bar's world darkening. + if input.darken_world > 0.0 { + let t = input.darken_world; + color[0] += (color[0] * 0.7 - color[0]) * t; + color[1] += (color[1] * 0.6 - color[1]) * t; + color[2] += (color[2] * 0.6 - color[2]) * t; + } let brighten = if eye_in_water { water_vision } else { 0.0 }; if color.iter().all(|&c| c != 0.0) { let scale = 1.0 / color[0].max(color[1]).max(color[2]); @@ -394,6 +403,7 @@ mod tests { skybox: true, blindness: None, darkness: None, + darken_world: 0.0, boss_world_fog: false, } } @@ -422,6 +432,25 @@ mod tests { assert_eq!(water.environmental_start, -8.0); } #[test] + fn boss_darkening_dims_fog_color() { + // FogRenderer.computeFogColor: lerp toward 70/60/60% of each channel. + let sky = Sky { + fog_color: [1.0, 1.0, 1.0], + sky_color: [1.0, 1.0, 1.0], + ..Sky::default() + }; + let tables = FogTables { + water_fog_end: vec![1.0], + }; + let mut input = inputs(&sky); + input.skybox = false; + input.darken_world = 1.0; + let fog = setup_fog(&tables, &FogState::default(), &input); + assert!((fog.color[0] - 0.7).abs() < 1.0e-5); + assert!((fog.color[1] - 0.6).abs() < 1.0e-5); + assert!((fog.color[2] - 0.6).abs() < 1.0e-5); + } + #[test] fn void_darkness_starts_32_above_the_floor() { let sky = Sky { fog_end: 1024.0, diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 533973805..f0765e0f6 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -1562,6 +1562,8 @@ struct Game<'a> { score: scoreboard::ScoreState, fns: functions::FnState, bosses: bossbars::BossState, + /// `Hud.bossOverlay`: the bars this window is showing. + boss_overlay: horizon_client::boss_overlay::BossOverlay, exec: commands::execute::ExecState, rand_sequences: horizon_client::random_sequences::RandomSequences, border: horizon_client::worldborder::WorldBorder, @@ -4060,6 +4062,8 @@ impl Game<'_> { self.track_experience_display(); // `/tick freeze` stops the world but not the player. let running = self.clock.world_runs(); + // GameRenderer.tick: a wither's bar darkens the world, then fades. + self.boss_overlay.tick_darken(running); if running { self.world.game_time += 1; if self.ui.rules.bool("advance_weather") { @@ -4435,6 +4439,8 @@ impl Game<'_> { }); horizon_client::spans::time("tick_particles", || self.tick_particles()); } + // ServerBossEvent broadcasts, after this level's entities have ticked. + self.publish_boss_bars(); // Minecraft.handleKeybinds: held use repeats only while no item is in // use (a raised shield or a drawn bow places and uses nothing). if self.right @@ -7952,6 +7958,8 @@ impl Game<'_> { self.session_lock = None; horizon_client::crash::clear_detail("World"); self.debug_charts.reset(); + self.boss_overlay.reset(); + self.bosses = Default::default(); self.streaming.paused = true; self.world = World::new(self.res.facts().air, 0); self.player = None; @@ -10095,7 +10103,7 @@ impl Game<'_> { if !self.settings.horizon.fog && !underwater && self.darkness_fog().is_none() - && !self.dragon_bar_shown() + && !self.boss_overlay.should_create_world_fog() && self.potions.effects.get("blindness").is_none() && fog.environmental_end >= (self.streaming.radius * 16) as f32 { @@ -10166,6 +10174,9 @@ impl Game<'_> { darkness: self.darkness_lighting(alpha as f32), night_vision: self.night_vision_intensity(), block_flicker: self.fog.block_light_flicker, + boss_darken: self + .boss_overlay + .darken_amount(self.clock.fraction() as f32), menu_blur: self.menu_blur(), }; let result = match self.renderer.as_mut() { @@ -11953,6 +11964,7 @@ impl<'a> Game<'a> { score: Default::default(), fns: Default::default(), bosses: Default::default(), + boss_overlay: Default::default(), exec: Default::default(), rand_sequences: Default::default(), border: Default::default(), @@ -12680,6 +12692,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { darkness: (0.0, 0.0), night_vision: 0.0, block_flicker: 0.0, + boss_darken: 0.0, menu_blur: None, } } diff --git a/crates/horizon-client/src/game/bossbars.rs b/crates/horizon-client/src/game/bossbars.rs index ec7b72632..85638da4a 100644 --- a/crates/horizon-client/src/game/bossbars.rs +++ b/crates/horizon-client/src/game/bossbars.rs @@ -1,22 +1,29 @@ //! `/bossbar` (BossBarCommands) and drawing the custom boss bars. use super::{ - Game, - commands::{R, err}, + Dim, Game, + commands::{R, Target, err}, }; use horizon_client::{ + boss_overlay, bossbar::{self, BossBars}, command::{Arg, Context}, - hud_overlays, save, - scoreboard::{self as sb, Comp, comp_text}, + mobs::Species, + save, + scoreboard::{self as sb, Comp, comp_text, json_to_tag, tag_to_json}, scoreboard_cmd::format_list, text::Text, }; +use horizon_core::random::RandomSource; +use horizon_protocol::packets::play::{self, BossAction, Clientbound as C}; use horizon_world::nbt::Tag; +use std::collections::{HashMap, HashSet}; #[derive(Default)] pub(super) struct BossState { pub bars: BossBars, loaded: bool, + /// What each viewer was last sent, so a change becomes one packet. + sent: HashMap>, } fn num(n: impl std::fmt::Display) -> Text { @@ -36,7 +43,7 @@ impl Game<'_> { if let Some(root) = save::read_dat(&self.bossbar_path()) && let Some(data) = root.get("data") { - self.bosses.bars = BossBars::unpack(data, self.level.player_uuid); + self.bosses.bars = BossBars::unpack(data); } self.ui.registries.bossbars = self.bosses.bars.ids(); } @@ -50,7 +57,7 @@ impl Game<'_> { let _ = save::write_dat( &self.bossbar_path(), Tag::compound([ - ("data", self.bosses.bars.pack(self.level.player_uuid)), + ("data", self.bosses.bars.pack()), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]), ); @@ -68,6 +75,7 @@ impl Game<'_> { b.value = b.value.min(b.max); } } + self.publish_boss_bars(); } fn bar_id(ctx: &Context) -> String { match ctx.get("id") { @@ -86,6 +94,7 @@ impl Game<'_> { self.load_bossbars(); let result = self.bossbar_command(id, ctx); self.ui.registries.bossbars = self.bosses.bars.ids(); + self.publish_boss_bars(); result } fn bossbar_command(&mut self, id: &str, ctx: &Context) -> R { @@ -97,6 +106,10 @@ impl Game<'_> { } let name = self.component_json(ctx, "name")?; self.bosses.bars.add(&bar, name); + if let Some(b) = self.bosses.bars.get_mut(&bar) { + // CustomBossEvents.create: Mth.createInsecureUUID(level.random). + b.uuid = insecure_uuid(&mut self.world.random); + } let shown = self .bosses .bars @@ -173,19 +186,20 @@ impl Game<'_> { i32::from(b.visible) } _ => { - if b.player { - let name = self.target_name(super::commands::Target::Player); - self.feedback(Text::translate( - "commands.bossbar.get.players.some", - &[s, num(1), format_list(vec![name])], - )); - } else { + let names = self.bossbar_player_names(&b.players); + let n = names.len(); + if n == 0 { self.feedback(Text::translate( "commands.bossbar.get.players.none", &[s], )); + } else { + self.feedback(Text::translate( + "commands.bossbar.get.players.some", + &[s, num(n), format_list(names)], + )); } - i32::from(b.player) + n as i32 } }) } @@ -295,31 +309,46 @@ impl Game<'_> { Ok(0) } _ => { - // players [] - let include = if ctx.get("targets").is_some() { - !self.entities(ctx, "targets", true)?.is_empty() + // players []: replace the set. No argument clears it. + let targets = if ctx.get("targets").is_some() { + self.entities(ctx, "targets", true)? } else { - false + Vec::new() }; + let mut uuids = Vec::new(); + let mut names = Vec::new(); + for t in &targets { + let Some(uuid) = self.player_target_uuid(*t) else { + continue; + }; + if !uuids.contains(&uuid) { + uuids.push(uuid); + names.push(self.target_name(*t)); + } + } let b = self.bosses.bars.get_mut(bar).expect("checked"); - if b.player == include { + let mut had = b.players.clone(); + had.sort(); + let mut next = uuids.clone(); + next.sort(); + if had == next { return Err(err("commands.bossbar.set.players.unchanged", &[])); } - b.player = include; + b.players = uuids; + let n = b.players.len(); let s = shown(self); - if include { - let name = self.target_name(super::commands::Target::Player); - self.feedback(Text::translate( - "commands.bossbar.set.players.success.some", - &[s, num(1), format_list(vec![name])], - )); - } else { + if n == 0 { self.feedback(Text::translate( "commands.bossbar.set.players.success.none", &[s], )); + } else { + self.feedback(Text::translate( + "commands.bossbar.set.players.success.some", + &[s, num(targets.len()), format_list(names)], + )); } - Ok(i32::from(include)) + Ok(n as i32) } } } @@ -333,29 +362,455 @@ impl Game<'_> { Ok(self.resolve_component(value)) } - // ---- drawing ------------------------------------------------------------------ + /// Names of the online players in a boss bar's set, in join order. + fn bossbar_player_names(&self, players: &[[i32; 4]]) -> Vec { + self.known_boss_players() + .into_iter() + .filter(|(uuid, _)| players.contains(uuid)) + .map(|(_, name)| name) + .collect() + } + /// The players who can be told about a bar: the one in place, then guests. + fn known_boss_players(&self) -> Vec<([i32; 4], Text)> { + let mut out = Vec::new(); + if self.player.is_some() { + out.push((self.acting_uuid(), self.target_name(Target::Player))); + } + if let Some(host) = &self.lan { + for g in host.hub.guests.iter().filter(|g| g.placed) { + let ints = horizon_client::lan::playerdata::uuid_ints(g.uuid); + if out.iter().any(|(uuid, _)| *uuid == ints) { + continue; + } + out.push((ints, Text::literal(g.name.clone()))); + } + } + out + } + fn player_target_uuid(&self, target: Target) -> Option<[i32; 4]> { + match target { + Target::Player => Some(self.acting_uuid()), + Target::Guest(conn) => self.lan.as_ref().and_then(|h| { + h.hub + .guests + .iter() + .find(|g| g.conn == conn) + .map(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid)) + }), + _ => None, + } + } - /// The visible custom bars (`/bossbar`), after the built-in ones. - pub(super) fn custom_bossbars(&self, bars: &mut Vec) { - for b in self - .bosses - .bars - .bars - .iter() - .filter(|b| b.visible && b.player) - { - bars.push(hud_overlays::BossBar { - name: comp_text(&b.name), - progress: b.progress(), - color: bossbar::COLORS - .iter() - .position(|c| *c == b.color) - .unwrap_or(0), - overlay: bossbar::STYLES - .iter() - .position(|s| *s == b.style) - .unwrap_or(0), + // ---- ServerBossEvent delivery ------------------------------------------------ + + /// Tell every viewer the boss events they should be seeing. The window + /// that simulates the world applies them to its own overlay; everyone + /// else gets `ClientboundBossEventPacket`s. + pub(super) fn publish_boss_bars(&mut self) { + if self.remote.is_some() { + return; + } + self.load_bossbars(); + let now = boss_overlay::now_millis(); + let viewers = self.boss_viewers(); + let live: HashSet = viewers.iter().map(|v| v.uuid).collect(); + self.bosses.sent.retain(|id, _| live.contains(id)); + let mut packets = Vec::new(); + for viewer in &viewers { + let desired = self.boss_events_for(viewer); + let changes = self.diff_boss_events(viewer, &desired); + if viewer.local { + apply_local(&mut self.boss_overlay, &changes, now); + } else if let Some(conn) = viewer.conn { + for change in changes { + packets.push((conn, boss_packet(change))); + } + } + } + if packets.is_empty() { + return; + } + let Some(host) = self.lan.as_ref() else { + return; + }; + for (conn, packet) in &packets { + host.hub.send_to(*conn, packet); + } + } + fn boss_viewers(&self) -> Vec { + let mut out = Vec::new(); + // While `as_guest` has swapped a guest in, the host's own position + // is not in place. Their overlay updates once the tick puts it back. + let local = + self.serving.is_none() && self.options.dedicated.is_none() && self.lan_acting.is_none(); + if local && let Some(p) = &self.player { + let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid()); + out.push(Viewer { + uuid: ints_to_uuid(ints), + ints, + at: [p.position.x, p.position.y, p.position.z], + alive: !p.health.dead_or_dying(), + here: true, + local: true, + conn: None, + view_chunks: self.settings.render_distance, }); } + if let Some(host) = &self.lan { + for g in host.hub.guests.iter().filter(|g| g.placed) { + if out.iter().any(|v| v.uuid == g.uuid) { + continue; + } + out.push(Viewer { + uuid: g.uuid, + ints: horizon_client::lan::playerdata::uuid_ints(g.uuid), + at: g.position, + alive: !g.dead, + here: g.dimension == self.dim.id(), + local: false, + conn: Some(g.conn), + view_chunks: g.view_distance, + }); + } + } + out + } + /// Dragon, withers, the raid and custom bars, in that order. + fn boss_events_for(&self, viewer: &Viewer) -> Vec { + let mut out = Vec::new(); + if viewer.here + && let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive) + { + out.push(Offer { + id: bossbar::stable_uuid("minecraft:ender_dragon"), + name: translate_tag("entity.minecraft.ender_dragon"), + progress, + color: 0, + overlay: 0, + darken: false, + music: true, + fog: true, + scope: Scope::Level(self.dim), + }); + } + if viewer.here { + let range = f64::from(10.min(viewer.view_chunks.max(0)) * 16); + for m in self + .mobs + .iter() + .filter(|m| m.species() == Species::Wither && m.alive() && !m.removed) + { + let (dx, dz) = (m.position.x - viewer.at[0], m.position.z - viewer.at[2]); + if dx * dx + dz * dz > range * range { + continue; + } + let progress = if m.extra.counter > 0 { + 1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32 + } else { + (m.health / m.attrs.max_health).clamp(0.0, 1.0) + }; + let name = if let Some(text) = &m.custom_name_text { + json_to_tag(&text.component()) + } else if let Some(name) = &m.custom_name { + Tag::String(name.as_str().into()) + } else { + translate_tag("entity.minecraft.wither") + }; + out.push(Offer { + id: super::lan::entity_uuid(m.id), + name, + progress, + color: 5, + overlay: 0, + darken: true, + music: false, + fog: false, + scope: Scope::Level(self.dim), + }); + } + } + if viewer.here + && let Some((id, name, progress)) = self.raid_boss_at(viewer.at, viewer.alive) + { + out.push(Offer { + id: bossbar::stable_uuid(&format!("raid:{id}")), + name, + progress, + color: 2, + overlay: 2, + darken: false, + music: false, + fog: false, + scope: Scope::Level(self.dim), + }); + } + for b in &self.bosses.bars.bars { + if !b.visible || !b.players.contains(&viewer.ints) { + continue; + } + out.push(Offer { + id: b.uuid, + name: json_to_tag(&b.name), + progress: b.progress(), + color: color_index(&b.color) as u8, + overlay: style_index(&b.style) as u8, + darken: b.darken, + music: b.music, + fog: b.fog, + scope: Scope::Global, + }); + } + out + } + fn diff_boss_events(&mut self, viewer: &Viewer, desired: &[Offer]) -> Vec { + let full = viewer.local; + let dim = self.dim; + let sent = self.bosses.sent.entry(viewer.uuid).or_default(); + let mut changes = Vec::new(); + let gone: Vec = sent + .iter() + .filter(|(id, prev)| { + let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim); + owned && !desired.iter().any(|e| e.id == **id) + }) + .map(|(id, _)| *id) + .collect(); + for id in gone { + sent.remove(&id); + changes.push(Change::Remove(id)); + } + for event in desired { + let snap = event.snap(); + match sent.get(&event.id).map(|s| s.snap.clone()) { + None => { + sent.insert( + event.id, + Sent { + snap, + scope: event.scope, + }, + ); + changes.push(Change::Add(event.clone())); + } + Some(prev) => { + if prev.progress != snap.progress { + changes.push(Change::Progress(event.id, snap.progress)); + } + if prev.name != snap.name { + changes.push(Change::Name(event.id, snap.name.clone())); + } + if prev.color != snap.color || prev.overlay != snap.overlay { + changes.push(Change::Style(event.id, snap.color, snap.overlay)); + } + if prev.darken != snap.darken + || prev.music != snap.music + || prev.fog != snap.fog + { + changes.push(Change::Properties( + event.id, + snap.darken, + snap.music, + snap.fog, + )); + } + sent.insert( + event.id, + Sent { + snap, + scope: event.scope, + }, + ); + } + } + } + changes + } + /// `ClientPacketListener.handleBossUpdate`. + pub(super) fn apply_boss_event(&mut self, packet: play::BossEvent) { + let now = boss_overlay::now_millis(); + let ord = |v: i32| v.clamp(0, 255) as u8; + match packet.action { + BossAction::Add { + name, + progress, + color, + overlay, + darken, + music, + fog, + } => self.boss_overlay.add( + packet.id, + component_text(&name), + progress, + ord(color), + ord(overlay), + darken, + music, + fog, + now, + ), + BossAction::Remove => self.boss_overlay.remove(packet.id), + BossAction::UpdateProgress { progress } => { + self.boss_overlay.set_progress(packet.id, progress, now) + } + BossAction::UpdateName { name } => { + self.boss_overlay.set_name(packet.id, component_text(&name)) + } + BossAction::UpdateStyle { color, overlay } => { + self.boss_overlay + .set_style(packet.id, ord(color), ord(overlay)) + } + BossAction::UpdateProperties { darken, music, fog } => self + .boss_overlay + .set_properties(packet.id, darken, music, fog), + } } } + +/// `Mth.createInsecureUUID`. +fn insecure_uuid(random: &mut impl RandomSource) -> u128 { + let most = random.next_long() & -61441 | 16384; + let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN; + ((most as u64 as u128) << 64) | (least as u64 as u128) +} +fn ints_to_uuid(ints: [i32; 4]) -> u128 { + ints.iter() + .fold(0u128, |acc, &i| (acc << 32) | u128::from(i as u32)) +} +fn translate_tag(key: &str) -> Tag { + Tag::compound([("translate", Tag::String(key.into()))]) +} +fn color_index(color: &str) -> usize { + bossbar::COLORS + .iter() + .position(|c| *c == color) + .unwrap_or(6) +} +fn style_index(style: &str) -> usize { + bossbar::STYLES + .iter() + .position(|s| *s == style) + .unwrap_or(0) +} +fn component_text(tag: &Tag) -> Text { + comp_text(&tag_to_json(tag)) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Scope { + Global, + Level(Dim), +} +#[derive(Clone)] +struct Snap { + name: Tag, + progress: f32, + color: u8, + overlay: u8, + darken: bool, + music: bool, + fog: bool, +} +struct Sent { + snap: Snap, + scope: Scope, +} +struct Viewer { + uuid: u128, + ints: [i32; 4], + at: [f64; 3], + alive: bool, + here: bool, + local: bool, + conn: Option, + view_chunks: i32, +} +#[derive(Clone)] +struct Offer { + id: u128, + name: Tag, + progress: f32, + color: u8, + overlay: u8, + darken: bool, + music: bool, + fog: bool, + scope: Scope, +} +impl Offer { + fn snap(&self) -> Snap { + Snap { + name: self.name.clone(), + progress: self.progress, + color: self.color, + overlay: self.overlay, + darken: self.darken, + music: self.music, + fog: self.fog, + } + } +} +enum Change { + Add(Offer), + Remove(u128), + Progress(u128, f32), + Name(u128, Tag), + Style(u128, u8, u8), + Properties(u128, bool, bool, bool), +} +fn apply_local(overlay: &mut boss_overlay::BossOverlay, changes: &[Change], now: u128) { + for change in changes { + match change { + Change::Add(e) => overlay.add( + e.id, + component_text(&e.name), + e.progress, + e.color, + e.overlay, + e.darken, + e.music, + e.fog, + now, + ), + Change::Remove(id) => overlay.remove(*id), + Change::Progress(id, progress) => overlay.set_progress(*id, *progress, now), + Change::Name(id, name) => overlay.set_name(*id, component_text(name)), + Change::Style(id, color, overlay_style) => { + overlay.set_style(*id, *color, *overlay_style) + } + Change::Properties(id, darken, music, fog) => { + overlay.set_properties(*id, *darken, *music, *fog) + } + } + } +} +fn boss_packet(change: Change) -> C { + let (id, action) = match change { + Change::Add(e) => ( + e.id, + BossAction::Add { + name: e.name, + progress: e.progress, + color: i32::from(e.color), + overlay: i32::from(e.overlay), + darken: e.darken, + music: e.music, + fog: e.fog, + }, + ), + Change::Remove(id) => (id, BossAction::Remove), + Change::Progress(id, progress) => (id, BossAction::UpdateProgress { progress }), + Change::Name(id, name) => (id, BossAction::UpdateName { name }), + Change::Style(id, color, overlay) => ( + id, + BossAction::UpdateStyle { + color: i32::from(color), + overlay: i32::from(overlay), + }, + ), + Change::Properties(id, darken, music, fog) => { + (id, BossAction::UpdateProperties { darken, music, fog }) + } + }; + C::BossEvent(play::BossEvent { id, action }) +} diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index c6f5d419b..54b260c67 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -12,7 +12,6 @@ use horizon_client::{ hud_layout, hud_overlays, mesher::Vertex, particles::{ParticleOptions, ParticleType}, - text::Text, }; use horizon_core::{ geometry::{Aabb, Vec3}, @@ -2157,23 +2156,12 @@ impl Game<'_> { }); } } - /// `BossHealthOverlay`: the dragon's pink bar, then the withers', the - /// raids' and the custom bars, in that order. + /// `BossHealthOverlay.extractRenderState`: whatever `publish_boss_bars` + /// (or a server's packets) put on this client's overlay. pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) { - let mut bars = Vec::new(); - if let Some(d) = self.dragon.as_ref().filter(|_| self.dragon_bar_shown()) { - bars.push(hud_overlays::BossBar { - name: Text::literal( - self.translate("entity.minecraft.ender_dragon", "Ender Dragon"), - ), - progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0), - color: 0, - overlay: 0, - }); - } - self.wither_bars(&mut bars); - self.raid_bars(&mut bars); - self.custom_bossbars(&mut bars); + let bars = self + .boss_overlay + .bars(horizon_client::boss_overlay::now_millis()); let draws = hud_overlays::boss_overlay( batch.width as i32, batch.height as i32, @@ -2182,14 +2170,26 @@ impl Game<'_> { ); hud_layout::paint(batch, &self.gui.atlas, &draws); } - /// The dragon fight's boss bar shows (`EnderDragonFight.dragonEvent`, - /// with `setCreateWorldFog`). - pub(super) fn dragon_bar_shown(&self) -> bool { - self.dim == Dim::End && self.dragon.is_some() + /// The dragon fight's bar for a player at `at` + /// (`EnderDragonFight.dragonEvent`, visible while the fight is not won + /// and the player is alive within 192 blocks of (0, 128, 0)). + pub(super) fn dragon_boss_progress(&self, at: [f64; 3], alive: bool) -> Option { + if self.dim != Dim::End || !alive || !self.fight.loaded || self.fight.killed { + return None; + } + let near = at[0].powi(2) + (at[1] - 128.0).powi(2) + at[2].powi(2) <= 192.0 * 192.0; + if !near { + return None; + } + Some( + self.dragon + .as_ref() + .map_or(1.0, |d| (d.health / MAX_HEALTH).clamp(0.0, 1.0)), + ) } - /// `music.dragon` while the boss bar plays music. + /// `BossHealthOverlay.shouldPlayMusic`. pub(super) fn boss_music(&self) -> bool { - self.dim == Dim::End && self.dragon.is_some() || self.wither_near() + self.boss_overlay.should_play_music() } } pub(super) struct DragonFireball { diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index bff4a9918..bedca5708 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -6,7 +6,6 @@ use super::{Dim, Game}; use horizon_client::mobs::villager as mobs_villager; use horizon_client::{ effects::{EffectInstance, OmenTick}, - hud_overlays, mobs::{ Mob, Species, raider, spawn_rules::{self, SpawnLevel}, @@ -15,9 +14,9 @@ use horizon_client::{ }, patrol::{GameplayAttributes, Member, PatrolLevel, PatrolSpawner}, raid::{self, Action, BarName, RaidLevel, RaidPlayer, RaiderInfo, RaiderType, Raids, Spawn}, - text::Text, }; use horizon_core::{geometry::Vec3, random::RandomSource}; +use horizon_world::nbt::Tag; use std::collections::{HashMap, HashSet}; /// Patrol and raid state owned by the game. @@ -1063,37 +1062,18 @@ impl Game<'_> { } } - /// The raid boss bars: red and notched in tens, with the raid's title. - pub(super) fn raid_bars(&self, bars: &mut Vec) { - for raid in self.raiding.raids.map.values() { - if !raid.bar.visible || !raid.bar.player || raid.is_stopped() { - continue; - } - let title = self.translate("event.minecraft.raid", "Raid"); - let name = match raid.bar.name { - BarName::Raid => title, - BarName::Remaining(n) => format!( - "{title} - {}", - self.translate( - "event.minecraft.raid.raiders_remaining", - "Raiders Remaining: %s" - ) - .replace("%s", &n.to_string()) - ), - BarName::Victory => { - self.translate("event.minecraft.raid.victory.full", "Raid - Victory") - } - BarName::Defeat => { - self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat") - } - }; - bars.push(hud_overlays::BossBar { - name: Text::literal(name), - progress: raid.bar.progress, - color: 2, - overlay: 2, - }); + /// The raid bar a living player at `at` is in (`Raid.getPlayers`): red, + /// notched in tens. The name is the translate component the client draws. + pub(super) fn raid_boss_at(&self, at: [f64; 3], alive: bool) -> Option<(i32, Tag, f32)> { + if !alive || self.dim != Dim::Overworld { + return None; } + let block = block_of(Vec3::new(at[0], at[1], at[2])); + let (id, raid) = self.raiding.raids.nearby(block)?; + if !raid.bar.visible || raid.is_stopped() { + return None; + } + Some((id, raid_name_tag(raid.bar.name), raid.bar.progress)) } /// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`). pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) { @@ -1239,6 +1219,36 @@ impl Game<'_> { } } +/// `Raid.raidEvent`'s name: the raid title, the remaining-raiders line, or +/// the victory and defeat lines. +fn raid_name_tag(name: BarName) -> Tag { + let key = |k: &str| Tag::compound([("translate", Tag::String(k.into()))]); + match name { + BarName::Raid => key("event.minecraft.raid"), + BarName::Victory => key("event.minecraft.raid.victory.full"), + BarName::Defeat => key("event.minecraft.raid.defeat.full"), + BarName::Remaining(n) => Tag::compound([ + ("translate", Tag::String("event.minecraft.raid".into())), + ( + "extra", + Tag::List(vec![ + Tag::String(" - ".into()), + Tag::compound([ + ( + "translate", + Tag::String("event.minecraft.raid.raiders_remaining".into()), + ), + ( + "with", + Tag::List(vec![Tag::String(n.to_string().as_str().into())]), + ), + ]), + ]), + ), + ]), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index da0def0fb..9c0504a2c 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -369,6 +369,7 @@ impl Game<'_> { } self.apply_settings(); self.clear_waypoints(); + self.boss_overlay.reset(); self.remote = Some(Remote { session, brand: "unknown".into(), @@ -450,6 +451,7 @@ impl Game<'_> { self.extras_left(); } self.clear_waypoints(); + self.boss_overlay.reset(); if let Some(mut r) = self.remote.take() { r.session.disconnect(); } @@ -2640,6 +2642,7 @@ impl Game<'_> { } // ClientPacketListener.setActionBarText, setTitleText, // setSubtitleText, setTitlesAnimation and handleTitlesClear. + C::BossEvent(p) => self.apply_boss_event(p), C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false), C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)), C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)), @@ -3053,6 +3056,7 @@ impl Game<'_> { // Minecraft.tick: TickRateManager.tick; ClientLevel.tickTime advances // the time only while the level runs normally. let running = self.clock.world_runs(); + self.boss_overlay.tick_darken(running); if running { self.world.game_time += 1; self.remote_clock_tick(); diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 331db8833..e013c8d29 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -100,7 +100,10 @@ impl Game<'_> { skybox: self.dim == Dim::Overworld, blindness: None, darkness: self.darkness_fog(), - boss_world_fog: self.dragon_bar_shown(), + darken_world: self + .boss_overlay + .darken_amount(self.clock.fraction() as f32), + boss_world_fog: self.boss_overlay.should_create_world_fog(), }, ) } diff --git a/crates/horizon-client/src/game/wither.rs b/crates/horizon-client/src/game/wither.rs index 7b86dc527..91fe91b00 100644 --- a/crates/horizon-client/src/game/wither.rs +++ b/crates/horizon-client/src/game/wither.rs @@ -2,10 +2,8 @@ //! its boss bar, and its skulls hitting and exploding. use super::Game; use horizon_client::{ - hud_overlays, mobs::{Damage, DamageKind, Species, Who}, projectiles::{Projectile, ProjectileKind}, - text::Text, }; impl Game<'_> { @@ -78,45 +76,6 @@ impl Game<'_> { } Ok(()) } - /// The wither boss bars (purple), within range. - pub(super) fn wither_bars(&self, bars: &mut Vec) { - let Some(p) = self.player.as_ref() else { - return; - }; - for m in self - .mobs - .iter() - .filter(|m| m.species() == Species::Wither && m.alive()) - { - if m.position.distance_squared(p.position) > 64.0 * 64.0 { - continue; - } - let progress = if m.extra.counter > 0 { - 1.0 - m.extra.counter as f32 / horizon_client::mobs::wither::INVULNERABLE as f32 - } else { - (m.health / m.attrs.max_health).clamp(0.0, 1.0) - }; - let name = m - .custom_name - .clone() - .unwrap_or_else(|| self.translate("entity.minecraft.wither", "Wither")); - bars.push(hud_overlays::BossBar { - name: Text::literal(name), - progress, - color: 5, - overlay: 0, - }); - } - } - pub(super) fn wither_near(&self) -> bool { - self.player.as_ref().is_some_and(|p| { - self.mobs.iter().any(|m| { - m.species() == Species::Wither - && m.alive() - && m.position.distance_squared(p.position) <= 64.0 * 64.0 - }) - }) - } /// `WitherSkull.onHitEntity` / `onHit`: 8 damage (healing the wither 5 /// when it kills), withering for 10 seconds, then a power 1 blast. pub(super) fn wither_skull_hit( diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index c369ee1c7..005e9ce90 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -268,6 +268,8 @@ pub struct Frame<'a> { pub night_vision: f32, /// Torch flicker added to the block light factor of 1.4. pub block_flicker: f32, + /// `lightmap.fsh` BossOverlayWorldDarkeningFactor (a wither's bar). + pub boss_darken: f32, /// `Screen.extractBlurredBackground`: blur the frame by this radius /// (`menuBackgroundBlurriness`) before the screen is drawn over it. pub menu_blur: Option, @@ -2778,6 +2780,7 @@ impl Renderer { gamma: f32, night_vision: f32, flicker: f32, + boss_darken: f32, ) { // lightmap.fsh: per (block, sky) level. let brightness = |level: f32| level / (4.0 - 3.0 * level); @@ -2795,6 +2798,8 @@ impl Renderer { let parabolic = (2.0 * block_level - 1.0).powi(2); let block_color = Vec3::from(sky.block_light_tint).lerp(Vec3::ONE, 0.9 * parabolic); color += block_color * block_brightness; + // lightmap.fsh: a boss bar mixes the light toward 70/60/60%. + color = color.lerp(color * Vec3::new(0.7, 0.6, 0.6), boss_darken); color -= Vec3::splat(darkness); color = color.clamp(Vec3::ZERO, Vec3::ONE); let max = color.max_element().max(1e-6); @@ -2909,6 +2914,7 @@ impl Renderer { (self.gamma - frame.darkness.0).max(0.0), frame.night_vision, frame.block_flicker, + frame.boss_darken, ); let view_proj = self.view_projection(frame); if frame.title { diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 3c741e963..43a399ef0 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -24,6 +24,7 @@ pub mod block_entity_render; pub mod block_transform; pub mod blocks; pub mod border_render; +pub mod boss_overlay; pub mod bossbar; pub mod breaking; pub mod brewing; diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 206ed9aff..7dd8156c4 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -1580,6 +1580,125 @@ impl Codec> for StatList { } } +/// `ClientboundBossEventPacket` (`boss_event`). +#[derive(Clone, Debug, PartialEq)] +pub enum BossAction { + Add { + name: Tag, + progress: f32, + /// `BossBarColor` ordinal. + color: i32, + /// `BossBarOverlay` ordinal. + overlay: i32, + darken: bool, + music: bool, + fog: bool, + }, + Remove, + UpdateProgress { + progress: f32, + }, + UpdateName { + name: Tag, + }, + UpdateStyle { + color: i32, + overlay: i32, + }, + UpdateProperties { + darken: bool, + music: bool, + fog: bool, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct BossEvent { + pub id: u128, + pub action: BossAction, +} +fn boss_flags(darken: bool, music: bool, fog: bool) -> u8 { + u8::from(darken) | (u8::from(music) << 1) | (u8::from(fog) << 2) +} +fn split_flags(flags: u8) -> (bool, bool, bool) { + (flags & 1 != 0, flags & 2 != 0, flags & 4 != 0) +} +impl Codec for BossEvent { + fn read(r: &mut Reader) -> Result { + let id = r.uuid()?; + let action = match r.varint()? { + 0 => { + let name = r.nbt()?; + let progress = r.f32()?; + let color = r.varint()?; + let overlay = r.varint()?; + let (darken, music, fog) = split_flags(r.u8()?); + BossAction::Add { + name, + progress, + color, + overlay, + darken, + music, + fog, + } + } + 1 => BossAction::Remove, + 2 => BossAction::UpdateProgress { progress: r.f32()? }, + 3 => BossAction::UpdateName { name: r.nbt()? }, + 4 => BossAction::UpdateStyle { + color: r.varint()?, + overlay: r.varint()?, + }, + 5 => { + let (darken, music, fog) = split_flags(r.u8()?); + BossAction::UpdateProperties { darken, music, fog } + } + op => return Err(Error::new(format!("unknown boss event operation {op}"))), + }; + Ok(BossEvent { id, action }) + } + fn write(v: &BossEvent, w: &mut Writer) { + w.uuid(v.id); + match &v.action { + BossAction::Add { + name, + progress, + color, + overlay, + darken, + music, + fog, + } => { + w.varint(0); + w.nbt(name); + w.f32(*progress); + w.varint(*color); + w.varint(*overlay); + w.u8(boss_flags(*darken, *music, *fog)); + } + BossAction::Remove => w.varint(1), + BossAction::UpdateProgress { progress } => { + w.varint(2); + w.f32(*progress); + } + BossAction::UpdateName { name } => { + w.varint(3); + w.nbt(name); + } + BossAction::UpdateStyle { color, overlay } => { + w.varint(4); + w.varint(*color); + w.varint(*overlay); + } + BossAction::UpdateProperties { darken, music, fog } => { + w.varint(5); + w.u8(boss_flags(*darken, *music, *fog)); + } + } + } +} + packet_enum!(Clientbound, crate::ids::play::clientbound { BundleDelimiter(Empty) = BUNDLE_DELIMITER, AddEntity(AddEntity) = ADD_ENTITY, @@ -1590,6 +1709,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { BlockEntityData(BlockEntityData) = BLOCK_ENTITY_DATA, BlockEvent(BlockEvent) = BLOCK_EVENT, BlockUpdate(BlockUpdate) = BLOCK_UPDATE, + BossEvent(BossEvent) = BOSS_EVENT, ChangeDifficulty(ChangeDifficulty) = CHANGE_DIFFICULTY, ChunkBatchFinished(ChunkBatchFinished) = CHUNK_BATCH_FINISHED, ChunkBatchStart(Empty) = CHUNK_BATCH_START, @@ -2860,4 +2980,40 @@ mod tests { w.varint(101); assert!(BookPages::read(&mut Reader::new(&w.data)).is_err()); } + fn boss_event_round_trips() { + let add = Clientbound::BossEvent(BossEvent { + id: 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210, + action: BossAction::Add { + name: Tag::String("Wither".into()), + progress: 0.5, + color: 5, + overlay: 0, + darken: true, + music: false, + fog: true, + }, + }); + let body = add.encode(); + // Packet id 9, then the uuid, operation 0, and the property byte + // (darken 1, fog 4). + assert_eq!(body[0], 9); + assert_eq!(*body.last().unwrap(), 5); + round_trip(add, Clientbound::encode, Clientbound::decode); + round_trip( + Clientbound::BossEvent(BossEvent { + id: 7, + action: BossAction::UpdateProgress { progress: 0.25 }, + }), + Clientbound::encode, + Clientbound::decode, + ); + round_trip( + Clientbound::BossEvent(BossEvent { + id: 7, + action: BossAction::Remove, + }), + Clientbound::encode, + Clientbound::decode, + ); + } } From b61b25f80fa2afdb7249ccc48c5e2e0f9eb90c68 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:35:37 +0200 Subject: [PATCH 06/50] Shader packs, clean room 6: composite-style programs to naga A program's preprocessed GLSL, any version from 110 to 460, compatibility or core, rewritten into the Vulkan-flavoured GLSL 450 that naga's frontend reads, then parsed and validated into naga modules with the program's interface: its uniform block (offsets from naga's layout), its samplers and their bindings, its output locations. The rewrite works on preprocessing tokens and keeps the pack's lines, so every error points at the pack's own file and line through the preprocessor's line map. - Loose uniforms go into one block shared by both stages; bool is stored as int. Samplers are split into texture and sampler objects, combined again where used, and passed as two arguments to the pack's functions. - The compatibility built-ins become declarations of ours (prefix hz_): gl_Vertex, gl_MultiTexCoordN, the matrices and ftransform(), gl_FragData, gl_FragColor, gl_TexCoord, gl_FrontColor, gl_Fog (from vanilla's fog), texture2D and the other old texture functions, shadow2D; varyings get locations by name across the stages; words that became keywords after GLSL 1.x are renamed in old code. - naga gaps covered: modf, conditional initializers of globals (run by our main), and unsized arrays sized from their constructor. - The pack's main is called by one of ours that draws the quad from (0,0) to (1,1) and adapts GL's conventions: clip y negated for every target but the window, so memory rows are GL's rows and gl_FragCoord needs no change; clip z from GL's [-w, w] to [0, w], so stored depth is GL's window depth. In final, gl_FragCoord.y is counted from the bottom of the window. Black box over the public test packs: all 105 composite-style programs (composite, deferred, final, prepare, begin, shadowcomp of the base and dimension folders) translate and validate. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 11 + crates/horizon-shaders/Cargo.toml | 1 + crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/translate/mod.rs | 436 ++++++++++++++++++ .../horizon-shaders/src/translate/rewrite.rs | 257 +++++++++++ crates/horizon-shaders/src/translate/scan.rs | 399 ++++++++++++++++ crates/horizon-shaders/tests/real_packs.rs | 73 ++- 7 files changed, 1179 insertions(+), 1 deletion(-) create mode 100644 crates/horizon-shaders/src/translate/mod.rs create mode 100644 crates/horizon-shaders/src/translate/rewrite.rs create mode 100644 crates/horizon-shaders/src/translate/scan.rs diff --git a/Cargo.lock b/Cargo.lock index 7036ea5ca..7b1770094 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1435,6 +1435,7 @@ name = "horizon-shaders" version = "0.1.0" dependencies = [ "horizon-core", + "naga", "zip", ] @@ -1951,6 +1952,7 @@ dependencies = [ "naga-types", "num-traits", "once_cell", + "pp-rs", "rustc-hash 1.1.0", "spirv", "thiserror 2.0.21", @@ -2627,6 +2629,15 @@ dependencies = [ "zerovec", ] +[[package]] +name = "pp-rs" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee" +dependencies = [ + "unicode-xid", +] + [[package]] name = "presser" version = "0.3.1" diff --git a/crates/horizon-shaders/Cargo.toml b/crates/horizon-shaders/Cargo.toml index 750949e83..38e068b94 100644 --- a/crates/horizon-shaders/Cargo.toml +++ b/crates/horizon-shaders/Cargo.toml @@ -8,6 +8,7 @@ description = "Shader packs (the shaders/ folder format): reading packs, their . [dependencies] horizon-core = { path = "../horizon-core" } +naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in"] } zip = { version = "2", default-features = false, features = ["deflate"] } [lints] diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 14f85b758..bae3c0dcd 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -7,6 +7,7 @@ //! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). //! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). +//! - [`translate_composite`]: a composite-style program into naga modules and its interface (1.6, 2.2). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). mod condition; @@ -16,6 +17,7 @@ mod macros; mod pack; mod preprocess; mod properties; +mod translate; pub use condition::eval_condition; pub use discover::{Found, discover}; @@ -24,3 +26,4 @@ pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; pub use properties::{Entry, Note, Properties}; +pub use translate::{Error, Interface, Program, Sampler, Target, Uniform, translate_composite}; diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs new file mode 100644 index 000000000..9cc2ea07a --- /dev/null +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -0,0 +1,436 @@ +//! A program's preprocessed GLSL (any version from 110 to 460, compatibility or core) turned +//! into the Vulkan-flavoured GLSL 450 naga reads, then into validated naga modules (spec 1.6, +//! 2.2). The rewrite works on preprocessing tokens and never adds or removes a line of the pack's +//! code, so naga's line numbers map back through the preprocessor to the pack's files. +//! +//! What it does: +//! - Loose uniforms go into one uniform block shared by both stages (`bool` stored as `int`). +//! - Each sampler is split into a texture and a sampler object, combined again where it is used +//! (`sampler2D(t, s)`), or passed as two arguments to the pack's own functions. +//! - The compatibility built-ins (`gl_Vertex`, `gl_MultiTexCoordN`, `gl_*Matrix`, `ftransform()`, +//! `gl_FragData`, `gl_FragColor`, `gl_TexCoord`, `texture2D`...) become declarations of ours, +//! named with the reserved prefix `hz_`; varyings get locations by name across the stages. +//! - The pack's `main` becomes `hz_main`, called by a `main` of ours that sets the inputs and +//! adapts GL's conventions (spec 5.5): clip y is negated for every target but the window, so +//! memory rows are GL's rows and `gl_FragCoord` needs no change, and clip z goes from GL's +//! [-w, w] to [0, w], so stored depth is GL's window depth. +//! +//! Composite-style programs draw one quad from (0,0) to (1,1) with an identity model-view and an +//! orthographic projection onto it, the texture coordinate equal to the position (OQ-6). + +use std::collections::BTreeMap; +use std::fmt::Write; + +use naga::ShaderStage; +use naga::front::glsl::{Frontend, Options}; +use naga::valid::{Capabilities, ValidationFlags, Validator}; + +use crate::Preprocessed; + +mod rewrite; +mod scan; + +use scan::Stage; + +/// What a program's stages need from the game. +#[derive(Debug, Default, PartialEq)] +pub struct Interface { + /// The uniform block at binding 0: its size and each value's name, GLSL type and offset. + /// Ours come first: `hz_target` (the target's width, height, 1/width, 1/height), and the + /// fixed-function fog the compatibility `gl_Fog` reads (OQ-28): `hz_fog_color` (vanilla's fog + /// colour) and `hz_fog` (density, start, end, 1 / (end - start)). + pub uniform_size: u32, + pub uniforms: Vec, + /// Each sampler: texture at `binding`, sampler object at `binding + 1`. + pub samplers: Vec, + /// The fragment stage's output locations. + pub outputs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Uniform { + pub name: String, + /// The type the pack declared (`float`, `ivec2`, `mat4`; `bool` is stored as an `int`). + pub ty: String, + pub offset: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Sampler { + pub name: String, + /// The type the pack declared: `sampler2D`, `sampler2DShadow`, `usampler2D`, `sampler3D`... + pub ty: String, + pub binding: u32, +} + +/// A program that failed, at a pack file and line. +#[derive(Clone, Debug, PartialEq)] +pub struct Error { + pub file: String, + pub line: u32, + pub message: String, +} + +pub struct Program { + pub vertex: naga::Module, + pub fragment: naga::Module, + pub interface: Interface, +} + +/// Where the program draws, which decides the conventions of 5.5. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Target { + /// Pack buffers: rows stored as GL stores them. + Buffers, + /// The window (`final`). + Window, +} + +/// Translates a composite-style program (`composite`, `deferred`, `final`...). +pub fn translate_composite( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, +) -> Result> { + let mut v = Stage::scan(vertex, ShaderStage::Vertex); + let mut f = Stage::scan(fragment, ShaderStage::Fragment); + let mut errors: Vec = v.errors.drain(..).chain(f.errors.drain(..)).collect(); + + // One uniform block and one set of samplers for both stages. + let mut uniforms: BTreeMap = BTreeMap::new(); + let mut samplers: BTreeMap = BTreeMap::new(); + for stage in [&v, &f] { + for (name, ty, at) in &stage.uniforms { + if let Some(old) = uniforms.insert(name.clone(), ty.clone()) + && &old != ty + { + errors.push(stage.error( + *at, + &format!("`{name}` is `{old}` in one stage and `{ty}` in the other"), + )); + } + } + for (name, ty) in &stage.samplers { + samplers.insert(name.clone(), ty.clone()); + } + } + let samplers: Vec = samplers + .into_iter() + .enumerate() + .map(|(i, (name, ty))| Sampler { + name, + ty, + binding: 1 + 2 * i as u32, + }) + .collect(); + + // Varyings by name: the vertex outputs, then fragment inputs the vertex stage lacks. + let mut locations: BTreeMap = BTreeMap::new(); + for var in v.outs.iter().chain(&f.ins) { + let next = locations.len() as u32; + locations + .entry(var.name.clone()) + .or_insert((next, var.decl.clone())); + } + + let header = |stage: &Stage| { + let mut h = String::from("#version 450\n"); + h.push_str("layout(set = 0, binding = 0) uniform HzUniforms { vec4 hz_target; vec4 hz_fog_color; vec4 hz_fog;"); + for (name, ty) in &uniforms { + match ty.as_str() { + "bool" => write!(h, " int hz_bool_{name};"), + _ => match ty.split_once('[') { + Some((base, size)) => write!(h, " {base} {name}[{size};"), + None => write!(h, " {ty} {name};"), + }, + } + .unwrap(); + } + h.push_str(" };\n"); + for s in &samplers { + let (texture, sampler) = split_sampler(&s.ty); + writeln!( + h, + "layout(set = 0, binding = {}) uniform {texture} {}; layout(set = 0, binding = {}) uniform {sampler} hz_s_{};", + s.binding, s.name, s.binding + 1, s.name + ) + .unwrap(); + } + let direction = if stage.kind == ShaderStage::Vertex { + "out" + } else { + "in" + }; + for (name, (location, decl)) in &locations { + writeln!( + h, + "layout(location = {location}) {direction} {}", + decl.replace("@", name) + ) + .unwrap(); + } + h.push_str(&stage.prelude()); + h + }; + let flip = if target == Target::Buffers { "-" } else { "" }; + let fog = + " hz_Fog = hz_FogParameters(hz_fog_color, hz_fog.x, hz_fog.y, hz_fog.z, hz_fog.w);\n"; + let mut vertex_main = String::from( + "\nvoid main() {\n vec2 hz_p = hz_quad[gl_VertexIndex];\n hz_Vertex = vec4(hz_p, 0.0, 1.0);\n \ + hz_MultiTexCoord0 = hz_Vertex;\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 0.0, 1.0);\n", + ); + for (name, value) in &v.attributes { + writeln!(vertex_main, " {name} = {value};").unwrap(); + } + vertex_main.push_str(fog); + for name in &v.inits { + writeln!(vertex_main, " {name} = hz_init_{name}();").unwrap(); + } + write!( + vertex_main, + " hz_main();\n gl_Position = vec4(gl_Position.x, {flip}gl_Position.y, (gl_Position.z + gl_Position.w) * 0.5, gl_Position.w);\n}}\n" + ) + .unwrap(); + let mut fragment_main = String::from("\nvoid main() {\n"); + fragment_main.push_str(fog); + if target == Target::Window && f.frag_coord { + fragment_main.push_str(" hz_FragCoord = vec4(gl_FragCoord.x, hz_target.y - gl_FragCoord.y, gl_FragCoord.zw);\n"); + } + for name in &f.inits { + writeln!(fragment_main, " {name} = hz_init_{name}();").unwrap(); + } + fragment_main.push_str(" hz_main();\n}\n"); + + let v_text = format!( + "{}{}{vertex_main}", + header(&v), + v.rewrite(&samplers, &uniforms) + ); + let f_text = format!( + "{}{}{fragment_main}", + header(&f), + f.rewrite(&samplers, &uniforms) + ); + if !errors.is_empty() { + return Err(errors); + } + let vertex_module = compile(&v, &v_text, header(&v).lines().count()); + let fragment_module = compile(&f, &f_text, header(&f).lines().count()); + let (vertex, fragment) = match (vertex_module, fragment_module) { + (Ok(v), Ok(f)) => (v, f), + (a, b) => return Err(a.err().into_iter().chain(b.err()).flatten().collect()), + }; + let (uniform_size, offsets) = block_layout(&fragment); + let uniforms = offsets + .into_iter() + .filter_map(|(member, offset)| { + let name = member + .strip_prefix("hz_bool_") + .unwrap_or(&member) + .to_owned(); + let ty = if name.starts_with("hz_") { + "vec4".to_owned() + } else { + uniforms.get(&name)?.clone() + }; + Some(Uniform { name, ty, offset }) + }) + .collect(); + let outputs = f.outputs.iter().copied().collect(); + Ok(Program { + vertex, + fragment, + interface: Interface { + uniform_size, + uniforms, + samplers, + outputs, + }, + }) +} + +/// `sampler2DShadow` → (`texture2D`, `samplerShadow`); `isampler3D` → (`itexture3D`, `sampler`). +pub(super) fn split_sampler(ty: &str) -> (String, &'static str) { + let shadow = ty.ends_with("Shadow"); + let base = ty + .trim_end_matches("Shadow") + .replacen("sampler", "texture", 1); + (base, if shadow { "samplerShadow" } else { "sampler" }) +} + +/// Parses and validates one stage; errors at the pack's files and lines. +fn compile(stage: &Stage, text: &str, header_lines: usize) -> Result> { + let at = |span: naga::Span, message: String| { + let start = span.to_range().map_or(0, |r| r.start); + let line = text[..start.min(text.len())].matches('\n').count(); + match line + .checked_sub(header_lines) + .and_then(|l| stage.pp.lines.get(l)) + { + Some(o) => Error { + file: stage + .pp + .files + .get(o.file as usize) + .cloned() + .unwrap_or_default(), + line: o.line, + message, + }, + None => Error { + file: "".into(), + line: line as u32 + 1, + message, + }, + } + }; + let module = Frontend::default() + .parse(&Options::from(stage.kind), text) + .map_err(|e| { + e.errors + .into_iter() + .map(|e| at(e.meta, e.kind.to_string())) + .collect::>() + })?; + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&module) + .map_err(|e| { + let span = e.spans().next().map_or(naga::Span::default(), |(s, _)| *s); + // The validator nests its reasons: say them all. + let inner = e.into_inner(); + let mut message = inner.to_string(); + let mut source = std::error::Error::source(&inner); + while let Some(s) = source { + write!(message, ": {s}").unwrap(); + source = s.source(); + } + vec![at(span, message)] + })?; + Ok(module) +} + +/// The uniform block's size and member offsets, from naga's layout. +fn block_layout(module: &naga::Module) -> (u32, Vec<(String, u32)>) { + for (_, var) in module.global_variables.iter() { + if var.space != naga::AddressSpace::Uniform { + continue; + } + if let naga::TypeInner::Struct { members, span } = &module.types[var.ty].inner { + let offsets = members + .iter() + .filter_map(|m| Some((m.name.clone()?, m.offset))) + .collect(); + return (*span, offsets); + } + } + (16, vec![("hz_target".into(), 0)]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + use crate::{Macros, Pack, preprocess}; + + fn program(files: &[(&str, &str)], target: Target) -> Result> { + let files: Vec<(String, &str)> = files + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + let m = Macros::standard(); + translate_composite( + &preprocess(&pack, "composite.vsh", &m), + &preprocess(&pack, "composite.fsh", &m), + target, + ) + } + + const VSH_120: &str = "#version 120\nvarying vec2 texcoord;\nvoid main() {\n gl_Position = ftransform();\n \ + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;\n}\n"; + + #[test] + fn compatibility_programs_translate() { + let fsh = "#version 120\nuniform sampler2D colortex0;\nuniform sampler2D depthtex0;\nuniform float viewWidth;\n\ + uniform bool hideGUI;\nuniform int frameCounter;\nvarying vec2 texcoord;\n\ + vec3 tint(sampler2D s, vec2 c) { return texture2D(s, c).rgb; }\nvoid main() {\n \ + vec3 c = tint(colortex0, texcoord) * texture2D(depthtex0, texcoord).r;\n if (hideGUI) c *= 0.5;\n \ + /* DRAWBUFFERS:04 */\n gl_FragData[0] = vec4(c, 1.0);\n gl_FragData[1] = vec4(frameCounter / viewWidth);\n}\n"; + let p = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + Target::Buffers, + ) + .unwrap(); + let i = &p.interface; + let samplers: Vec<(&str, u32)> = i + .samplers + .iter() + .map(|s| (s.name.as_str(), s.binding)) + .collect(); + assert_eq!(samplers, [("colortex0", 1), ("depthtex0", 3)]); + let uniforms: Vec<(&str, &str)> = i + .uniforms + .iter() + .map(|u| (u.name.as_str(), u.ty.as_str())) + .collect(); + assert_eq!( + uniforms, + [ + ("hz_target", "vec4"), + ("hz_fog_color", "vec4"), + ("hz_fog", "vec4"), + ("frameCounter", "int"), + ("hideGUI", "bool"), + ("viewWidth", "float") + ] + ); + assert_eq!(i.uniforms[0].offset, 0); + assert!(i.uniform_size >= 28); + assert_eq!(i.outputs, [0, 1]); + } + + #[test] + fn core_programs_translate() { + let vsh = "#version 330 core\nin vec3 vaPosition;\nin vec2 vaUV0;\nuniform mat4 modelViewMatrix, projectionMatrix;\n\ + out vec2 uv;\nflat out int flatId;\nvoid main() {\n \ + gl_Position = projectionMatrix * modelViewMatrix * vec4(vaPosition, 1.0);\n uv = vaUV0;\n flatId = 1;\n}\n"; + let fsh = "#version 330 core\nin vec2 uv;\nuniform sampler2D colortex0;\nuniform usampler2D colortex5;\n\ + layout(location = 2) out vec4 color;\nvoid main() {\n \ + if (texture(colortex0, uv).a < 0.1) discard;\n color = texture(colortex0, uv) + float(texelFetch(colortex5, ivec2(gl_FragCoord.xy), 0).r);\n}\n"; + let p = program( + &[("composite.vsh", vsh), ("composite.fsh", fsh)], + Target::Window, + ) + .unwrap(); + assert_eq!(p.interface.outputs, [2]); + assert!( + p.interface + .uniforms + .iter() + .any(|u| u.name == "projectionMatrix" && u.ty == "mat4") + ); + } + + #[test] + fn errors_point_at_the_pack_file_and_line() { + let fsh = + "#version 120\n#include \"/lib/bad.glsl\"\nvoid main() { gl_FragColor = vec4(f()); }\n"; + let bad = "// fine\nfloat f() {\n return undefinedThing;\n}\n"; + let errors = program( + &[ + ("composite.vsh", VSH_120), + ("composite.fsh", fsh), + ("lib/bad.glsl", bad), + ], + Target::Buffers, + ) + .err() + .unwrap(); + assert_eq!( + (errors[0].file.as_str(), errors[0].line), + ("lib/bad.glsl", 3), + "{errors:?}" + ); + } +} diff --git a/crates/horizon-shaders/src/translate/rewrite.rs b/crates/horizon-shaders/src/translate/rewrite.rs new file mode 100644 index 000000000..312575139 --- /dev/null +++ b/crates/horizon-shaders/src/translate/rewrite.rs @@ -0,0 +1,257 @@ +//! The second pass: the stage's code written again for naga, line for line, and the +//! declarations of ours it needs. + +use std::collections::{BTreeMap, HashMap}; +use std::fmt::Write; + +use naga::ShaderStage; + +use super::scan::{LATER_KEYWORDS, Stage, UNIFORM_STATE, VERTEX_INPUTS, is_ident, significant}; +use super::{Sampler, split_sampler}; + +impl Stage<'_> { + /// Declarations of ours this stage uses (after the uniform block, samplers and varyings). + pub(super) fn prelude(&self) -> String { + let mut p = String::from( + "float hz_modf(float x, out float i) { i = trunc(x); return x - i; }\n\ + vec2 hz_modf(vec2 x, out vec2 i) { i = trunc(x); return x - i; }\n\ + vec3 hz_modf(vec3 x, out vec3 i) { i = trunc(x); return x - i; }\n\ + vec4 hz_modf(vec4 x, out vec4 i) { i = trunc(x); return x - i; }\n\ + struct hz_FogParameters { vec4 color; float density; float start; float end; float scale; };\n\ + hz_FogParameters hz_Fog;\n\ + const mat4 hz_ModelViewMatrix = mat4(1.0); const mat4 hz_ProjectionMatrix = mat4(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, -1.0, -1.0, 0.0, 1.0);\n\ + const mat4 hz_ModelViewProjectionMatrix = hz_ProjectionMatrix; const mat4 hz_ModelViewMatrixInverse = mat4(1.0); const mat3 hz_NormalMatrix = mat3(1.0);\n\ + const mat4 hz_ProjectionMatrixInverse = mat4(0.5, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.5, 0.5, 0.0, 1.0);\n\ + const mat4 hz_TextureMatrix[8] = mat4[8](mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n", + ); + if self.kind == ShaderStage::Vertex { + p.push_str( + "const vec2 hz_quad[6] = vec2[6](vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0));\n\ + vec4 hz_Vertex; vec4 hz_Color; vec4 hz_SecondaryColor; vec3 hz_Normal; float hz_FogCoord;\n\ + vec4 hz_MultiTexCoord0; vec4 hz_MultiTexCoord1; vec4 hz_MultiTexCoord2; vec4 hz_MultiTexCoord3;\n\ + vec4 hz_MultiTexCoord4; vec4 hz_MultiTexCoord5; vec4 hz_MultiTexCoord6; vec4 hz_MultiTexCoord7;\n", + ); + } else { + for n in &self.outputs { + if !self.frag_outs.iter().any(|(at, _)| at == n) { + writeln!(p, "layout(location = {n}) out vec4 hz_out{n};").unwrap(); + } + } + for (_, decl) in &self.frag_outs { + writeln!(p, "{decl}").unwrap(); + } + p.push_str("vec4 hz_FragCoord;\n"); + } + p + } + + /// The pack's code with the rewrites above; the same number of lines. + pub(super) fn rewrite( + &self, + samplers: &[Sampler], + uniforms: &BTreeMap, + ) -> String { + let removed = |i: usize| self.removed.iter().any(|r| r.contains(&i)); + let sampler_ty: HashMap<&str, &str> = samplers + .iter() + .map(|s| (s.name.as_str(), s.ty.as_str())) + .collect(); + let old = self.pp.version.as_ref().is_none_or(|(v, _)| *v < 400); + let fragment = self.kind == ShaderStage::Fragment; + let mut out = String::with_capacity(self.pp.code.len() + 1024); + // Per open bracket: the function called, and the closing text to add. + let mut calls: Vec<(&str, &str, usize)> = Vec::new(); + // Sampler parameters of the function being written: name → type. + let mut params: HashMap = HashMap::new(); + let mut depth = 0; + let mut skip_until = 0; + let next = |i: usize| (i + 1..self.tokens.len()).find(|&j| significant(self.tokens[j])); + let prev = |i: usize| (0..i).rev().find(|&j| significant(self.tokens[j])); + for (i, &t) in self.tokens.iter().enumerate() { + if i < skip_until { + continue; + } + if let Some(text) = self.replaced.get(&i) { + out.push_str(text); + continue; + } + if removed(i) { + out.extend(t.chars().filter(|&c| c == '\n')); + if t.contains(' ') && !t.contains('\n') { + out.push(' '); + } + continue; + } + let called = next(i).is_some_and(|j| self.tokens[j] == "("); + match t { + "{" => depth += 1, + "}" => { + depth -= 1; + if depth == 0 { + params.clear(); + } + } + "(" => { + let callee = prev(i).map_or("", |j| self.tokens[j]); + let close = if matches!(callee, "shadow2D" | "shadow2DLod" | "shadow2DProj") { + ")" + } else { + "" + }; + calls.push((callee, close, 0)); + } + ")" => { + if let Some((_, close, _)) = calls.pop() { + out.push_str(close); + } + } + "," => { + if let Some(c) = calls.last_mut() { + c.2 += 1; + } + } + _ => {} + } + // `T x[] = T[](a, b)`: naga gives unsized arrays no size, so the count is written in. + if t == "[" + && let Some(close) = next(i).filter(|&j| self.tokens[j] == "]") + { + if let Some(n) = self.array_length(close) { + write!(out, "[{n}]").unwrap(); + skip_until = close + 1; + continue; + } + } + if !is_ident(t) { + out.push_str(t); + continue; + } + // A function definition's sampler parameters: `sampler2D s` → `texture2D s, sampler hz_s_s`. + if depth == 0 && t.contains("sampler") && calls.len() == 1 { + if let Some(n) = next(i).filter(|&n| is_ident(self.tokens[n])) { + let name = self.tokens[n]; + let (texture, sampler) = split_sampler(t); + write!(out, "{texture} {name}, {sampler} hz_s_{name}").unwrap(); + params.insert(name.to_owned(), t.to_owned()); + skip_until = n + 1; + continue; + } + } + let sampler = params + .get(t) + .map(String::as_str) + .or_else(|| sampler_ty.get(t).copied()); + if let Some(ty) = sampler { + let as_argument = calls.last().is_some_and(|(callee, _, arg)| { + self.sampler_functions + .get(*callee) + .is_some_and(|p| p.get(*arg) == Some(&true)) + }) && prev(i) + .is_some_and(|j| matches!(self.tokens[j], "(" | ",")); + if as_argument { + write!(out, "{t}, hz_s_{t}").unwrap(); + } else { + write!(out, "{ty}({t}, hz_s_{t})").unwrap(); + } + continue; + } + if uniforms.get(t).is_some_and(|ty| ty == "bool") { + write!(out, "(hz_bool_{t} != 0)").unwrap(); + continue; + } + let replaced: Option = match t { + "main" if depth == 0 && called => Some("hz_main".into()), + // GLSL's `modf`, which naga's frontend lacks. + "modf" if called => Some("hz_modf".into()), + "texture2D" | "texture1D" | "texture3D" | "textureCube" if called => { + Some("texture".into()) + } + "texture2DLod" | "texture3DLod" | "texture2DLodARB" | "textureCubeLod" + if called => + { + Some("textureLod".into()) + } + "texture2DProj" if called => Some("textureProj".into()), + "texture2DGrad" | "texture2DGradARB" if called => Some("textureGrad".into()), + "shadow2D" if called => Some("vec4(texture".into()), + "shadow2DLod" if called => Some("vec4(textureLod".into()), + "shadow2DProj" if called => Some("vec4(textureProj".into()), + "ftransform" if called => { + // `ftransform()` → the product it stands for; its `()` stays as a no-op call... so skip it. + let close = next(i).and_then(next); + skip_until = close.map_or(i + 1, |c| c + 1); + Some("(hz_ModelViewProjectionMatrix * hz_Vertex)".into()) + } + "gl_FragColor" => Some("hz_out0".into()), + "gl_FragData" | "gl_TexCoord" => match self.index_after(i) { + Some((n, close)) => { + skip_until = close + 1; + Some(match t { + "gl_FragData" => format!("hz_out{n}"), + _ => format!("hz_TexCoord{n}"), + }) + } + None => None, + }, + "gl_Color" if fragment => Some("hz_FrontColor".into()), + "gl_FragCoord" if !fragment => None, + "gl_FragCoord" => Some("hz_FragCoord".into()), + "gl_FrontColor" => Some("hz_FrontColor".into()), + "gl_Fog" => Some("hz_Fog".into()), + _ if UNIFORM_STATE.iter().any(|b| t.starts_with(b)) => { + Some(format!("hz_{}", &t[3..])) + } + _ if !fragment && VERTEX_INPUTS.iter().any(|b| t.starts_with(b)) => { + Some(format!("hz_{}", &t[3..])) + } + "gl_VertexID" => Some("gl_VertexIndex".into()), + "gl_InstanceID" => Some("gl_InstanceIndex".into()), + _ if old && LATER_KEYWORDS.contains(&t) => Some(format!("hz_kw_{t}")), + _ => None, + }; + out.push_str(replaced.as_deref().unwrap_or(t)); + } + out + } +} + +impl Stage<'_> { + /// The element count of an unsized array whose `[]` ends at `close`: the arguments of the + /// constructor that follows (`[] = T[](...)` or `[](...)`). + pub(super) fn array_length(&self, close: usize) -> Option { + let mut j = close + 1; + let mut seen_eq = false; + while let Some(&t) = self.tokens.get(j) { + match t { + "(" => break, + "=" if !seen_eq => seen_eq = true, + ";" | "," | ")" | "{" => return None, + _ => {} + } + j += 1; + } + let (mut depth, mut args, mut any) = (0, 1, false); + for &t in &self.tokens[j + 1..] { + match t { + "(" | "[" | "{" => depth += 1, + ")" if depth == 0 => return any.then_some(args), + ")" | "]" | "}" => depth -= 1, + "," if depth == 0 => args += 1, + _ if significant(t) => any = true, + _ => {} + } + } + None + } +} + +/// What our vertex `main` gives a vertex input of a composite-style program. +pub(super) fn attribute_value(name: &str, ty: &str) -> String { + match name { + "vaPosition" => "vec3(hz_p, 0.0)".into(), + "vaUV0" => "hz_p".into(), + "vaColor" => "vec4(1.0)".into(), + "vaNormal" => "vec3(0.0, 0.0, 1.0)".into(), + _ => format!("{ty}(0)"), + } +} diff --git a/crates/horizon-shaders/src/translate/scan.rs b/crates/horizon-shaders/src/translate/scan.rs new file mode 100644 index 000000000..1fd91dd94 --- /dev/null +++ b/crates/horizon-shaders/src/translate/scan.rs @@ -0,0 +1,399 @@ +//! The first pass over a stage's tokens: what it declares at global scope, which of its +//! functions take samplers, which outputs and built-ins it uses. + +use std::collections::{BTreeSet, HashMap}; +use std::fmt::Write; + +use naga::ShaderStage; + +use super::Error; +use super::rewrite::attribute_value; +use crate::Preprocessed; +use crate::macros::lex; + +/// A varying: its name and its declaration with `@` where the name goes (`flat ivec2 @[2];`). +pub(super) struct Var { + pub(super) name: String, + pub(super) decl: String, +} + +pub(super) struct Stage<'p> { + pub(super) kind: ShaderStage, + pub(super) pp: &'p Preprocessed, + pub(super) tokens: Vec<&'p str>, + /// Token ranges to blank out (declarations moved into the header). + pub(super) removed: Vec>, + /// Name, type with its array suffix, token of the type. + pub(super) uniforms: Vec<(String, String, usize)>, + pub(super) samplers: Vec<(String, String)>, + pub(super) ins: Vec, + pub(super) outs: Vec, + /// Vertex inputs, now private globals, and the value our `main` gives each. + pub(super) attributes: Vec<(String, String)>, + /// The pack's fragment outputs, declared again with their locations. + pub(super) frag_outs: Vec<(u32, String)>, + /// The pack's functions that take samplers: which parameters are samplers. + pub(super) sampler_functions: HashMap>, + pub(super) outputs: BTreeSet, + pub(super) frag_coord: bool, + /// Tokens written differently, by index. + pub(super) replaced: HashMap, + /// Globals whose initializer runs in our `main` (`g = hz_init_g();`), in order. + pub(super) inits: Vec, + pub(super) errors: Vec, +} + +pub(super) const STORAGE: [&str; 5] = ["uniform", "in", "out", "attribute", "varying"]; +pub(super) const QUALIFIERS: [&str; 11] = [ + "flat", + "smooth", + "noperspective", + "centroid", + "invariant", + "highp", + "mediump", + "lowp", + "precise", + "sample", + "const", +]; +/// The compatibility profile's uniform state (with its `Inverse`/`Transpose` forms). +pub(super) const UNIFORM_STATE: [&str; 5] = [ + "gl_ModelViewMatrix", + "gl_ProjectionMatrix", + "gl_ModelViewProjectionMatrix", + "gl_NormalMatrix", + "gl_TextureMatrix", +]; +/// The compatibility profile's vertex inputs. +pub(super) const VERTEX_INPUTS: [&str; 6] = [ + "gl_Vertex", + "gl_Color", + "gl_SecondaryColor", + "gl_Normal", + "gl_FogCoord", + "gl_MultiTexCoord", +]; +/// Words that became keywords after GLSL 1.x and that old packs use as names. +pub(super) const LATER_KEYWORDS: [&str; 17] = [ + "sample", + "buffer", + "shared", + "filter", + "input", + "output", + "common", + "partition", + "active", + "resource", + "patch", + "subroutine", + "coherent", + "volatile", + "restrict", + "readonly", + "writeonly", +]; + +pub(super) fn significant(t: &str) -> bool { + !t.trim().is_empty() +} + +pub(super) fn is_ident(t: &str) -> bool { + t.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_') +} + +impl<'p> Stage<'p> { + pub(super) fn scan(pp: &'p Preprocessed, kind: ShaderStage) -> Self { + let mut s = Stage { + kind, + pp, + tokens: lex(&pp.code), + removed: Vec::new(), + uniforms: Vec::new(), + samplers: Vec::new(), + ins: Vec::new(), + outs: Vec::new(), + attributes: Vec::new(), + frag_outs: Vec::new(), + sampler_functions: HashMap::new(), + outputs: BTreeSet::new(), + frag_coord: false, + replaced: HashMap::new(), + inits: Vec::new(), + errors: Vec::new(), + }; + let (mut start, mut depth, mut parens) = (0, 0usize, 0usize); + for i in 0..s.tokens.len() { + match s.tokens[i] { + "(" => parens += 1, + ")" => parens = parens.saturating_sub(1), + "{" if depth == 0 && parens == 0 => { + s.global(start, i, true); + depth = 1; + } + "{" => depth += 1, + "}" if depth > 0 => { + depth -= 1; + if depth == 0 { + start = i + 1; + } + } + ";" if depth == 0 && parens == 0 => { + s.global(start, i, false); + start = i + 1; + } + "gl_FragData" | "gl_FragColor" if kind == ShaderStage::Fragment => { + let n = if s.tokens[i] == "gl_FragColor" { + Some(0) + } else { + s.index_after(i).map(|(n, _)| n) + }; + match n { + Some(n) => _ = s.outputs.insert(n), + None => s + .errors + .push(s.error(i, "`gl_FragData` needs a constant index")), + } + } + "gl_FragCoord" => s.frag_coord = true, + "gl_TexCoord" => { + if let Some((n, _)) = s.index_after(i) { + s.builtin_varying(format!("hz_TexCoord{n}")); + } + } + "gl_FrontColor" => s.builtin_varying("hz_FrontColor".into()), + "gl_Color" if kind == ShaderStage::Fragment => { + s.builtin_varying("hz_FrontColor".into()) + } + _ => {} + } + } + s + } + + pub(super) fn builtin_varying(&mut self, name: String) { + let list = if self.kind == ShaderStage::Vertex { + &mut self.outs + } else { + &mut self.ins + }; + if !list.iter().any(|v| v.name == name) { + list.push(Var { + name, + decl: "vec4 @;".into(), + }); + } + } + + /// `[ n ]` right after token `i`: the number and the index of the `]`. + pub(super) fn index_after(&self, i: usize) -> Option<(u32, usize)> { + let mut it = (i + 1..self.tokens.len()).filter(|&j| significant(self.tokens[j])); + let open = it.next()?; + let n = it.next()?; + let close = it.next()?; + (self.tokens[open] == "[" && self.tokens[close] == "]") + .then(|| self.tokens[n].parse().ok().map(|n| (n, close))) + .flatten() + } + + pub(super) fn error(&self, token: usize, message: &str) -> Error { + let line: usize = self.tokens[..token.min(self.tokens.len())] + .iter() + .map(|t| t.matches('\n').count()) + .sum(); + let o = self.pp.lines.get(line).copied().unwrap_or_default(); + Error { + file: self + .pp + .files + .get(o.file as usize) + .cloned() + .unwrap_or_default(), + line: o.line, + message: message.to_owned(), + } + } + + /// A statement at global scope, tokens `from..to` (without its `;`, or before a `{`). + pub(super) fn global(&mut self, from: usize, to: usize, body: bool) { + let words: Vec = (from..to) + .filter(|&i| significant(self.tokens[i])) + .collect(); + // A prototype: a `(` before any `=`, and no storage qualifier. + let paren = words + .iter() + .position(|&i| matches!(self.tokens[i], "(" | "=")); + let prototype = paren.is_some_and(|p| self.tokens[words[p]] == "(") + && !words.iter().any(|&i| STORAGE.contains(&self.tokens[i])); + if body || prototype { + return self.function(&words); + } + let mut k = 0; + let mut storage = None; + let mut location = None; + let mut interpolation = String::new(); + while let Some(&i) = words.get(k) { + let w = self.tokens[i]; + if w == "layout" { + // layout(location = n, ...) + let mut j = k + 1; + while let Some(&t) = words.get(j) { + j += 1; + if self.tokens[t] == "location" { + location = words + .get(j + 1) + .and_then(|&n| self.tokens[n].parse::().ok()); + } + if self.tokens[t] == ")" { + break; + } + } + k = j; + continue; + } + if STORAGE.contains(&w) { + storage = Some(w); + } else if QUALIFIERS.contains(&w) { + if matches!( + w, + "flat" | "smooth" | "noperspective" | "centroid" | "sample" + ) { + write!(interpolation, "{w} ").unwrap(); + } + } else { + break; + } + k += 1; + } + let Some(storage) = storage else { + return self.initializer(&words, to); + }; + let Some(&ty_at) = words.get(k) else { return }; + let ty = self.tokens[ty_at].to_owned(); + if words[k..].iter().any(|&i| self.tokens[i] == "{") { + return; // An interface block: left as the pack wrote it. + } + // Declarators: name [array] [= init], separated by commas. + let mut declarators: Vec<(String, String)> = Vec::new(); + let mut j = k + 1; + while let Some(&n) = words.get(j) { + let name = self.tokens[n].to_owned(); + let mut array = String::new(); + j += 1; + let mut nesting = 0; + while let Some(&t) = words.get(j) { + match self.tokens[t] { + "," if nesting == 0 => break, + "(" | "[" => nesting += 1, + ")" | "]" => nesting -= 1, + _ => {} + } + if self.tokens[t] == "=" && nesting == 0 { + // An initializer: uniforms are set by the game, so it is dropped. + while words.get(j).is_some_and(|&t| self.tokens[t] != ",") { + j += 1; + } + break; + } + array.push_str(self.tokens[t]); + j += 1; + } + j += 1; + declarators.push((name, array)); + } + let fragment = self.kind == ShaderStage::Fragment; + let input = matches!(storage, "in" | "attribute") || (storage == "varying" && fragment); + self.removed.push(from..to + 1); + for (name, array) in declarators { + let decl = format!("{interpolation}{ty} @{array};"); + match storage { + "uniform" if ty.contains("sampler") => self.samplers.push((name, ty.clone())), + "uniform" if ty.contains("image") => self + .errors + .push(self.error(ty_at, &format!("`{name}`: images are not supported yet"))), + "uniform" => self.uniforms.push((name, format!("{ty}{array}"), ty_at)), + _ if input && !fragment => { + let value = attribute_value(&name, &ty); + self.attributes.push((name, value)); + self.removed.pop(); + // A private global of ours now: `in`/`attribute` and qualifiers go. + for &w in &words[..k] { + self.removed.push(w..w + 1); + } + } + _ if input => self.ins.push(Var { name, decl }), + _ if fragment => { + let n = location.unwrap_or(self.outputs.len() as u32); + self.outputs.insert(n); + self.frag_outs + .push((n, format!("layout(location = {n}) out {ty} {name}{array};"))); + } + _ => self.outs.push(Var { name, decl }), + } + } + } + + /// A global `T name = a ? b : c;`: naga can't evaluate a conditional at global scope, so + /// the initializer becomes a function of ours on the same line (`T name; T hz_init_name() { + /// return a ? b : c; }`), called by our `main` before the pack's. + pub(super) fn initializer(&mut self, words: &[usize], to: usize) { + let text = |i: usize| self.tokens[i]; + let Some(eq) = words.iter().position(|&i| text(i) == "=") else { + return; + }; + if !words[eq..].iter().any(|&i| text(i) == "?") + || words[..eq].iter().any(|&i| text(i) == ",") + { + return; + } + let Some(name_at) = words[..eq].iter().rposition(|&i| is_ident(text(i))) else { + return; + }; + let name = text(words[name_at]).to_owned(); + let array: String = words[name_at + 1..eq].iter().map(|&i| text(i)).collect(); + let ty: Vec<&str> = words[..name_at] + .iter() + .map(|&i| text(i)) + .filter(|&t| t != "const") + .collect(); + let Some(ty) = ty.last() else { return }; + for &i in &words[..name_at] { + if text(i) == "const" { + self.removed.push(i..i + 1); + } + } + self.replaced.insert( + words[eq], + format!("; {ty}{array} hz_init_{name}() {{ return"), + ); + self.replaced.insert(to, "; }".into()); + self.inits.push(name); + } + + /// A function definition or prototype: remember which parameters are samplers. + pub(super) fn function(&mut self, words: &[usize]) { + let Some(open) = words.iter().position(|&i| self.tokens[i] == "(") else { + return; + }; + let Some(&name) = open.checked_sub(1).and_then(|n| words.get(n)) else { + return; + }; + let mut params = Vec::new(); + let mut is_sampler = false; + for &i in &words[open + 1..] { + match self.tokens[i] { + "," | ")" => { + params.push(is_sampler); + is_sampler = false; + } + t if t.contains("sampler") => is_sampler = true, + _ => {} + } + } + if params.iter().any(|&p| p) { + self.sampler_functions + .insert(self.tokens[name].to_owned(), params); + } + } +} diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index c252df6e3..4fac4b0d7 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -5,7 +5,11 @@ use std::path::Path; use std::time::Instant; -use horizon_shaders::{Lang, Macros, Pack, Properties, discover, preprocess}; +use std::collections::BTreeMap; + +use horizon_shaders::{ + Lang, Macros, Pack, Properties, Target, discover, preprocess, translate_composite, +}; #[test] #[ignore = "needs HORIZON_TEST_SHADER_PACKS"] @@ -86,6 +90,73 @@ fn real_packs_read() { for d in diagnostics.iter().take(8) { println!(" {d}"); } + // Every composite-style program with both stages, translated and validated. + let start = Instant::now(); + let (mut ok, mut failed, mut causes) = (0, 0, BTreeMap::::new()); + for dir in ["", "world0", "world-1", "world1"] { + for file in pack.list(dir) { + let Some(name) = file.strip_suffix(".fsh") else { + continue; + }; + let composite = [ + "composite", + "deferred", + "final", + "prepare", + "begin", + "shadowcomp", + ] + .iter() + .any(|p| { + name.strip_prefix(p) + .is_some_and(|n| n.chars().all(|c| c.is_ascii_digit())) + }); + let path = |ext: &str| { + if dir.is_empty() { + format!("{name}.{ext}") + } else { + format!("{dir}/{name}.{ext}") + } + }; + if !composite || !pack.exists(&path("vsh")) { + continue; + } + let target = if name == "final" { + Target::Window + } else { + Target::Buffers + }; + let v = preprocess(&pack, &path("vsh"), ¯os); + let fr = preprocess(&pack, &path("fsh"), ¯os); + match translate_composite(&v, &fr, target) { + Ok(_) => ok += 1, + Err(errors) => { + failed += 1; + let first = &errors[0]; + let cause: String = first.message.chars().take(90).collect(); + *causes.entry(cause).or_default() += 1; + if std::env::var_os("SHOW_ERRORS").is_some() { + println!( + " {}: {}:{}: {}", + path("fsh"), + first.file, + first.line, + first.message + ); + } + } + } + } + } + println!( + " composite-style programs: {ok} translate, {failed} fail, {:.0} ms", + start.elapsed().as_secs_f64() * 1000.0 + ); + let mut causes: Vec<_> = causes.into_iter().collect(); + causes.sort_by_key(|c| std::cmp::Reverse(c.1)); + for (cause, n) in causes.iter().take(6) { + println!(" {n} x {cause}"); + } assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } From d83b3e18bc98d93c20fd47ce5b85367a324f7900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Dr=C3=A4ger?= <20014538+sdraeger@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:21:23 -0700 Subject: [PATCH 07/50] Keep mob pitch on /tp --- crates/horizon-client/src/game/commands.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index a229cbdc9..bab925c4f 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1098,9 +1098,11 @@ impl Game<'_> { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { m.position = v; m.previous = v; - if let Some([yaw, _]) = rotation { + if let Some([yaw, pitch]) = rotation { m.yaw = yaw; m.body_yaw = yaw; + // Entity.setXRot clamps to -90..90. + m.head_pitch = pitch.clamp(-90.0, 90.0); } } } From 891e1c12830ce848d799e0b8829bffc5832fcf04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Dr=C3=A4ger?= <20014538+sdraeger@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:21:23 -0700 Subject: [PATCH 08/50] Run client mod hooks on the server thread --- crates/horizon-client/src/game/chat.rs | 8 +++++++- crates/horizon-client/src/game/remote.rs | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index f760bd82f..1bc30513b 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -570,8 +570,14 @@ impl Game<'_> { /// ChatScreen.handleChatInput: history, then a command or a chat line. pub(super) fn handle_chat_input(&mut self, message: &str) { self.ui.chat.add_recent(message); - // On a server (single player's own included) the server runs it. + // On a server (single player's own included) the server runs it, + // unless a client mod owns the command. if self.remote.is_some() { + if let Some(command) = message.strip_prefix('/') + && self.mod_command(command) + { + return; + } self.send_chat(message); return; } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 32cffa306..23c6677c2 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -733,6 +733,9 @@ impl Game<'_> { self.ticks, self.screen == Screen::Chat, ); + if self.mods.running() { + self.mods_chat(&text); + } if let Some(r) = self.remote.as_mut() { r.chat.push((text, Instant::now())); r.chat_received += 1; @@ -3041,6 +3044,8 @@ impl Game<'_> { /// One client tick while connected (Minecraft.tick with a ClientLevel). pub(super) fn tick_remote(&mut self) -> Result<()> { self.tick_remote_level()?; + // Client mods tick on any server, single player's own included. + self.tick_mods(); // Minecraft.tick sends this even when the player cannot tick yet, // including while waiting for chunks. Keep it outside early returns. if self.remote.as_ref().is_some_and(|r| r.joined) { From 72f88befb804421c88c9584b02f0974c33508d0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Dr=C3=A4ger?= <20014538+sdraeger@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:21:23 -0700 Subject: [PATCH 09/50] Allow spaces in horizon-ctl screenshot paths --- crates/horizon-client/src/game/control.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 205a66074..c6690d435 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -503,7 +503,10 @@ impl Game<'_> { if self.pending_screenshot.is_some() { return Err("a screenshot is already pending".into()); } - renderer.screenshot = Some(arg(1)?.into()); + arg(1)?; + // The rest of the line: a path may hold spaces. + let path = line.trim_start().strip_prefix("screenshot").unwrap_or(""); + renderer.screenshot = Some(path.trim().into()); self.pending_screenshot = Some(reply.clone()); Ok(None) } From d064dba945969f26b74231e34e2010b1523dc946 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:23:43 +0200 Subject: [PATCH 10/50] Shader packs, clean room 7: buffer settings and output lists from stage files The configuration packs write into their stage files, read from live code only (the preprocessor has dropped false branches, so an option's choice is the one in effect): - the buffers a fragment shader writes: the last live DRAWBUFFERS or RENDERTARGETS comment, at most 8, buffers 0-7 without one; - per colortex buffer (names and aliases): Format in block comments, with every format name of the spec in any case, as channels, bits and kind; Clear, ClearColor and MipmapEnabled. A later file changing a setting, an unknown format and a value of the wrong type are reported with their file and line. Buffer 1 becomes RGBA32F when a program declares gdepth and the pack set no format. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/config.rs | 431 +++++++++++++++++++++++++++ crates/horizon-shaders/src/lib.rs | 3 + 2 files changed, 434 insertions(+) create mode 100644 crates/horizon-shaders/src/config.rs diff --git a/crates/horizon-shaders/src/config.rs b/crates/horizon-shaders/src/config.rs new file mode 100644 index 000000000..0f21e4900 --- /dev/null +++ b/crates/horizon-shaders/src/config.rs @@ -0,0 +1,431 @@ +//! The configuration packs write into their stage files (spec 1.7, 6.1): which buffers a fragment +//! shader writes (`/* DRAWBUFFERS:0257 */`, `/* RENDERTARGETS: 0,2 */`) and, per colortex buffer, +//! its format (`const int colortex2Format = RGBA16F;`, inside a block comment because the format +//! names are not GLSL), whether and to what it is cleared, and whether it gets mipmaps. +//! +//! Everything is read from live code only: the preprocessor has already dropped the false +//! branches, so a format chosen by an option is the one the option selects. Every stage file of +//! the programs that run is read (OQ-17); a later file overrides an earlier one, with a note when +//! the values differ. + +use crate::Preprocessed; + +/// colortex buffers (6.1.1). +pub const BUFFERS: usize = 16; + +/// A colour format of 6.7, as channels, bits per channel and kind. Packed formats are described +/// by their widest channel: `RGB10_A2` is 4 × 10 bits, `R11F_G11F_B10F` 3 × 11 bits float. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Format { + pub channels: u8, + pub bits: u8, + pub kind: Kind, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Unorm, + Snorm, + Float, + Int, + Uint, +} + +impl Format { + /// The default `RGBA` (6.1.3). + pub const RGBA8: Format = Format { + channels: 4, + bits: 8, + kind: Kind::Unorm, + }; + + /// A format name of 6.7, in any case. + pub fn parse(name: &str) -> Option { + let name = name.trim().to_ascii_uppercase(); + let packed = |channels, bits, kind| { + Some(Format { + channels, + bits, + kind, + }) + }; + match name.as_str() { + "RGBA" => return Some(Self::RGBA8), + "R3_G3_B2" => return packed(3, 3, Kind::Unorm), + "RGB5_A1" => return packed(4, 5, Kind::Unorm), + "RGB10_A2" => return packed(4, 10, Kind::Unorm), + "RGB10_A2UI" => return packed(4, 10, Kind::Uint), + "R11F_G11F_B10F" => return packed(3, 11, Kind::Float), + "RGB9_E5" => return packed(3, 9, Kind::Float), + "RGBA4" => return packed(4, 4, Kind::Unorm), + "RGB565" => return packed(3, 6, Kind::Unorm), + "RGBA2" => return packed(4, 2, Kind::Unorm), + _ => {} + } + let letters = name.bytes().take_while(u8::is_ascii_alphabetic).count(); + let channels = match &name[..letters] { + "R" => 1, + "RG" => 2, + "RGB" => 3, + "RGBA" => 4, + _ => return None, + }; + let rest = &name[letters..]; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + let bits: u8 = rest[..digits].parse().ok()?; + let kind = match &rest[digits..] { + "" => Kind::Unorm, + "_SNORM" => Kind::Snorm, + "F" => Kind::Float, + "I" => Kind::Int, + "UI" => Kind::Uint, + _ => return None, + }; + let valid = match kind { + Kind::Unorm | Kind::Snorm => matches!(bits, 8 | 16), + Kind::Float => matches!(bits, 16 | 32), + Kind::Int | Kind::Uint => matches!(bits, 8 | 16 | 32), + }; + valid.then_some(Format { + channels, + bits, + kind, + }) + } +} + +/// The settings of one colortex buffer. +#[derive(Clone, Debug, PartialEq)] +pub struct Buffer { + pub format: Format, + /// Cleared every frame (`Clear`, default true). + pub clear: bool, + /// `ClearColor`, else 6.1.5's default: colortex0 the fog colour, colortex1 white, + /// the others transparent black. + pub clear_color: Option<[f32; 4]>, + /// `MipmapEnabled`. + pub mipmap: bool, +} + +impl Default for Buffer { + fn default() -> Self { + Self { + format: Format::RGBA8, + clear: true, + clear_color: None, + mipmap: false, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Config { + pub buffers: [Buffer; BUFFERS], + /// Conflicting or unreadable settings, as `file:line: message`. + pub notes: Vec, + format_set: [bool; BUFFERS], + /// Each setting read so far and its value, to report a later file changing it. + seen: Vec<(String, String)>, + gdepth: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + buffers: std::array::from_fn(|_| Buffer::default()), + notes: Vec::new(), + format_set: [false; BUFFERS], + seen: Vec::new(), + gdepth: false, + } + } +} + +/// The colortex index of a buffer name or alias (6.1.1). +pub fn buffer_index(name: &str) -> Option { + let alias = [ + "gcolor", + "gdepth", + "gnormal", + "composite", + "gaux1", + "gaux2", + "gaux3", + "gaux4", + ]; + if let Some(i) = alias.iter().position(|a| *a == name) { + return Some(i); + } + let n: usize = name.strip_prefix("colortex")?.parse().ok()?; + (n < BUFFERS).then_some(n) +} + +impl Config { + /// Reads one preprocessed stage file. + pub fn read(&mut self, pp: &Preprocessed) { + let line = |at: usize| pp.locate(code_line(&pp.code, at)); + for (at, ty, name, value) in constants(&pp.code) { + let place = line(at); + self.constant(&place, ty, name, value); + } + for (origin, comment) in &pp.comments { + for (_, ty, name, value) in constants(comment) { + let file = pp + .files + .get(origin.file as usize) + .map_or("?", String::as_str); + let place = format!("{file}:{}", origin.line); + self.constant(&place, ty, name, value); + } + } + if declares(&pp.code, "gdepth") { + self.gdepth = true; + } + } + + /// The settings once every file is read: buffer 1 becomes RGBA32F when a program declares + /// `gdepth` and the pack gave it no format (6.1.3). + pub fn finish(mut self) -> Self { + if self.gdepth && !self.format_set[1] { + self.buffers[1].format = Format { + channels: 4, + bits: 32, + kind: Kind::Float, + }; + } + self + } + + fn constant(&mut self, place: &str, ty: &str, name: &str, value: &str) { + let settings = [ + ("Format", "int"), + ("ClearColor", "vec4"), + ("Clear", "bool"), + ("MipmapEnabled", "bool"), + ]; + let Some((buffer, setting)) = settings.iter().find_map(|(suffix, want)| { + let buffer = buffer_index(name.strip_suffix(suffix)?)?; + (ty == *want).then_some((buffer, *suffix)) + }) else { + return; + }; + let b = &mut self.buffers[buffer]; + let ok = match setting { + "Format" => Format::parse(value).map(|f| { + b.format = f; + self.format_set[buffer] = true; + }), + "ClearColor" => vec4(value).map(|c| b.clear_color = Some(c)), + "Clear" => boolean(value).map(|v| b.clear = v), + _ => boolean(value).map(|v| b.mipmap = v), + }; + if ok.is_none() { + let problem = match setting { + "Format" => format!("unknown format `{value}`"), + "ClearColor" => format!("`{name}` is not a vec4: `{value}`"), + _ => format!("`{name}` is not a bool: `{value}`"), + }; + self.notes.push(format!("{place}: {problem}")); + return; + } + // The same setting under another name (an alias) counts as the same. + let key = format!("{buffer}{setting}"); + match self.seen.iter_mut().find(|(k, _)| *k == key) { + Some((_, old)) if old != value => { + self.notes.push(format!( + "{place}: `{name}` changed by a later file to `{value}`" + )); + *old = value.to_owned(); + } + Some(_) => {} + None => self.seen.push((key, value.to_owned())), + } + } +} + +/// The buffers a fragment shader writes, from the last `DRAWBUFFERS` or `RENDERTARGETS` comment +/// of its live code, or `None` without one (then it writes buffers 0 to 7). 6.1.4. +pub fn draw_buffers(pp: &Preprocessed) -> Option> { + let mut found = None; + for (_, comment) in &pp.comments { + let text = comment.trim(); + if let Some(digits) = text.strip_prefix("DRAWBUFFERS:") { + let list: Option> = digits.trim().chars().map(|c| c.to_digit(10)).collect(); + found = list.or(found); + } else if let Some(list) = text.strip_prefix("RENDERTARGETS:") { + let list: Option> = list + .split(',') + .map(|n| { + n.trim() + .parse() + .ok() + .filter(|&n: &u32| (n as usize) < BUFFERS) + }) + .collect(); + found = list.or(found); + } + } + found.map(|mut list| { + list.truncate(8); + list + }) +} + +/// `const = ;` anywhere in `text`: (byte offset, type, name, value). +fn constants(text: &str) -> Vec<(usize, &str, &str, &str)> { + let mut out = Vec::new(); + let mut from = 0; + while let Some(found) = text[from..].find("const") { + let at = from + found; + from = at + 5; + let before = text[..at].chars().next_back(); + if before.is_some_and(|c| c.is_alphanumeric() || c == '_') { + continue; + } + let rest = &text[from..]; + let Some(end) = rest.find(';') else { break }; + let decl = &rest[..end]; + let Some((left, value)) = decl.split_once('=') else { + continue; + }; + let mut words = left.split_whitespace(); + if let (Some(ty), Some(name), None) = (words.next(), words.next(), words.next()) { + out.push((at, ty, name, value.trim())); + } + } + out +} + +/// Whether `text` declares a uniform sampler called `name`. +fn declares(text: &str, name: &str) -> bool { + text.split(';').any(|decl| { + let words: Vec<&str> = decl.split_whitespace().collect(); + words.first() == Some(&"uniform") && words.last() == Some(&name) + }) +} + +fn code_line(code: &str, at: usize) -> usize { + code[..at].matches('\n').count() +} + +fn boolean(value: &str) -> Option { + match value { + "true" => Some(true), + "false" => Some(false), + _ => None, + } +} + +fn number(text: &str) -> Option { + let text = text.trim(); + text.strip_suffix(['f', 'F']).unwrap_or(text).parse().ok() +} + +/// `vec4(r, g, b, a)` or `vec4(x)`. +fn vec4(value: &str) -> Option<[f32; 4]> { + let inner = value + .strip_prefix("vec4")? + .trim() + .strip_prefix('(')? + .strip_suffix(')')?; + let parts: Option> = inner.split(',').map(number).collect(); + match parts?.as_slice() { + [x] => Some([*x; 4]), + [r, g, b, a] => Some([*r, *g, *b, *a]), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + use crate::{Macros, Pack, preprocess}; + + fn stage(text: &str) -> Preprocessed { + let pack = Pack::from_zip_bytes("t", zip(&[("shaders/composite.fsh", text)])).unwrap(); + preprocess(&pack, "composite.fsh", &Macros::standard()) + } + + #[test] + fn formats_parse() { + let f = |channels, bits, kind| { + Some(Format { + channels, + bits, + kind, + }) + }; + assert_eq!(Format::parse("rgba16f"), f(4, 16, Kind::Float)); + assert_eq!(Format::parse("R32UI"), f(1, 32, Kind::Uint)); + assert_eq!(Format::parse("RG8_SNORM"), f(2, 8, Kind::Snorm)); + assert_eq!(Format::parse("R11F_G11F_B10F"), f(3, 11, Kind::Float)); + assert_eq!(Format::parse("RGB16"), f(3, 16, Kind::Unorm)); + assert_eq!(Format::parse("RGBA12"), None); + assert_eq!(Format::parse("R16_SNORMX"), None); + } + + #[test] + fn draw_buffers_and_constants_come_from_live_code() { + let pp = stage( + "#version 120\n\ + #define HQ\n\ + /*\n\ + const int colortex2Format = RGBA16F;\n\ + const int gaux4Format = R32F;\n\ + */\n\ + #ifdef HQ\n\ + /* RENDERTARGETS: 0,2,11 */\n\ + #else\n\ + /* DRAWBUFFERS:01 */\n\ + const bool colortex5Clear = true;\n\ + #endif\n\ + const bool colortex5Clear = false;\n\ + const vec4 colortex3ClearColor = vec4(0.5, 0.25, 0.0, 1.0f);\n\ + const bool colortex3MipmapEnabled = true;\n\ + const vec4 gnormalClearColor = vec4(1.0);\n\ + uniform sampler2D gdepth;\n\ + void main() {}\n", + ); + assert_eq!(draw_buffers(&pp), Some(vec![0, 2, 11])); + let mut config = Config::default(); + config.read(&pp); + let config = config.finish(); + let b = &config.buffers; + assert_eq!(b[2].format, Format::parse("RGBA16F").unwrap()); + assert_eq!(b[7].format, Format::parse("R32F").unwrap()); + assert!(!b[5].clear && b[0].clear); + assert_eq!(b[3].clear_color, Some([0.5, 0.25, 0.0, 1.0])); + assert!(b[3].mipmap && !b[2].mipmap); + assert_eq!(b[2].clear_color, Some([1.0; 4])); + assert_eq!(b[1].format, Format::parse("RGBA32F").unwrap(), "gdepth"); + assert!(config.notes.is_empty(), "{:?}", config.notes); + } + + #[test] + fn no_directive_and_conflicts() { + let pp = stage("void main() {}\n"); + assert_eq!(draw_buffers(&pp), None); + let a = + stage("/* const int colortex1Format = RGB8; */\nconst bool colortex1Clear = maybe;\n"); + let b = + stage("/* const int colortex1Format = RGBA16; const int colortex2Format = XYZ; */\n"); + let mut config = Config::default(); + config.read(&a); + config.read(&b); + let notes = config.notes.join("\n"); + assert!( + notes.contains("composite.fsh:2: `colortex1Clear` is not a bool"), + "{notes}" + ); + assert!( + notes.contains("composite.fsh:1: `colortex1Format` changed"), + "{notes}" + ); + assert!(notes.contains("unknown format `XYZ`"), "{notes}"); + assert_eq!( + config.finish().buffers[1].format, + Format::parse("RGBA16").unwrap() + ); + } +} diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index bae3c0dcd..465658fdf 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -8,9 +8,11 @@ //! - [`Lang`]: the pack's translated names (8.4). //! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). //! - [`translate_composite`]: a composite-style program into naga modules and its interface (1.6, 2.2). +//! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). mod condition; +mod config; mod discover; mod lang; mod macros; @@ -20,6 +22,7 @@ mod properties; mod translate; pub use condition::eval_condition; +pub use config::{BUFFERS, Buffer, Config, Format, Kind, buffer_index, draw_buffers}; pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; From 6b2ec5f8307eb80eb1158eff35916973ee84aaf9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:23:43 +0200 Subject: [PATCH 11/50] Shader packs, clean room 8: a dimension's post-processing programs What the renderer needs to run a pack's post-processing, still without wgpu: the programs of one dimension (prepare, deferred, composite and final with their numbered variants, in the order they run), each preprocessed and translated, with the buffers it writes and the flips that follow it, and the buffer settings its stage files carry. - A dimension's folder (world0, world-1, world1) replaces the base folder when it exists. program..enabled expressions over switch options, flip.. keys and the virtual deferred_pre and composite_pre. Programs with a geometry stage, or that fail to preprocess or translate, are reported and skipped. - tests/packs/post-test: Horizon's own small test pack (tint, depth in colortex2, depth bands on half the screen, vignette). Black box over the public test packs: every dimension of every pack loads its post-processing programs (2 to 9) with no note. Nothing uses it yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 3 + crates/horizon-shaders/src/post.rs | 295 ++++++++++++++++++ .../tests/packs/post-test/README.md | 6 + .../packs/post-test/shaders/composite.fsh | 23 ++ .../packs/post-test/shaders/composite.vsh | 8 + .../packs/post-test/shaders/composite1.fsh | 25 ++ .../packs/post-test/shaders/composite1.vsh | 8 + .../tests/packs/post-test/shaders/final.fsh | 12 + .../tests/packs/post-test/shaders/final.vsh | 8 + .../post-test/shaders/shaders.properties | 2 + crates/horizon-shaders/tests/real_packs.rs | 18 +- 11 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 crates/horizon-shaders/src/post.rs create mode 100644 crates/horizon-shaders/tests/packs/post-test/README.md create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh create mode 100644 crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index 465658fdf..fa39de067 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -9,6 +9,7 @@ //! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). //! - [`translate_composite`]: a composite-style program into naga modules and its interface (1.6, 2.2). //! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1). +//! - [`load_post`]: a dimension's `prepare`, `deferred`, `composite` and `final` programs, in order (2.1). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). mod condition; @@ -17,6 +18,7 @@ mod discover; mod lang; mod macros; mod pack; +mod post; mod preprocess; mod properties; mod translate; @@ -27,6 +29,7 @@ pub use discover::{Found, discover}; pub use lang::Lang; pub use macros::{MC_VERSION, Macros, RENDER_STAGES}; pub use pack::{Pack, PackError}; +pub use post::{Group, Post, PostProgram, dimension_folder, load_post}; pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; pub use properties::{Entry, Note, Properties}; pub use translate::{Error, Interface, Program, Sampler, Target, Uniform, translate_composite}; diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs new file mode 100644 index 000000000..f0427303e --- /dev/null +++ b/crates/horizon-shaders/src/post.rs @@ -0,0 +1,295 @@ +//! The post-processing programs of one dimension, ready for the renderer: `prepare`, `deferred`, +//! `composite` and `final` with their numbered variants, in the order they run (spec 2.1), each +//! preprocessed and translated, with the buffers it writes and the flips that follow it, and the +//! buffer settings their files carry (6.1). +//! +//! Decisions (impl-plan.md): +//! - OQ-12: a dimension's folder (`world0`, `world-1`, `world1`) replaces the base folder for that +//! dimension when it exists; every stage file is read from it. +//! - OQ-29: a program with a geometry stage is reported and treated as missing. +//! - A program that fails to translate is reported and treated as missing (2.5). + +use crate::config::{self, Config}; +use crate::{Macros, Pack, Preprocessed, Program, Properties, Target, eval_condition}; +use crate::{preprocess, translate_composite}; + +/// Which group of 2.1 a program belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Group { + Prepare, + Deferred, + Composite, + Final, +} + +const GROUPS: [(Group, &str); 4] = [ + (Group::Prepare, "prepare"), + (Group::Deferred, "deferred"), + (Group::Composite, "composite"), + (Group::Final, "final"), +]; + +pub struct PostProgram { + /// The program's name (`composite3`). + pub name: String, + pub group: Group, + pub program: Program, + /// colortex buffer of each fragment output location (location n writes `buffers[n]`). + pub buffers: Vec, + /// `flip..`: buffers forced to flip (true) or kept (false) after this program. + pub flips: Vec<(u32, bool)>, +} + +pub struct Post { + /// The folder the programs came from: `""` for `shaders` itself, else `world0`... + pub folder: String, + pub programs: Vec, + /// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6). + pub deferred_pre: Vec<(u32, bool)>, + pub composite_pre: Vec<(u32, bool)>, + pub config: Config, + /// Programs skipped and why, as `program: message` or `file:line: message`. + pub notes: Vec, +} + +/// The folder a dimension reads its programs from: `world` when the pack has it, else the +/// base folder `""` (1.2). `id` is the legacy number: 0 the Overworld, -1 the Nether, 1 the End. +pub fn dimension_folder(pack: &Pack, id: i32) -> String { + let folder = format!("world{id}"); + if pack.list("").iter().any(|f| f == &folder) || !pack.list(&folder).is_empty() { + folder + } else { + String::new() + } +} + +/// Loads the post-processing programs of `folder`. +pub fn load_post(pack: &Pack, folder: &str, properties: &Properties, macros: &Macros) -> Post { + let mut post = Post { + folder: folder.to_owned(), + programs: Vec::new(), + deferred_pre: flips(properties, "deferred_pre"), + composite_pre: flips(properties, "composite_pre"), + config: Config::default(), + notes: Vec::new(), + }; + let path = |name: &str, ext: &str| match folder { + "" => format!("{name}.{ext}"), + f => format!("{f}/{name}.{ext}"), + }; + for (group, base) in GROUPS { + let names: Vec = if group == Group::Final { + vec![base.to_owned()] + } else { + std::iter::once(base.to_owned()) + .chain((1..=99).map(|n| format!("{base}{n}"))) + .collect() + }; + for name in names { + let (vsh, fsh) = (path(&name, "vsh"), path(&name, "fsh")); + if !pack.exists(&fsh) || !pack.exists(&vsh) { + continue; + } + if !enabled(properties, folder, &name, macros) { + continue; + } + if pack.exists(&path(&name, "gsh")) { + post.notes.push(format!( + "{name}: uses a geometry stage (not supported); skipped" + )); + continue; + } + let vertex = preprocess(pack, &vsh, macros); + let fragment = preprocess(pack, &fsh, macros); + let problems: Vec = [&vertex, &fragment] + .iter() + .flat_map(|pp| diagnostics(pp)) + .collect(); + if !problems.is_empty() { + post.notes.extend(problems); + post.notes.push(format!("{name}: skipped")); + continue; + } + let target = match group { + Group::Final => Target::Window, + _ => Target::Buffers, + }; + let program = match translate_composite(&vertex, &fragment, target) { + Ok(p) => p, + Err(errors) => { + post.notes.extend( + errors + .iter() + .map(|e| format!("{}:{}: {}", e.file, e.line, e.message)), + ); + post.notes.push(format!("{name}: skipped")); + continue; + } + }; + post.config.read(&vertex); + post.config.read(&fragment); + let buffers = config::draw_buffers(&fragment).unwrap_or_else(|| (0..8).collect()); + post.programs.push(PostProgram { + flips: flips(properties, &name), + name, + group, + program, + buffers, + }); + } + } + post.config = std::mem::take(&mut post.config).finish(); + post +} + +fn diagnostics(pp: &Preprocessed) -> Vec { + pp.diagnostics + .iter() + .map(|d| { + let file = pp + .files + .get(d.origin.file as usize) + .map_or("?", String::as_str); + format!("{file}:{}: {}", d.origin.line, d.message) + }) + .collect() +} + +/// `program..enabled` (or `program./.enabled`): a boolean expression over +/// switch options (3.3). An option is on when its macro is defined. +fn enabled(properties: &Properties, folder: &str, name: &str, macros: &Macros) -> bool { + let keys = [ + format!("program.{folder}/{name}.enabled"), + format!("program.{name}.enabled"), + ]; + let Some(expr) = keys.iter().find_map(|k| properties.get(k)) else { + return true; + }; + // `true`/`false` as numbers, every other name as `defined(name)`, then the `#if` arithmetic. + let mut condition = String::new(); + for token in crate::macros::lex(expr) { + match token { + "true" => condition.push('1'), + "false" => condition.push('0'), + t if t.starts_with(|c: char| c.is_alphabetic() || c == '_') => { + condition.push_str(&format!("defined({t})")); + } + t => condition.push_str(t), + } + } + eval_condition(&condition, macros).ok() != Some(0) +} + +/// `flip..=` keys of one program. +fn flips(properties: &Properties, program: &str) -> Vec<(u32, bool)> { + let prefix = format!("flip.{program}."); + properties + .entries + .iter() + .filter_map(|e| { + let buffer = config::buffer_index(e.key.strip_prefix(&prefix)?)?; + let value = match e.value.trim() { + "true" => true, + "false" => false, + _ => return None, + }; + Some((buffer as u32, value)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pack::tests::zip; + + const VSH: &str = "#version 120\nvarying vec2 uv;\nvoid main() {\n\ + gl_Position = ftransform();\nuv = gl_MultiTexCoord0.xy;\n}\n"; + const FSH: &str = "#version 120\nuniform sampler2D colortex0;\nvarying vec2 uv;\n\ + void main() {\n/* DRAWBUFFERS:3 */\n\ + gl_FragData[0] = texture2D(colortex0, uv);\n}\n"; + + #[test] + fn programs_load_in_order_with_their_settings() { + let pack = Pack::from_zip_bytes( + "t", + zip(&[ + ("shaders/composite.vsh", VSH), + ("shaders/composite.fsh", FSH), + ("shaders/composite2.vsh", VSH), + ("shaders/composite2.fsh", FSH), + ("shaders/deferred.vsh", VSH), + ( + "shaders/deferred.fsh", + "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n", + ), + ("shaders/composite1.fsh", FSH), + ("shaders/composite3.vsh", VSH), + ("shaders/composite3.fsh", FSH), + ("shaders/composite4.vsh", VSH), + ("shaders/composite4.fsh", FSH), + ("shaders/composite4.gsh", "x"), + ("shaders/composite5.vsh", VSH), + ( + "shaders/composite5.fsh", + "#version 120\nvoid main() { undefined(); }\n", + ), + ("shaders/final.vsh", VSH), + ("shaders/final.fsh", FSH), + ("shaders/world-1/final.vsh", VSH), + ("shaders/world-1/final.fsh", FSH), + ]), + ) + .unwrap(); + let properties = Properties::parse( + "program.composite3.enabled = HQ && !LOW\nflip.composite2.gaux1=true\n\ + flip.composite.colortex3 = false\nflip.composite_pre.colortex1=true\n", + &Macros::default(), + ); + assert_eq!(dimension_folder(&pack, 0), ""); + assert_eq!(dimension_folder(&pack, -1), "world-1"); + let post = load_post(&pack, "", &properties, &Macros::standard()); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["deferred", "composite", "composite2", "final"]); + let p = &post.programs; + assert_eq!(p[0].buffers, (0..8).collect::>()); + assert_eq!(p[1].buffers, [3]); + assert_eq!(p[1].flips, [(3, false)]); + assert_eq!(p[2].flips, [(4, true)]); + assert_eq!(post.composite_pre, [(1, true)]); + assert_eq!(p[3].group, Group::Final); + let notes = post.notes.join("\n"); + assert!( + notes.contains("composite4: uses a geometry stage"), + "{notes}" + ); + assert!(notes.contains("composite5.fsh:2:"), "{notes}"); + assert!(notes.contains("composite5: skipped"), "{notes}"); + + let mut macros = Macros::standard(); + macros.define("HQ", ""); + let post = load_post(&pack, "world-1", &properties, ¯os); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["final"]); + let post = load_post(&pack, "", &properties, ¯os); + assert!(post.programs.iter().any(|p| p.name == "composite3")); + } + + #[test] + fn horizons_test_pack_loads() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/post-test"); + let pack = Pack::open(&dir).unwrap(); + let macros = Macros::standard(); + let properties = Properties::parse(&pack.read_text("shaders.properties").unwrap(), ¯os); + let post = load_post(&pack, &dimension_folder(&pack, 0), &properties, ¯os); + assert!(post.notes.is_empty(), "{:?}", post.notes); + let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); + assert_eq!(names, ["composite", "composite1", "final"]); + assert_eq!(post.programs[0].buffers, [0, 2]); + assert_eq!(post.programs[1].buffers, [0]); + assert_eq!( + post.config.buffers[2].format, + crate::Format::parse("RGBA16F").unwrap() + ); + } +} diff --git a/crates/horizon-shaders/tests/packs/post-test/README.md b/crates/horizon-shaders/tests/packs/post-test/README.md new file mode 100644 index 000000000..c3d124eec --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/README.md @@ -0,0 +1,6 @@ +# post-test + +Horizon's own shader pack for testing post-processing (written for Horizon, no other pack's code). +`composite` tints the world warm and stores linear depth in colortex2, `composite1` shows that +depth as fog bands on the right half with a little noise, and `final` adds a vignette. Copy the +folder into `shaderpacks/` and run the game with `HORIZON_SHADER_PACK=post-test`. diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh new file mode 100644 index 000000000..9261e9fae --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.fsh @@ -0,0 +1,23 @@ +#version 120 + +/* +const int colortex2Format = RGBA16F; +*/ + +uniform sampler2D colortex0; +uniform sampler2D depthtex0; +uniform mat4 gbufferProjectionInverse; +uniform float far; + +varying vec2 texcoord; + +void main() { + vec3 color = texture2D(colortex0, texcoord).rgb; + float depth = texture2D(depthtex0, texcoord).r; + // Window depth back to a view-space distance. + vec4 view = gbufferProjectionInverse * vec4(texcoord * 2.0 - 1.0, depth * 2.0 - 1.0, 1.0); + float distance = -view.z / view.w; + /* DRAWBUFFERS:02 */ + gl_FragData[0] = vec4(color * vec3(1.08, 1.0, 0.86), 1.0); + gl_FragData[1] = vec4(distance / far, depth, 0.0, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh new file mode 100644 index 000000000..6dbee8d1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite.vsh @@ -0,0 +1,8 @@ +#version 120 + +varying vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh new file mode 100644 index 000000000..5207ad758 --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.fsh @@ -0,0 +1,25 @@ +#version 330 compatibility + +uniform sampler2D colortex0; +uniform sampler2D colortex2; +uniform sampler2D noisetex; +uniform float viewWidth; +uniform float viewHeight; + +in vec2 texcoord; + +/* RENDERTARGETS: 0 */ +layout(location = 0) out vec4 outColor; + +void main() { + vec3 color = texture(colortex0, texcoord).rgb; + if (gl_FragCoord.x > viewWidth * 0.5) { + float d = clamp(texture(colortex2, texcoord).r, 0.0, 1.0); + float band = step(0.5, fract(d * 8.0)); + vec3 fog = mix(vec3(0.2, 0.45, 0.9), vec3(0.95, 0.75, 0.3), d); + color = mix(color, fog, 0.35 + 0.15 * band); + float grain = texture(noisetex, gl_FragCoord.xy / 256.0).r - 0.5; + color += grain * 0.04; + } + outColor = vec4(color, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh new file mode 100644 index 000000000..12ef6da1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/composite1.vsh @@ -0,0 +1,8 @@ +#version 330 compatibility + +out vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = gl_MultiTexCoord0.xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh b/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh new file mode 100644 index 000000000..5efbf50df --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/final.fsh @@ -0,0 +1,12 @@ +#version 120 + +uniform sampler2D colortex0; + +varying vec2 texcoord; + +void main() { + vec3 color = texture2D(colortex0, texcoord).rgb; + vec2 centred = texcoord - 0.5; + float vignette = 1.0 - dot(centred, centred) * 0.9; + gl_FragColor = vec4(color * vignette, 1.0); +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh b/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh new file mode 100644 index 000000000..6dbee8d1f --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/final.vsh @@ -0,0 +1,8 @@ +#version 120 + +varying vec2 texcoord; + +void main() { + gl_Position = ftransform(); + texcoord = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy; +} diff --git a/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties b/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties new file mode 100644 index 000000000..510ada3a9 --- /dev/null +++ b/crates/horizon-shaders/tests/packs/post-test/shaders/shaders.properties @@ -0,0 +1,2 @@ +# Horizon's post-processing test pack. +program.composite1.enabled = !NO_SPLIT diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index 4fac4b0d7..6885b40f7 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -8,7 +8,8 @@ use std::time::Instant; use std::collections::BTreeMap; use horizon_shaders::{ - Lang, Macros, Pack, Properties, Target, discover, preprocess, translate_composite, + Lang, Macros, Pack, Properties, Target, dimension_folder, discover, load_post, preprocess, + translate_composite, }; #[test] @@ -157,6 +158,21 @@ fn real_packs_read() { for (cause, n) in causes.iter().take(6) { println!(" {n} x {cause}"); } + // The post-processing set of each dimension, as the game loads it. + for id in [0, -1, 1] { + let start = std::time::Instant::now(); + let folder = dimension_folder(&pack, id); + let post = load_post(&pack, &folder, &shaders, &Macros::standard()); + println!( + " dimension {id} (folder `{folder}`): {} post programs, {} notes, {:.0} ms", + post.programs.len(), + post.notes.len() + post.config.notes.len(), + start.elapsed().as_secs_f64() * 1000.0 + ); + for n in post.notes.iter().chain(&post.config.notes).take(5) { + println!(" {n}"); + } + } assert!(!shaders.entries.is_empty(), "{}: no keys read", f.name); read += 1; } From 303d497a676cd9a2667ca0c28345f6fc1ad45ee7 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:26:42 +0200 Subject: [PATCH 12/50] Shader packs, clean room 9: frame uniforms, and gl_FragCoord in buffer passes The values a pack's post-processing programs read once per frame, in GL's conventions: camera position and the previous one, eye altitude, up/sun/moon/shadow-light positions in view space (the sky's own frame for the sun and moon), sun and shadow angles, world time, day and moon phase, frame counter, frame time and run time, view size and aspect, near and far, rain, smoothed wetness, thunder, fog colour, start and end, sky colour, eye in fluid, blindness, night vision, darkness, brightness, GUI hidden, eye brightness and its smoothed value, and the model-view and projection matrices with their inverses and last frame's. The projection is GL's perspective with the world's near and far planes times vanilla's view bobbing. Each value is written as the type the pack declared. The game passes what only it knows (day time, weather levels, the fluid at the camera, light at the camera, blindness, F1, brightness) in the renderer's Frame when HORIZON_SHADER_PACK is set. Fix (slice 4): programs that draw to buffers read gl_FragCoord through a variable only final set; every target sets it now, with a test. Found by the first frame drawn with a pack. Nothing reads the uniforms yet: players see no change. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 8 + crates/horizon-client/src/game/fov.rs | 2 +- .../horizon-client/src/game/shader_packs.rs | 34 ++ crates/horizon-client/src/gfx.rs | 3 + .../horizon-client/src/gfx/pack_uniforms.rs | 329 ++++++++++++++++++ crates/horizon-shaders/src/translate/mod.rs | 36 +- 6 files changed, 409 insertions(+), 3 deletions(-) create mode 100644 crates/horizon-client/src/game/shader_packs.rs create mode 100644 crates/horizon-client/src/gfx/pack_uniforms.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 533973805..15aabd222 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -138,6 +138,7 @@ mod screens; mod sculk; mod sculk_fixture; mod server_travel; +mod shader_packs; mod shield; mod sign_edit; mod sniffers; @@ -10063,6 +10064,11 @@ impl Game<'_> { } else { self.gather_dynamic_lights(camera, alpha); } + let pack_inputs = if title { + Default::default() + } else { + self.shader_pack_inputs(camera, alpha as f32) + }; let frame = Frame { weather: &weather, weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32), @@ -10146,6 +10152,7 @@ impl Game<'_> { hand: &hand, hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()), screen: &screen, + pack: pack_inputs, border, game_time: self.world.game_time as f32, shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0, @@ -12671,6 +12678,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { hand: &[], hand_fov_degrees: 70.0, screen: &[], + pack: Default::default(), border: None, game_time: 0.0, shader_game_time: 0.0, diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 37796c8c3..27d3fb61e 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -74,7 +74,7 @@ impl Game<'_> { }) } /// The fluid at the camera, as `Camera.getFluidInCamera` reports it. - fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid { + pub(super) fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid { match fog_type(&self.world, self.res.facts(), camera, self.forward()) { FogType::Water => CameraFluid::Water, FogType::Lava => CameraFluid::Lava, diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs new file mode 100644 index 000000000..e2cff8902 --- /dev/null +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -0,0 +1,34 @@ +//! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=` +//! (the runtime that uses it comes next). What the pack's uniforms need from the game. + +use horizon_client::camera::CameraFluid; +use horizon_client::gfx::pack_uniforms::PackInputs; + +use super::Game; + +impl Game<'_> { + /// This frame's inputs to the pack's uniforms. + pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs { + if std::env::var_os("HORIZON_SHADER_PACK").is_none() { + return PackInputs::default(); + } + let lerp = |(old, new): (f32, f32)| old + (new - old) * partial; + let at = camera.floor().as_ivec3(); + let light = self.world.light(at.x, at.y, at.z); + PackInputs { + day_time: self.world.day_time, + rain: lerp(self.world.weather.rain_level), + thunder: lerp(self.world.weather.thunder_level), + eye_in: match self.camera_fluid(camera.to_array()) { + CameraFluid::None => 0, + CameraFluid::Water => 1, + CameraFluid::Lava => 2, + CameraFluid::PowderSnow => 3, + }, + blindness: f32::from(u8::from(self.potions.effects.get("blindness").is_some())), + eye_light: [i32::from(light & 15), i32::from(light >> 4)], + hide_gui: self.ui.hide_gui, + brightness: self.settings.gamma, + } + } +} diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index c369ee1c7..02cac38d6 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -68,6 +68,7 @@ mod flat; pub mod lightning; mod lod; mod occlusion; +pub mod pack_uniforms; mod page_runs; pub mod panorama; mod resident; @@ -249,6 +250,8 @@ pub struct Frame<'a> { pub hand_fov_degrees: f32, /// Full-screen camera effects drawn over the world and hand, under the HUD. pub screen: &'a [crate::screen_effects::ScreenQuad], + /// What a shader pack's uniforms need from the game. + pub pack: pack_uniforms::PackInputs, /// The world border wall, when near enough to be drawn. pub border: Option, pub game_time: f32, diff --git a/crates/horizon-client/src/gfx/pack_uniforms.rs b/crates/horizon-client/src/gfx/pack_uniforms.rs new file mode 100644 index 000000000..7d2a73a6a --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_uniforms.rs @@ -0,0 +1,329 @@ +//! The uniforms a shader pack's post-processing programs read once per frame (spec 5.2, class +//! F), from the renderer's `Frame` and the few values only the game knows (`PackInputs`). +//! Matrices follow GL's conventions (5.5): `gbufferProjection` maps view depth to clip z in +//! [-w, w], and the depth buffers packs read hold GL window depth. + +use glam::{Mat4, Vec3, Vec4}; + +use super::Frame; + +/// What the game passes for the pack's uniforms, beyond what `Frame` carries. +#[derive(Clone, Copy, Debug, Default)] +pub struct PackInputs { + /// `Level.getDayTime`. + pub day_time: i64, + /// The interpolated rain and thunder levels, 0..1. + pub rain: f32, + pub thunder: f32, + /// The fluid at the camera: 0 none, 1 water, 2 lava, 3 powder snow. + pub eye_in: i32, + pub blindness: f32, + /// Block and sky light at the camera, 0..15. + pub eye_light: [i32; 2], + pub hide_gui: bool, + /// The brightness option, 0..1. + pub brightness: f32, +} + +/// One uniform's value: up to 16 floats or 3 integers. +#[derive(Clone, Copy, Debug)] +pub enum Value { + F([f32; 16]), + I([i32; 3]), +} + +impl Value { + fn f(v: &[f32]) -> Self { + let mut a = [0.0; 16]; + a[..v.len()].copy_from_slice(v); + Value::F(a) + } + fn i(v: &[i32]) -> Self { + let mut a = [0; 3]; + a[..v.len()].copy_from_slice(v); + Value::I(a) + } + + /// Writes the value as the type the pack declared, at `offset` of a std140 block. + pub fn write(&self, ty: &str, out: &mut [u8], offset: usize) { + let ints = matches!(ty, "int" | "bool" | "uint") || ty.starts_with(['i', 'u']); + let (floats, integers): ([f32; 16], [i32; 16]) = match *self { + Value::F(f) => (f, f.map(|v| v as i32)), + Value::I(i) => { + let mut f = [0.0; 16]; + let mut v = [0; 16]; + for k in 0..3 { + f[k] = i[k] as f32; + v[k] = i[k]; + } + (f, v) + } + }; + let count = match ty { + "mat4" => 16, + "mat3" => { + // std140: three columns, each padded to a vec4, from the mat4's upper left. + for c in 0..3 { + let column: [f32; 3] = std::array::from_fn(|r| floats[c * 4 + r]); + put(out, offset + c * 16, bytemuck::cast_slice(&column)); + } + return; + } + t => match t.as_bytes().last() { + Some(n @ b'2'..=b'4') if t.contains("vec") => (n - b'0') as usize, + _ => 1, + }, + }; + if ints { + put(out, offset, bytemuck::cast_slice(&integers[..count])); + } else { + put(out, offset, bytemuck::cast_slice(&floats[..count])); + } + } +} + +fn put(out: &mut [u8], offset: usize, bytes: &[u8]) { + if let Some(dst) = out.get_mut(offset..offset + bytes.len()) { + dst.copy_from_slice(bytes); + } +} + +/// The uniforms provided, in the order of [`FrameUniforms::values`]. +pub const NAMES: &[&str] = &[ + "cameraPosition", + "previousCameraPosition", + "eyeAltitude", + "upPosition", + "sunPosition", + "moonPosition", + "shadowLightPosition", + "sunAngle", + "shadowAngle", + "worldTime", + "worldDay", + "moonPhase", + "frameCounter", + "frameTime", + "frameTimeCounter", + "viewWidth", + "viewHeight", + "aspectRatio", + "near", + "far", + "rainStrength", + "wetness", + "thunderStrength", + "fogColor", + "skyColor", + "fogMode", + "fogStart", + "fogEnd", + "fogDensity", + "fogShape", + "isEyeInWater", + "blindness", + "nightVision", + "darknessFactor", + "darknessLightFactor", + "screenBrightness", + "hideGUI", + "eyeBrightness", + "eyeBrightnessSmooth", + "heldItemId", + "heldItemId2", + "heldBlockLightValue", + "heldBlockLightValue2", + "renderStage", + "gbufferModelView", + "gbufferModelViewInverse", + "gbufferProjection", + "gbufferProjectionInverse", + "gbufferPreviousModelView", + "gbufferPreviousProjection", +]; + +/// Values carried from frame to frame. +pub struct FrameUniforms { + pub values: Vec, + start: std::time::Instant, + last: Option, + frame: u32, + previous: Option<(Vec3, Mat4, Mat4)>, + wetness: f32, + eye_smooth: [f32; 2], +} + +impl Default for FrameUniforms { + fn default() -> Self { + Self { + values: Vec::new(), + start: std::time::Instant::now(), + last: None, + frame: 0, + previous: None, + wetness: 0.0, + eye_smooth: [0.0; 2], + } + } +} + +/// `(0, 100, 0)` on the frame the sky draws a body on: Y -90°, then X by its angle in degrees. +fn body(angle: f32) -> Vec3 { + (Mat4::from_rotation_y((-90.0f32).to_radians()) * Mat4::from_rotation_x(angle.to_radians())) + .transform_vector3(Vec3::new(0.0, 100.0, 0.0)) +} + +/// `value` moved toward `target` with a half-life of `half_life` seconds over `dt` (5.6). +fn smooth(value: f32, target: f32, half_life: f32, dt: f32) -> f32 { + target + (value - target) * 0.5f32.powf(dt / half_life) +} + +impl FrameUniforms { + /// This frame's values for a render size of `size` pixels. + pub fn update(&mut self, frame: &Frame, inputs: &PackInputs, size: [u32; 2]) { + let now = std::time::Instant::now(); + let dt = self.last.map_or(0.0, |l| (now - l).as_secs_f32()); + self.last = Some(now); + let aspect = size[0] as f32 / size[1].max(1) as f32; + let view = crate::camera::view_matrix(frame.yaw, frame.pitch); + let projection = Mat4::perspective_rh_gl( + frame.fov_degrees.to_radians(), + aspect, + 0.05, + frame.depth_far, + ) * frame.bob; + let camera = frame.camera.as_vec3(); + let (previous_camera, previous_view, previous_projection) = + self.previous + .unwrap_or((Vec3::ZERO, Mat4::ZERO, Mat4::ZERO)); + self.previous = Some((camera, view, projection)); + + let to_view = |v: Vec3| view.transform_vector3(v); + let sun_angle = (frame.sky.sun_angle / 360.0 + 0.25).rem_euclid(1.0); + let day = sun_angle < 0.5; + let sun = to_view(body(frame.sky.sun_angle)); + let moon = to_view(body(frame.sky.moon_angle)); + // Half-lives are given in ticks (7.2); smoothing runs per frame in seconds (OQ-26). + let half_life = if inputs.rain > self.wetness { + 600.0 + } else { + 200.0 + }; + self.wetness = smooth(self.wetness, inputs.rain, half_life / 20.0, dt); + let eye = inputs.eye_light.map(|l| (l * 16) as f32); + for (smoothed, target) in self.eye_smooth.iter_mut().zip(eye) { + *smoothed = smooth(*smoothed, target, 10.0 / 20.0, dt); + } + let (fog_color, fog_start, fog_end) = match &frame.fog { + Some(f) => ( + [f.color[0], f.color[1], f.color[2]], + f.environmental_start.min(f.render_distance_start), + f.environmental_end.min(f.render_distance_end), + ), + None => (frame.sky.fog_color, frame.distance * 0.9, frame.distance), + }; + let day_time = inputs.day_time.rem_euclid(24000) as i32; + let world_day = inputs.day_time.div_euclid(24000); + let seconds = (now - self.start).as_secs_f32() % 3600.0; + + let value = |name: &str| -> Value { + let v3 = |v: Vec3| Value::f(&v.to_array()); + let m4 = |m: Mat4| Value::f(&m.to_cols_array()); + match name { + "cameraPosition" => v3(camera), + "previousCameraPosition" => v3(previous_camera), + "eyeAltitude" => Value::f(&[frame.camera.y as f32]), + "upPosition" => v3(to_view(Vec3::new(0.0, 100.0, 0.0))), + "sunPosition" => v3(sun), + "moonPosition" => v3(moon), + "shadowLightPosition" => v3(if day { sun } else { moon }), + "sunAngle" => Value::f(&[sun_angle]), + "shadowAngle" => Value::f(&[if day { sun_angle } else { sun_angle - 0.5 }]), + "worldTime" => Value::i(&[day_time]), + "worldDay" => Value::i(&[world_day as i32]), + "moonPhase" => Value::i(&[world_day.rem_euclid(8) as i32]), + "frameCounter" => Value::i(&[self.frame as i32]), + "frameTime" => Value::f(&[dt]), + "frameTimeCounter" => Value::f(&[seconds]), + "viewWidth" => Value::f(&[size[0] as f32]), + "viewHeight" => Value::f(&[size[1] as f32]), + "aspectRatio" => Value::f(&[aspect]), + "near" => Value::f(&[0.05]), + "far" => Value::f(&[frame.distance]), + "rainStrength" => Value::f(&[inputs.rain]), + "wetness" => Value::f(&[self.wetness]), + "thunderStrength" => Value::f(&[inputs.thunder]), + "fogColor" => Value::f(&fog_color), + "skyColor" => Value::f(&frame.sky.sky_color), + // GL_LINEAR: vanilla's fog is linear. + "fogMode" => Value::i(&[9729]), + "fogStart" => Value::f(&[fog_start]), + "fogEnd" => Value::f(&[fog_end]), + "fogDensity" => Value::f(&[1.0]), + "fogShape" => Value::i(&[0]), + "isEyeInWater" => Value::i(&[inputs.eye_in]), + "blindness" => Value::f(&[inputs.blindness]), + "nightVision" => Value::f(&[frame.night_vision]), + "darknessFactor" => Value::f(&[frame.darkness.0]), + "darknessLightFactor" => Value::f(&[frame.darkness.1]), + "screenBrightness" => Value::f(&[inputs.brightness]), + "hideGUI" => Value::i(&[inputs.hide_gui as i32]), + "eyeBrightness" => Value::i(&eye.map(|v| v as i32)), + "eyeBrightnessSmooth" => Value::i(&self.eye_smooth.map(|v| v.round() as i32)), + "gbufferModelView" => m4(view), + "gbufferModelViewInverse" => m4(view.inverse()), + "gbufferProjection" => m4(projection), + "gbufferProjectionInverse" => m4(projection.inverse()), + "gbufferPreviousModelView" => m4(previous_view), + "gbufferPreviousProjection" => m4(previous_projection), + // Held items wait for `item.properties` (5.3: 0 without the file); renderStage + // is NONE outside gbuffers draws. + _ => Value::i(&[0]), + } + }; + self.values = NAMES.iter().map(|n| value(n)).collect(); + self.frame = (self.frame + 1) % 720_720; + } + + /// `hz_fog_color` and `hz_fog` of the translator's block (the compatibility `gl_Fog`). + pub fn fog(&self) -> [Vec4; 2] { + let get = |name| match index(name).and_then(|i| self.values.get(i)) { + Some(Value::F(f)) => *f, + _ => [0.0; 16], + }; + let (color, start, end) = (get("fogColor"), get("fogStart")[0], get("fogEnd")[0]); + [ + Vec4::new(color[0], color[1], color[2], 1.0), + Vec4::new(1.0, start, end, 1.0 / (end - start).max(1e-3)), + ] + } +} + +/// The position of `name` in [`NAMES`]. +pub fn index(name: &str) -> Option { + NAMES.iter().position(|n| *n == name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn values_follow_the_declared_type() { + let mut out = vec![0u8; 64]; + Value::i(&[7]).write("float", &mut out, 0); + assert_eq!(f32::from_ne_bytes(out[0..4].try_into().unwrap()), 7.0); + Value::f(&[1.5, 2.5]).write("ivec2", &mut out, 4); + assert_eq!(i32::from_ne_bytes(out[8..12].try_into().unwrap()), 2); + Value::i(&[1]).write("bool", &mut out, 16); + assert_eq!(i32::from_ne_bytes(out[16..20].try_into().unwrap()), 1); + Value::f(&[1.0; 16]).write("mat4", &mut out, 60); + assert_eq!(out[60..64], [0; 4], "a value past the block is dropped"); + } + + #[test] + fn noon_is_straight_up_and_the_sun_sets_in_the_west() { + assert!(body(0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3)); + assert!(body(90.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3)); + } +} diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs index 9cc2ea07a..c6435025d 100644 --- a/crates/horizon-shaders/src/translate/mod.rs +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -193,8 +193,11 @@ pub fn translate_composite( .unwrap(); let mut fragment_main = String::from("\nvoid main() {\n"); fragment_main.push_str(fog); - if target == Target::Window && f.frag_coord { - fragment_main.push_str(" hz_FragCoord = vec4(gl_FragCoord.x, hz_target.y - gl_FragCoord.y, gl_FragCoord.zw);\n"); + if f.frag_coord { + fragment_main.push_str(match target { + Target::Window => " hz_FragCoord = vec4(gl_FragCoord.x, hz_target.y - gl_FragCoord.y, gl_FragCoord.zw);\n", + Target::Buffers => " hz_FragCoord = gl_FragCoord;\n", + }); } for name in &f.inits { writeln!(fragment_main, " {name} = hz_init_{name}();").unwrap(); @@ -412,6 +415,35 @@ mod tests { ); } + #[test] + fn frag_coord_is_set_for_every_target() { + let fsh = "#version 120\nvoid main() { gl_FragColor = vec4(gl_FragCoord.x); }\n"; + for target in [Target::Buffers, Target::Window] { + let p = program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + target, + ) + .unwrap(); + let m = &p.fragment; + let (handle, _) = m + .global_variables + .iter() + .find(|(_, g)| g.name.as_deref() == Some("hz_FragCoord")) + .unwrap(); + // naga calls our `main` from its entry point: look in every function. + let functions = m.functions.iter().map(|(_, f)| f); + let stores = functions.chain([&m.entry_points[0].function]).any(|f| { + f.body.iter().any(|s| match s { + naga::Statement::Store { pointer, .. } => { + matches!(f.expressions[*pointer], naga::Expression::GlobalVariable(h) if h == handle) + } + _ => false, + }) + }); + assert!(stores, "{target:?}: main does not set hz_FragCoord"); + } + } + #[test] fn errors_point_at_the_pack_file_and_line() { let fsh = From 7b56b59e7c08af912cba137148c958cb6da8ce2e Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:04:36 +0200 Subject: [PATCH 13/50] Mod registry: the submission form's service (crates/horizon-mod-registry) Its own workspace, as the telemetry server's, for its own Railway service. GitHub sign-in with no scope (the user's token reads the login once and is dropped), signed-cookie sessions, an origin check and a CSRF token, six submissions per account and sixty in all per hour. A submission runs the file's own checks (horizon_mods::index::inspect, the signature, the entry's fields through registry::project), uploads it to the registry's `staging` release as ., writes mods/.toml on a new branch with the submitter as co-author, and opens the pull request. Only an entry's owners publish updates; a key change is a pull request by hand. Shader packs are refused until their format exists. Tested end to end against a mock GitHub: sign-in, the signed sample mod, what lands on GitHub, and what is refused. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/mod-registry.yml | 34 + Cargo.toml | 2 +- crates/horizon-mod-registry/Cargo.lock | 1352 +++++++++++++++++++ crates/horizon-mod-registry/Cargo.toml | 33 + crates/horizon-mod-registry/Dockerfile | 14 + crates/horizon-mod-registry/README.md | 44 + crates/horizon-mod-registry/railway.json | 21 + crates/horizon-mod-registry/src/github.rs | 276 ++++ crates/horizon-mod-registry/src/lib.rs | 675 +++++++++ crates/horizon-mod-registry/src/main.rs | 25 + crates/horizon-mod-registry/tests/submit.rs | 373 +++++ docs/api/mod-index.md | 2 + 12 files changed, 2850 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mod-registry.yml create mode 100644 crates/horizon-mod-registry/Cargo.lock create mode 100644 crates/horizon-mod-registry/Cargo.toml create mode 100644 crates/horizon-mod-registry/Dockerfile create mode 100644 crates/horizon-mod-registry/README.md create mode 100644 crates/horizon-mod-registry/railway.json create mode 100644 crates/horizon-mod-registry/src/github.rs create mode 100644 crates/horizon-mod-registry/src/lib.rs create mode 100644 crates/horizon-mod-registry/src/main.rs create mode 100644 crates/horizon-mod-registry/tests/submit.rs diff --git a/.github/workflows/mod-registry.yml b/.github/workflows/mod-registry.yml new file mode 100644 index 000000000..0f252bb68 --- /dev/null +++ b/.github/workflows/mod-registry.yml @@ -0,0 +1,34 @@ +name: mod-registry +# The submission service is its own workspace (crates/horizon-mod-registry), so the checks job doesn't build +# it: this one does, when it or horizon-mods (the checks it shares with the launcher and the game) changes. +on: + push: + branches: [main] + paths: + - 'crates/horizon-mods/**' + - 'crates/horizon-mod-registry/**' + - '.github/workflows/mod-registry.yml' + pull_request: + paths: + - 'crates/horizon-mods/**' + - 'crates/horizon-mod-registry/**' + - '.github/workflows/mod-registry.yml' +permissions: + contents: read +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: crates/horizon-mod-registry + steps: + - uses: actions/checkout@v4 + - run: rustup toolchain install stable --profile minimal --component rustfmt --component clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: crates/horizon-mod-registry + save-if: ${{ github.ref == 'refs/heads/main' }} + - run: cargo +stable fmt --check + - run: cargo +stable clippy --all-targets --locked -- -D warnings + - run: cargo +stable test --locked diff --git a/Cargo.toml b/Cargo.toml index f197c34fe..6271b9673 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"] +exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal"] members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"] resolver = "2" diff --git a/crates/horizon-mod-registry/Cargo.lock b/crates/horizon-mod-registry/Cargo.lock new file mode 100644 index 000000000..44ef1e078 --- /dev/null +++ b/crates/horizon-mod-registry/Cargo.lock @@ -0,0 +1,1352 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "horizon-mod-registry" +version = "0.1.0" +dependencies = [ + "axum", + "base64", + "getrandom 0.3.4", + "hmac", + "horizon-mods", + "http-body-util", + "serde", + "serde_json", + "sha2", + "tokio", + "toml", + "tower", + "ureq", +] + +[[package]] +name = "horizon-mods" +version = "0.1.0" +dependencies = [ + "log", + "minisign-verify", + "serde", + "serde_json", + "sha2", + "toml", + "zip", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "once_cell", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.11", +] + +[[package]] +name = "webpki-roots" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/crates/horizon-mod-registry/Cargo.toml b/crates/horizon-mod-registry/Cargo.toml new file mode 100644 index 000000000..affc08f62 --- /dev/null +++ b/crates/horizon-mod-registry/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "horizon-mod-registry" +version = "0.1.0" +edition = "2024" +license = "MIT" +rust-version = "1.90" +publish = false +description = "The registry's submission service: GitHub sign-in, the file's checks, staging, and a pull request (docs/api/mod-index.md)" + +# Its own workspace, as the telemetry server's: the game's workspace never builds a web server. +[workspace] + +[dependencies] +horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] } +axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "multipart"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "signal", "fs", "io-util"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "1" +# GitHub's API, blocking, on tokio's blocking pool: the launcher's HTTP client. +ureq = { version = "2", default-features = false, features = ["json", "tls"] } +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +getrandom = "0.3" + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" + +[profile.release] +strip = true +lto = "thin" diff --git a/crates/horizon-mod-registry/Dockerfile b/crates/horizon-mod-registry/Dockerfile new file mode 100644 index 000000000..13522165a --- /dev/null +++ b/crates/horizon-mod-registry/Dockerfile @@ -0,0 +1,14 @@ +# Built from the repository root: docker build -f crates/horizon-mod-registry/Dockerfile . +FROM rust:1-slim AS build +WORKDIR /src +# The root manifest only for the shared crate's workspace settings (edition, lints). +COPY Cargo.toml Cargo.toml +COPY crates/horizon-mods crates/horizon-mods +COPY crates/horizon-mod-registry crates/horizon-mod-registry +RUN cargo build --release --locked --manifest-path crates/horizon-mod-registry/Cargo.toml + +FROM debian:stable-slim +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/* +COPY --from=build /src/crates/horizon-mod-registry/target/release/horizon-mod-registry /usr/local/bin/ +USER nobody +CMD ["horizon-mod-registry"] diff --git a/crates/horizon-mod-registry/README.md b/crates/horizon-mod-registry/README.md new file mode 100644 index 000000000..936fe5acf --- /dev/null +++ b/crates/horizon-mod-registry/README.md @@ -0,0 +1,44 @@ +# horizon-mod-registry + +The service behind the registry's submission form (`playhorizon.dev/mods/submit`). A creator signs in with GitHub and +sends a file with its page; the service runs the file's own checks (`horizon_mods::index::inspect`, the signature, the +entry's fields), uploads it to the `staging` release of +[ludovic111/horizon-mods](https://github.com/ludovic111/horizon-mods) as `.`, and opens a pull +request adding or updating `mods/.toml`. That repository's CI checks it again, with a trial run of a mod in the +sandbox; reviewers decide, and merging publishes. Formats and rules: [docs/api/mod-index.md](../../docs/api/mod-index.md). + +It keeps no data: a session is a signed cookie holding the GitHub login, and the user's OAuth token is used once, to +read that login, and dropped. The sign-in asks for no scope. + +| Route | | +| --- | --- | +| `GET /api/mods/auth/login` | Starts the GitHub sign-in | +| `GET /api/mods/auth/callback` | GitHub's redirect: sets the session, back to `/mods/submit` | +| `POST /api/mods/auth/logout` | Ends it | +| `GET /api/mods/me` | `{login, csrf}`, or 401 | +| `POST /api/mods/submit` | Multipart: `entry` (JSON: `id kind name summary description authors license source icon key signature version game changelog`) and `file`; `{pull_request}` or `{error}` | +| `GET /healthz` | `ok` | + +A submission must come from `REGISTRY_PUBLIC_URL`'s origin with the session's `X-CSRF-Token`. Six per account and sixty +in all per hour. Only an entry's `owners` publish its updates; a key change, an icon in the repository or anything else +is a pull request by hand. + +## Running it + +```sh +cargo test # against a mock GitHub (tests/submit.rs) +REGISTRY_PUBLIC_URL=http://localhost:4173 REGISTRY_SESSION_KEY=... GITHUB_CLIENT_ID=... GITHUB_CLIENT_SECRET=... \ + GITHUB_BOT_TOKEN=... REGISTRY_REPO=you/horizon-mods-test cargo run +``` + +| Variable | | +| --- | --- | +| `REGISTRY_PUBLIC_URL` | The site, `https://playhorizon.dev`: the OAuth callback is `/api/mods/auth/callback` | +| `REGISTRY_SESSION_KEY` | 32 characters or more, random: signs sessions | +| `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` | The OAuth app | +| `GITHUB_BOT_TOKEN` | Fine-grained, the registry repository only: Contents and Pull requests read and write | +| `REGISTRY_REPO` | Default `ludovic111/horizon-mods` | +| `PORT` | Default 8080 | + +On Railway it is its own service (`railway.json`, built with the `Dockerfile` from the repository root); the website's +Caddy sends `/api/mods/*` to it over Railway's private network. diff --git a/crates/horizon-mod-registry/railway.json b/crates/horizon-mod-registry/railway.json new file mode 100644 index 000000000..cfafe4367 --- /dev/null +++ b/crates/horizon-mod-registry/railway.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "crates/horizon-mod-registry/Dockerfile", + "watchPatterns": [ + "/crates/horizon-mods/**", + "/crates/horizon-mod-registry/**", + "/Cargo.toml" + ] + }, + "deploy": { + "healthcheckPath": "/healthz", + "healthcheckTimeout": 60, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 5, + "multiRegionConfig": { + "europe-west4-drams3a": { "numReplicas": 1 } + } + } +} diff --git a/crates/horizon-mod-registry/src/github.rs b/crates/horizon-mod-registry/src/github.rs new file mode 100644 index 000000000..01a1b1bdb --- /dev/null +++ b/crates/horizon-mod-registry/src/github.rs @@ -0,0 +1,276 @@ +//! What the service asks of GitHub: the sign-in (OAuth, no scope: only the +//! public profile), and with the bot's token on the registry repository +//! alone, an entry, the `staging` release, a branch, a file, a pull request. +//! Blocking (ureq): called from tokio's blocking pool. +use base64::{Engine, engine::general_purpose::STANDARD}; +use horizon_mods::index::registry::Entry; +use serde_json::{Value, json}; +use std::{path::Path, time::Duration}; + +/// Where GitHub is: the real one, or a test's mock. +#[derive(Clone, Debug)] +pub struct Endpoints { + /// `https://github.com`: OAuth. + pub web: String, + /// `https://api.github.com`. + pub api: String, + /// `https://uploads.github.com`: release assets. + pub uploads: String, +} + +impl Default for Endpoints { + fn default() -> Self { + Endpoints { + web: "https://github.com".into(), + api: "https://api.github.com".into(), + uploads: "https://uploads.github.com".into(), + } + } +} + +#[derive(Clone)] +pub struct GitHub { + pub at: Endpoints, + /// `owner/name` of the registry repository. + pub repo: String, + pub client_id: String, + pub client_secret: String, + /// Fine-grained, this repository only: contents and pull requests. + pub bot_token: String, + agent: ureq::Agent, +} + +/// A failed call: `status` is GitHub's, 0 when it wasn't reached. +#[derive(Debug)] +pub struct Failure { + pub status: u16, + pub message: String, +} + +type Result = std::result::Result; + +fn failure(e: ureq::Error) -> Failure { + match e { + ureq::Error::Status(status, response) => Failure { + status, + message: response + .into_string() + .unwrap_or_default() + .chars() + .take(300) + .collect(), + }, + other => Failure { + status: 0, + message: other.to_string(), + }, + } +} + +fn read(response: ureq::Response) -> Result { + response.into_json().map_err(|e| Failure { + status: 0, + message: e.to_string(), + }) +} + +impl GitHub { + pub fn new( + at: Endpoints, + repo: String, + client_id: String, + client_secret: String, + bot_token: String, + ) -> GitHub { + let agent = ureq::AgentBuilder::new() + .timeout(Duration::from_secs(60)) + .user_agent("horizon-mod-registry") + .build(); + GitHub { + at, + repo, + client_id, + client_secret, + bot_token, + agent, + } + } + + /// Where the sign-in starts. + pub fn authorize_url(&self, redirect: &str, state: &str) -> String { + format!( + "{}/login/oauth/authorize?client_id={}&redirect_uri={}&state={state}&allow_signup=true", + self.at.web, + self.client_id, + encode(redirect) + ) + } + + /// The signed-in account (login, id) for an OAuth `code`. Its token is + /// used once and dropped. + pub fn sign_in(&self, code: &str, redirect: &str) -> Result<(String, u64)> { + let token = self + .agent + .post(&format!("{}/login/oauth/access_token", self.at.web)) + .set("Accept", "application/json") + .send_json(json!({ + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": redirect, + })) + .map_err(failure) + .and_then(read)?; + let Some(token) = token["access_token"].as_str() else { + return Err(Failure { + status: 401, + message: "GitHub refused the sign-in".into(), + }); + }; + let user = self + .agent + .get(&format!("{}/user", self.at.api)) + .set("Authorization", &format!("Bearer {token}")) + .set("Accept", "application/vnd.github+json") + .call() + .map_err(failure) + .and_then(read)?; + match (user["login"].as_str(), user["id"].as_u64()) { + (Some(login), Some(id)) => Ok((login.to_owned(), id)), + _ => Err(Failure { + status: 0, + message: "GitHub sent no login".into(), + }), + } + } + + fn call(&self, method: &str, path: &str) -> ureq::Request { + self.agent + .request( + method, + &format!("{}/repos/{}{path}", self.at.api, self.repo), + ) + .set("Authorization", &format!("Bearer {}", self.bot_token)) + .set("Accept", "application/vnd.github+json") + .set("X-GitHub-Api-Version", "2022-11-28") + } + + /// `mods/.toml` on main, with its blob sha; None when it isn't there. + pub fn entry(&self, id: &str) -> Result> { + let response = self + .call("GET", &format!("/contents/mods/{id}.toml")) + .query("ref", "main") + .call(); + let file = match response { + Ok(r) => read(r)?, + Err(ureq::Error::Status(404, _)) => return Ok(None), + Err(e) => return Err(failure(e)), + }; + let bytes = file["content"] + .as_str() + .map(|c| c.replace('\n', "")) + .and_then(|c| STANDARD.decode(c).ok()) + .unwrap_or_default(); + let entry = toml::from_str(&String::from_utf8_lossy(&bytes)).map_err(|e| Failure { + status: 0, + message: format!("mods/{id}.toml on main: {e}"), + })?; + Ok(Some((entry, file["sha"].as_str().unwrap_or("").to_owned()))) + } + + /// Uploads `path` as asset `name` of the `staging` prerelease, unless it + /// is already there (the same file submitted again). + pub fn stage(&self, name: &str, path: &Path) -> Result<()> { + let release = self + .call("GET", "/releases/tags/staging") + .call() + .map_err(failure) + .and_then(read)?; + let there = release["assets"] + .as_array() + .is_some_and(|a| a.iter().any(|x| x["name"] == name)); + if there { + return Ok(()); + } + let id = release["id"].as_u64().unwrap_or(0); + let bytes = std::fs::read(path).map_err(|e| Failure { + status: 0, + message: e.to_string(), + })?; + self.agent + .post(&format!( + "{}/repos/{}/releases/{id}/assets", + self.at.uploads, self.repo + )) + .query("name", name) + .set("Authorization", &format!("Bearer {}", self.bot_token)) + .set("Accept", "application/vnd.github+json") + .set("Content-Type", "application/octet-stream") + .send_bytes(&bytes) + .map_err(failure)?; + Ok(()) + } + + /// Makes branch `name` from main; false when it already exists. + pub fn branch(&self, name: &str) -> Result { + let main = self + .call("GET", "/git/ref/heads/main") + .call() + .map_err(failure) + .and_then(read)?; + let sha = main["object"]["sha"].as_str().unwrap_or(""); + match self + .call("POST", "/git/refs") + .send_json(json!({"ref": format!("refs/heads/{name}"), "sha": sha})) + { + Ok(_) => Ok(true), + Err(ureq::Error::Status(422, _)) => Ok(false), + Err(e) => Err(failure(e)), + } + } + + /// Writes `path` on `branch` in one commit; `sha` is the blob it replaces. + pub fn put( + &self, + path: &str, + text: &str, + branch: &str, + sha: Option<&str>, + message: &str, + ) -> Result<()> { + let mut body = json!({ + "message": message, + "content": STANDARD.encode(text), + "branch": branch, + }); + if let Some(sha) = sha { + body["sha"] = json!(sha); + } + self.call("PUT", &format!("/contents/{path}")) + .send_json(body) + .map_err(failure)?; + Ok(()) + } + + /// Opens the pull request; its address. + pub fn pull(&self, title: &str, head: &str, body: &str) -> Result { + let pr = self + .call("POST", "/pulls") + .send_json(json!({"title": title, "head": head, "base": "main", "body": body})) + .map_err(failure) + .and_then(read)?; + Ok(pr["html_url"].as_str().unwrap_or("").to_owned()) + } +} + +/// Percent-encodes a query value. +fn encode(s: &str) -> String { + s.bytes() + .map(|b| match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + (b as char).to_string() + } + _ => format!("%{b:02X}"), + }) + .collect() +} diff --git a/crates/horizon-mod-registry/src/lib.rs b/crates/horizon-mod-registry/src/lib.rs new file mode 100644 index 000000000..9799bace8 --- /dev/null +++ b/crates/horizon-mod-registry/src/lib.rs @@ -0,0 +1,675 @@ +//! `horizon-mod-registry`: the submission form's service at +//! `playhorizon.dev/api/mods/*` (docs/api/mod-index.md). A creator signs in +//! with GitHub, sends a file and its page; the service runs the file's own +//! checks, uploads it to the registry's `staging` release and opens a pull +//! request with the entry. Reviewers decide; merging publishes. It keeps no +//! data: sessions are signed cookies, and GitHub holds everything else. +pub mod github; + +use axum::{ + Json, Router, + extract::{DefaultBodyLimit, Multipart, Query, State}, + http::{HeaderMap, StatusCode, header}, + response::{IntoResponse, Redirect, Response}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use github::GitHub; +use hmac::{Hmac, Mac}; +use horizon_mods::index::{ + self, Kind, + registry::{self, Entry, EntryVersion, Sources}, +}; +use serde::Deserialize; +use serde_json::json; +use sha2::Sha256; +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +/// How long a sign-in lasts. +const SESSION: u64 = 12 * 3600; +/// Submissions per account per hour, and from everyone. +const PER_ACCOUNT: usize = 6; +const PER_HOUR: usize = 60; +/// The page's JSON. +const MAX_FORM: usize = 64 << 10; + +pub struct Config { + /// `https://playhorizon.dev`: where the form is, and the only origin it + /// takes submissions from. + pub public_url: String, + /// At least 32 bytes: signs sessions. + pub session_key: Vec, + pub github: GitHub, + /// Where files wait during a submission. + pub scratch: PathBuf, +} + +impl Config { + /// `REGISTRY_PUBLIC_URL`, `REGISTRY_SESSION_KEY`, `REGISTRY_REPO`, + /// `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_BOT_TOKEN`. + pub fn from_env() -> Result { + let var = |name: &str| std::env::var(name).map_err(|_| format!("{name} is not set")); + let key = var("REGISTRY_SESSION_KEY")?; + if key.len() < 32 { + return Err("REGISTRY_SESSION_KEY needs at least 32 characters".into()); + } + Ok(Config { + public_url: var("REGISTRY_PUBLIC_URL")?.trim_end_matches('/').to_owned(), + session_key: key.into_bytes(), + github: GitHub::new( + github::Endpoints::default(), + std::env::var("REGISTRY_REPO").unwrap_or_else(|_| "ludovic111/horizon-mods".into()), + var("GITHUB_CLIENT_ID")?, + var("GITHUB_CLIENT_SECRET")?, + var("GITHUB_BOT_TOKEN")?, + ), + scratch: std::env::temp_dir().join("horizon-mod-registry"), + }) + } + + fn callback(&self) -> String { + format!("{}/api/mods/auth/callback", self.public_url) + } +} + +#[derive(Clone)] +pub struct App(Arc); + +struct Inner { + config: Config, + recent: Mutex>>, +} + +impl App { + pub fn new(config: Config) -> App { + App(Arc::new(Inner { + config, + recent: Mutex::new(HashMap::new()), + })) + } +} + +pub fn router(app: App) -> Router { + Router::new() + .route("/healthz", get(|| async { "ok" })) + .route("/api/mods/auth/login", get(login)) + .route("/api/mods/auth/callback", get(callback)) + .route("/api/mods/auth/logout", post(logout)) + .route("/api/mods/me", get(me)) + .route( + "/api/mods/submit", + post(submit).layer(DefaultBodyLimit::max( + index::MAX_FILE as usize + MAX_FORM * 2, + )), + ) + .with_state(app) +} + +/// An error players read in the form. +#[derive(Debug)] +pub struct Refusal(StatusCode, String); + +impl Refusal { + fn new(status: StatusCode, message: impl Into) -> Refusal { + Refusal(status, message.into()) + } +} + +impl IntoResponse for Refusal { + fn into_response(self) -> Response { + (self.0, Json(json!({"error": self.1}))).into_response() + } +} + +fn bad(message: impl Into) -> Refusal { + Refusal::new(StatusCode::BAD_REQUEST, message) +} + +fn github_down(e: github::Failure) -> Refusal { + eprintln!("horizon-mod-registry: GitHub {}: {}", e.status, e.message); + Refusal::new( + StatusCode::BAD_GATEWAY, + "GitHub didn't answer as expected: try again in a minute", + ) +} + +// Signed values: `.`, both base64url. + +fn sign(key: &[u8], payload: &str) -> String { + let mut mac = Hmac::::new_from_slice(key).expect("any key length"); + mac.update(payload.as_bytes()); + format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(payload), + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) + ) +} + +fn unsign(key: &[u8], value: &str) -> Option { + let (payload, tag) = value.split_once('.')?; + let payload = String::from_utf8(URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?; + let mut mac = Hmac::::new_from_slice(key).ok()?; + mac.update(payload.as_bytes()); + mac.verify_slice(&URL_SAFE_NO_PAD.decode(tag).ok()?).ok()?; + Some(payload) +} + +fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn random_hex() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("the system's random numbers"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn cookie(headers: &HeaderMap, name: &str) -> Option { + headers + .get_all(header::COOKIE) + .iter() + .filter_map(|v| v.to_str().ok()) + .flat_map(|v| v.split(';')) + .filter_map(|c| c.trim().split_once('=')) + .find(|(k, _)| *k == name) + .map(|(_, v)| v.to_owned()) +} + +fn set_cookie(name: &str, value: &str, max_age: u64) -> String { + format!("{name}={value}; Path=/api/mods; HttpOnly; Secure; SameSite=Lax; Max-Age={max_age}") +} + +/// The signed-in account: (login, id). +fn session(app: &Inner, headers: &HeaderMap) -> Option<(String, u64)> { + let payload = unsign(&app.config.session_key, &cookie(headers, "hm_session")?)?; + let mut parts = payload.split('\n'); + let (login, id, until) = (parts.next()?, parts.next()?, parts.next()?); + (until.parse::().ok()? > now()).then(|| (login.to_owned(), id.parse().unwrap_or(0))) +} + +/// The form sends this with each submission: a page on another site can't +/// read it, so it can't submit in a signed-in creator's name. +fn csrf(app: &Inner, headers: &HeaderMap) -> Option { + let session = cookie(headers, "hm_session")?; + let signed = sign(&app.config.session_key, &format!("csrf\n{session}")); + Some(signed.rsplit('.').next()?.chars().take(32).collect()) +} + +async fn login(State(app): State) -> Response { + let state = random_hex(); + let signed = sign(&app.0.config.session_key, &state); + let url = app + .0 + .config + .github + .authorize_url(&app.0.config.callback(), &state); + ( + [(header::SET_COOKIE, set_cookie("hm_state", &signed, 600))], + Redirect::to(&url), + ) + .into_response() +} + +#[derive(Deserialize)] +struct Callback { + code: String, + state: String, +} + +async fn callback( + State(app): State, + headers: HeaderMap, + Query(q): Query, +) -> Result { + let expected = cookie(&headers, "hm_state").and_then(|v| unsign(&app.0.config.session_key, &v)); + if expected.as_deref() != Some(q.state.as_str()) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "the sign-in expired or didn't start here: try again", + )); + } + let inner = app.0.clone(); + let (login, id) = tokio::task::spawn_blocking(move || { + inner + .config + .github + .sign_in(&q.code, &inner.config.callback()) + }) + .await + .map_err(|_| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, "sign-in failed"))? + .map_err(github_down)?; + let value = sign( + &app.0.config.session_key, + &format!("{login}\n{id}\n{}", now() + SESSION), + ); + let back = format!("{}/mods/submit", app.0.config.public_url); + let mut response = Redirect::to(&back).into_response(); + let headers = response.headers_mut(); + headers.append( + header::SET_COOKIE, + set_cookie("hm_session", &value, SESSION).parse().unwrap(), + ); + headers.append( + header::SET_COOKIE, + set_cookie("hm_state", "", 0).parse().unwrap(), + ); + Ok(response) +} + +async fn logout() -> Response { + ( + [(header::SET_COOKIE, set_cookie("hm_session", "", 0))], + StatusCode::NO_CONTENT, + ) + .into_response() +} + +async fn me( + State(app): State, + headers: HeaderMap, +) -> Result, Refusal> { + let (login, _) = session(&app.0, &headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "not signed in"))?; + Ok(Json( + json!({"login": login, "csrf": csrf(&app.0, &headers)}), + )) +} + +/// The page a submission fills in: a project's fields, and the version's. +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Form { + pub id: String, + pub kind: String, + pub name: String, + pub summary: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub authors: Vec, + pub license: String, + pub source: String, + /// `https://` only; an icon in the repository is a pull request by hand. + #[serde(default)] + pub icon: Option, + /// A new project's minisign public key. + #[serde(default)] + pub key: Option, + /// The `.minisig` text. + #[serde(default)] + pub signature: Option, + pub version: String, + pub game: String, + #[serde(default)] + pub changelog: String, +} + +fn take(window: &mut VecDeque, max: usize) -> bool { + while window + .front() + .is_some_and(|t| t.elapsed() > Duration::from_secs(3600)) + { + window.pop_front(); + } + window.len() < max +} + +async fn submit( + State(app): State, + headers: HeaderMap, + mut multipart: Multipart, +) -> Result, Refusal> { + let inner = app.0.clone(); + let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()); + if origin != Some(inner.config.public_url.as_str()) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "submit from the registry's form", + )); + } + let (login, user_id) = session(&inner, &headers) + .ok_or_else(|| Refusal::new(StatusCode::UNAUTHORIZED, "sign in with GitHub first"))?; + let sent = headers.get("x-csrf-token").and_then(|v| v.to_str().ok()); + if sent.is_none() || sent.map(str::to_owned) != csrf(&inner, &headers) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + "reload the form and try again", + )); + } + { + let mut recent = inner.recent.lock().unwrap(); + let ok = take(recent.entry(String::new()).or_default(), PER_HOUR) + && take(recent.entry(login.to_lowercase()).or_default(), PER_ACCOUNT); + if !ok { + return Err(Refusal::new( + StatusCode::TOO_MANY_REQUESTS, + "too many submissions this hour: try again later", + )); + } + let at = Instant::now(); + recent.entry(String::new()).or_default().push_back(at); + recent + .entry(login.to_lowercase()) + .or_default() + .push_back(at); + } + let dir = inner.config.scratch.join(random_hex()); + std::fs::create_dir_all(&dir) + .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let result = receive(&mut multipart, &dir).await; + let result = match result { + Ok((form, file)) => { + let inner = inner.clone(); + tokio::task::spawn_blocking(move || { + publish(&inner.config, &login, user_id, &form, &file) + }) + .await + .unwrap_or_else(|_| { + Err(Refusal::new( + StatusCode::INTERNAL_SERVER_ERROR, + "the check crashed", + )) + }) + } + Err(e) => Err(e), + }; + let _ = std::fs::remove_dir_all(&dir); + result.map(|url| Json(json!({"pull_request": url}))) +} + +/// Reads the form's two parts: `entry` (JSON) and `file`, streamed to `dir`. +async fn receive(multipart: &mut Multipart, dir: &Path) -> Result<(Form, PathBuf), Refusal> { + use tokio::io::AsyncWriteExt; + let (mut form, mut file) = (None, None); + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|e| bad(e.body_text()))? + { + match field.name() { + Some("entry") => { + let text = field.text().await.map_err(|e| bad(e.body_text()))?; + if text.len() > MAX_FORM { + return Err(bad("the page's text is too long")); + } + form = Some(serde_json::from_str::
(&text).map_err(|e| bad(e.to_string()))?); + } + Some("file") => { + let path = dir.join("upload"); + let mut out = tokio::fs::File::create(&path) + .await + .map_err(|e| Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; + let mut size = 0u64; + while let Some(chunk) = field.chunk().await.map_err(|e| bad(e.body_text()))? { + size += chunk.len() as u64; + if size > index::MAX_FILE { + return Err(bad(format!( + "the file is over {} MiB", + index::MAX_FILE >> 20 + ))); + } + out.write_all(&chunk).await.map_err(|e| { + Refusal::new(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()) + })?; + } + out.flush().await.ok(); + file = Some(path); + } + _ => {} + } + } + match (form, file) { + (Some(form), Some(file)) => Ok((form, file)), + _ => Err(bad("send the page and the file")), + } +} + +/// `YYYY-MM-DD` of a Unix time (UTC). +fn date(secs: u64) -> String { + // Howard Hinnant's days-to-civil. + let z = (secs / 86400) as i64 + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = yoe + era * 400 + i64::from(m <= 2); + format!("{y:04}-{m:02}-{d:02}") +} + +/// The submission, past its checks: the entry written on a new branch and a +/// pull request opened. Returns the pull request's address. +pub fn publish( + config: &Config, + login: &str, + user_id: u64, + form: &Form, + file: &Path, +) -> Result { + let gh = &config.github; + let kind = Kind::from(form.kind.clone()); + registry::open(&kind).map_err(bad)?; + if !index::valid_id(&form.id) { + return Err(bad("an id is 1 to 64 of a-z, 0-9 and _")); + } + if form + .icon + .as_ref() + .is_some_and(|i| !i.starts_with("https://")) + { + return Err(bad("the icon must be an https:// address")); + } + if !form.source.trim().starts_with("https://") { + return Err(bad("the source must be an https:// address of the code")); + } + if form.name.trim().is_empty() || form.license.trim().is_empty() { + return Err(bad("a project needs a name and a licence")); + } + // The file's own checks first: a wrong file never reaches GitHub. + let facts = index::inspect(&kind, file).map_err(bad)?; + if let Some(id) = facts.id.as_ref().filter(|id| **id != form.id) { + return Err(bad(format!("the file's id is {id:?}, not {:?}", form.id))); + } + if let Some(v) = facts + .version + .as_ref() + .filter(|v| **v != form.version.trim()) + { + return Err(bad(format!( + "the file says version {v:?}, not {:?}", + form.version.trim() + ))); + } + let sha256 = index::sha256_file(file).map_err(bad)?; + let extension = kind.extension().unwrap_or("zip"); + let version = EntryVersion { + version: form.version.trim().to_owned(), + published: date(now()), + game: form.game.trim().to_owned(), + file: format!("{}-{}.{extension}", form.id, form.version.trim()), + sha256: sha256.clone(), + signature: form.signature.clone().filter(|s| !s.trim().is_empty()), + changelog: form.changelog.clone(), + }; + let existing = gh.entry(&form.id).map_err(github_down)?; + let is_new = existing.is_none(); + let (entry, blob) = match existing { + None => ( + Entry { + id: form.id.clone(), + kind: kind.clone(), + name: form.name.trim().to_owned(), + summary: form.summary.trim().to_owned(), + description: form.description.clone(), + authors: form.authors.clone(), + owners: vec![login.to_owned()], + license: form.license.trim().to_owned(), + source: form.source.trim().to_owned(), + icon: form.icon.clone(), + key: form.key.clone().filter(|k| !k.trim().is_empty()), + versions: vec![version.clone()], + }, + None, + ), + Some((mut entry, blob)) => { + if !entry.owners.iter().any(|o| o.eq_ignore_ascii_case(login)) { + return Err(Refusal::new( + StatusCode::FORBIDDEN, + format!( + "{} is {}'s: only its owners publish its updates", + form.id, + entry.owners.join(", ") + ), + )); + } + if entry.kind != kind { + return Err(bad(format!("{} is a {}", form.id, entry.kind.name()))); + } + if entry.versions.iter().any(|v| v.version == version.version) { + return Err(Refusal::new( + StatusCode::CONFLICT, + format!( + "{} {} is already published: give this file a new version", + form.id, version.version + ), + )); + } + let key = form.key.clone().filter(|k| !k.trim().is_empty()); + if key.is_some() && key != entry.key { + return Err(bad( + "changing a project's key is a pull request by hand, reviewed on its own", + )); + } + entry.name = form.name.trim().to_owned(); + entry.summary = form.summary.trim().to_owned(); + entry.description = form.description.clone(); + entry.authors = form.authors.clone(); + entry.license = form.license.trim().to_owned(); + entry.source = form.source.trim().to_owned(); + if form.icon.is_some() { + entry.icon = form.icon.clone(); + } + entry.versions.insert(0, version.clone()); + (entry, Some(blob)) + } + }; + // Every check CI runs but the trial run: the file as its kind, its id and + // version, its signature, the entry's fields. CI runs it all again. + let files = file.parent().unwrap_or(Path::new(".")); + std::fs::copy(file, files.join(&sha256)).map_err(|e| bad(e.to_string()))?; + let mut checked = entry.clone(); + checked.versions.truncate(1); + if checked + .icon + .as_ref() + .is_some_and(|i| !i.starts_with("https://")) + { + checked.icon = None; + } + let project = registry::project( + &checked, + &Sources { + registry: files, + files, + release: index::FILE_HOST, + site: "https://example.invalid/", + generated: String::new(), + }, + ) + .map_err(|errors| bad(errors.join("; ")))?; + let text = entry.to_toml().map_err(bad)?; + gh.stage(&format!("{sha256}.{extension}"), file) + .map_err(github_down)?; + let branch = format!("submit/{}-{}", form.id, version.version); + if !gh.branch(&branch).map_err(github_down)? { + return Err(Refusal::new( + StatusCode::CONFLICT, + format!( + "{} {} was already submitted: its pull request is open", + form.id, version.version + ), + )); + } + let what = if is_new { "Add" } else { "Update" }; + let message = format!( + "{what} {} {}\n\nSubmitted by @{login} through the form.\n\nCo-authored-by: {login} <{user_id}+{login}@users.noreply.github.com>", + form.id, version.version + ); + gh.put( + &format!("mods/{}.toml", form.id), + &text, + &branch, + blob.as_deref(), + &message, + ) + .map_err(github_down)?; + let v = &project.versions[0]; + let capabilities: Vec = v + .capabilities + .iter() + .map(|c| format!("- may {}", c.describe())) + .collect(); + let requires: Vec = v + .requires + .iter() + .map(|r| format!("- needs {} {}", r.id, r.version)) + .collect(); + let body = format!( + "\nSubmitted by @{login} through [the form]({public}/mods/submit).\n\n\ + | | |\n|---|---|\n| Project | `{id}` ({kind}, {new}) |\n| Version | {version} for {game} |\n| File | `{file}`, {size} bytes, SHA-256 `{sha256}` |\n| Signed | {signed} |\n| Source | {source} |\n\n{lists}\n\n\ + The checks below run the file's own checks again and, for a mod, a trial run in the sandbox. \ + Reviewers: read the source and the checks' report before you approve.", + public = config.public_url, + id = form.id, + kind = kind.name(), + new = if is_new { "new" } else { "update" }, + version = v.version, + game = v.game, + file = v.file.name, + size = v.file.size, + signed = if entry.key.is_some() { + "yes, with the project's key" + } else { + "no" + }, + source = entry.source, + lists = [capabilities, requires].concat().join("\n"), + ); + gh.pull( + &format!("{what} {} {}", form.id, version.version), + &branch, + &body, + ) + .map_err(github_down) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dates_and_signatures() { + assert_eq!(date(0), "1970-01-01"); + assert_eq!(date(1_792_800_000), "2026-10-24"); + let key = b"0123456789abcdef0123456789abcdef"; + let signed = sign(key, "alice\n7\n99"); + assert_eq!(unsign(key, &signed).as_deref(), Some("alice\n7\n99")); + assert_eq!(unsign(b"another key, another key, another", &signed), None); + let forged = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode("mallory\n8\n99"), + signed.split_once('.').unwrap().1 + ); + assert_eq!(unsign(key, &forged), None); + } +} diff --git a/crates/horizon-mod-registry/src/main.rs b/crates/horizon-mod-registry/src/main.rs new file mode 100644 index 000000000..81fe1b7b6 --- /dev/null +++ b/crates/horizon-mod-registry/src/main.rs @@ -0,0 +1,25 @@ +//! `horizon-mod-registry`: serves on `PORT` (default 8080). Configured by the +//! environment (see `Config::from_env`); keeps no data. +use std::net::SocketAddr; + +use horizon_mod_registry::{App, Config, router}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 2)] +async fn main() { + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8080); + let config = Config::from_env().unwrap_or_else(|e| panic!("horizon-mod-registry: {e}")); + let repo = config.github.repo.clone(); + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port))) + .await + .unwrap_or_else(|e| panic!("port {port}: {e}")); + eprintln!("horizon-mod-registry: listening on {port}, submitting to {repo}"); + axum::serve(listener, router(App::new(config))) + .with_graceful_shutdown(async { + let _ = tokio::signal::ctrl_c().await; + }) + .await + .unwrap_or_else(|e| panic!("{e}")); +} diff --git a/crates/horizon-mod-registry/tests/submit.rs b/crates/horizon-mod-registry/tests/submit.rs new file mode 100644 index 000000000..77487089d --- /dev/null +++ b/crates/horizon-mod-registry/tests/submit.rs @@ -0,0 +1,373 @@ +//! The whole submission against a mock GitHub: sign in, submit the sample +//! signed mod (`crates/horizon-mods/tests/index`), and what lands on GitHub; +//! then what is refused. +use axum::{ + Json, Router, + body::{Body, Bytes}, + extract::{Path as UrlPath, Query, State}, + http::{Request, StatusCode, header}, + routing::{get, post}, +}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use horizon_mod_registry::{App, Config, github, router}; +use horizon_mods::index::registry::Entry; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; +use tower::ServiceExt; + +const PUBLIC: &str = "https://playhorizon.test"; +const HMOD_SHA: &str = "e1135345692f6dd8fd4c51920151944b82333e17049c9bb7d756f27b8deefc53"; + +#[derive(Default)] +struct Mock { + /// Path -> text on main. + main: HashMap, + assets: Vec<(String, usize)>, + branches: Vec, + /// (branch, path) -> (text, message, replaced blob). + commits: HashMap<(String, String), (String, String, Option)>, + pulls: Vec, + /// The account the next sign-in gets. + login: String, +} + +type Shared = Arc>; + +async fn mock_github() -> (String, Shared) { + let state: Shared = Arc::default(); + state.lock().unwrap().login = "alice".into(); + let app = Router::new() + .route( + "/login/oauth/access_token", + post(|Json(body): Json| async move { + Json(if body["code"] == "good" { + json!({"access_token": "gho_user"}) + } else { + json!({"error": "bad_verification_code"}) + }) + }), + ) + .route( + "/user", + get(|State(s): State| async move { + Json(json!({"login": s.lock().unwrap().login, "id": 7})) + }), + ) + .route( + "/repos/o/r/contents/mods/{file}", + get( + |State(s): State, UrlPath(file): UrlPath| async move { + let s = s.lock().unwrap(); + match s.main.get(&format!("mods/{file}")) { + Some(text) => Ok(Json(json!({"content": STANDARD.encode(text), "sha": "blob1"}))), + None => Err(StatusCode::NOT_FOUND), + } + }, + ) + .put( + |State(s): State, UrlPath(file): UrlPath, Json(b): Json| async move { + let text = String::from_utf8(STANDARD.decode(b["content"].as_str().unwrap()).unwrap()).unwrap(); + let key = (b["branch"].as_str().unwrap().to_owned(), format!("mods/{file}")); + let value = (text, b["message"].as_str().unwrap().to_owned(), b["sha"].as_str().map(str::to_owned)); + s.lock().unwrap().commits.insert(key, value); + Json(json!({})) + }, + ), + ) + .route( + "/repos/o/r/releases/tags/staging", + get(|State(s): State| async move { + let names: Vec = s.lock().unwrap().assets.iter().map(|(n, _)| json!({"name": n})).collect(); + Json(json!({"id": 42, "assets": names})) + }), + ) + .route( + "/repos/o/r/releases/42/assets", + post( + |State(s): State, Query(q): Query>, body: Bytes| async move { + s.lock().unwrap().assets.push((q["name"].clone(), body.len())); + Json(json!({})) + }, + ), + ) + .route( + "/repos/o/r/git/ref/heads/main", + get(|| async { Json(json!({"object": {"sha": "abc123"}})) }), + ) + .route( + "/repos/o/r/git/refs", + post(|State(s): State, Json(b): Json| async move { + assert_eq!(b["sha"], "abc123"); + let name = b["ref"].as_str().unwrap().trim_start_matches("refs/heads/").to_owned(); + let mut s = s.lock().unwrap(); + if s.branches.contains(&name) { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + s.branches.push(name); + Ok(Json(json!({}))) + }), + ) + .route( + "/repos/o/r/pulls", + post(|State(s): State, Json(b): Json| async move { + let mut s = s.lock().unwrap(); + s.pulls.push(b); + Json(json!({"html_url": format!("https://github.com/o/r/pull/{}", s.pulls.len())})) + }), + ) + .with_state(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + (base, state) +} + +fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../horizon-mods/tests/index") +} + +struct Client { + app: Router, + cookies: HashMap, +} + +impl Client { + async fn send(&mut self, request: Request) -> (StatusCode, Value, Option) { + let response = self.app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + for c in response.headers().get_all(header::SET_COOKIE) { + let (pair, _) = c.to_str().unwrap().split_once(';').unwrap(); + let (k, v) = pair.split_once('=').unwrap(); + self.cookies.insert(k.into(), v.into()); + } + let location = response + .headers() + .get(header::LOCATION) + .map(|l| l.to_str().unwrap().to_owned()); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + location, + ) + } + + fn cookie_header(&self) -> String { + self.cookies + .iter() + .filter(|(_, v)| !v.is_empty()) + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("; ") + } + + fn get(&self, uri: &str) -> Request { + Request::get(uri) + .header(header::COOKIE, self.cookie_header()) + .body(Body::empty()) + .unwrap() + } + + async fn sign_in(&mut self) -> String { + let (status, _, location) = self.send(self.get("/api/mods/auth/login")).await; + assert_eq!(status, StatusCode::SEE_OTHER); + let location = location.unwrap(); + assert!(location.contains( + "redirect_uri=https%3A%2F%2Fplayhorizon.test%2Fapi%2Fmods%2Fauth%2Fcallback" + )); + let state = location + .split("state=") + .nth(1) + .unwrap() + .split('&') + .next() + .unwrap() + .to_owned(); + // Another state than the one this browser started with is refused. + let (status, ..) = self + .send(self.get("/api/mods/auth/callback?code=good&state=other")) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, location) = self + .send(self.get(&format!("/api/mods/auth/callback?code=good&state={state}"))) + .await; + assert_eq!(status, StatusCode::SEE_OTHER); + assert_eq!( + location.as_deref(), + Some("https://playhorizon.test/mods/submit") + ); + let (status, me, _) = self.send(self.get("/api/mods/me")).await; + assert_eq!(status, StatusCode::OK); + me["csrf"].as_str().unwrap().to_owned() + } + + async fn submit(&mut self, csrf: &str, form: &Value, file: &[u8]) -> (StatusCode, Value) { + let boundary = "XhorizonX"; + let mut body = format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"entry\"\r\n\r\n{form}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"f\"\r\nContent-Type: application/octet-stream\r\n\r\n" + ) + .into_bytes(); + body.extend_from_slice(file); + body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes()); + let request = Request::post("/api/mods/submit") + .header(header::COOKIE, self.cookie_header()) + .header(header::ORIGIN, PUBLIC) + .header("x-csrf-token", csrf) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .unwrap(); + let (status, json, _) = self.send(request).await; + (status, json) + } +} + +fn form() -> Value { + let key = std::fs::read_to_string(fixtures().join("test.pub")).unwrap(); + json!({ + "id": "coords_hud", + "kind": "mod", + "name": "Coordinates HUD", + "summary": "Your coordinates in a corner.", + "authors": ["Alice"], + "license": "MIT", + "source": "https://github.com/alice/coords", + "key": key.lines().last().unwrap(), + "signature": std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")).unwrap(), + "version": "0.1.0", + "game": "26.3", + }) +} + +async fn setup() -> (Client, Shared) { + let (base, mock) = mock_github().await; + let scratch = + std::env::temp_dir().join(format!("horizon-mod-registry-test-{}", std::process::id())); + let config = Config { + public_url: PUBLIC.into(), + session_key: b"a test key of at least thirty-two bytes".to_vec(), + github: github::GitHub::new( + github::Endpoints { + web: base.clone(), + api: base.clone(), + uploads: base, + }, + "o/r".into(), + "client".into(), + "secret".into(), + "bot".into(), + ), + scratch, + }; + let client = Client { + app: router(App::new(config)), + cookies: HashMap::new(), + }; + (client, mock) +} + +#[tokio::test(flavor = "multi_thread")] +async fn a_submission_becomes_a_pull_request() { + let (mut client, mock) = setup().await; + let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap(); + // Not signed in. + let (status, _) = client.submit("x", &form(), &file).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let csrf = client.sign_in().await; + let (status, _) = client.submit("wrong", &form(), &file).await; + assert_eq!(status, StatusCode::FORBIDDEN); + + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["pull_request"], "https://github.com/o/r/pull/1"); + { + let m = mock.lock().unwrap(); + assert_eq!(m.assets, [(format!("{HMOD_SHA}.hmod"), file.len())]); + assert_eq!(m.branches, ["submit/coords_hud-0.1.0"]); + let (text, message, replaced) = &m.commits[&( + "submit/coords_hud-0.1.0".into(), + "mods/coords_hud.toml".into(), + )]; + let entry: Entry = toml::from_str(text).unwrap(); + assert_eq!(entry.owners, ["alice"]); + assert_eq!(entry.versions[0].sha256, HMOD_SHA); + assert_eq!(entry.versions[0].file, "coords_hud-0.1.0.hmod"); + assert!(entry.versions[0].signature.is_some() && entry.key.is_some()); + assert!(message.contains("Co-authored-by: alice <7+alice@users.noreply.github.com>")); + assert_eq!(replaced, &None); + let body = m.pulls[0]["body"].as_str().unwrap(); + assert!(body.starts_with(""), "{body}"); + assert!(body.contains("may draw text on your screen"), "{body}"); + } + // The same version again: its branch is there. + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::CONFLICT, "{json}"); + assert_eq!(mock.lock().unwrap().assets.len(), 1, "uploaded once"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn what_the_form_refuses() { + let (mut client, mock) = setup().await; + let file = std::fs::read(fixtures().join("coords_hud-0.1.0.hmod")).unwrap(); + let csrf = client.sign_in().await; + let refused = |status: StatusCode, json: &Value, part: &str| { + assert!( + json["error"].as_str().unwrap_or("").contains(part), + "{status} {json}" + ); + }; + // A file that isn't what the page says. + let mut f = form(); + f["id"] = json!("other_mod"); + let (status, json) = client.submit(&csrf, &f, &file).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + refused(status, &json, "the file's id is \"coords_hud\""); + // A broken signature. + let mut f = form(); + f["signature"] = json!( + std::fs::read_to_string(fixtures().join("coords_hud-0.1.0.hmod.minisig")) + .unwrap() + .replace("RUTV", "RUTW") + ); + let (status, json) = client.submit(&csrf, &f, &file).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{json}"); + // Shader packs aren't open. + let mut f = form(); + f["kind"] = json!("shader"); + let (status, json) = client.submit(&csrf, &f, &file).await; + refused(status, &json, "shader packs open"); + // An update by someone who isn't an owner. + let owned = "id = \"coords_hud\"\nkind = \"mod\"\nname = \"C\"\nsummary = \"s\"\nowners = [\"bob\"]\nlicense = \"MIT\"\nsource = \"https://x.test\"\n\n[[versions]]\nversion = \"0.0.1\"\npublished = \"2026-01-01\"\ngame = \"26.3\"\nfile = \"coords_hud-0.0.1.hmod\"\nsha256 = \"0000000000000000000000000000000000000000000000000000000000000000\"\n"; + mock.lock() + .unwrap() + .main + .insert("mods/coords_hud.toml".into(), owned.into()); + let (status, json) = client.submit(&csrf, &form(), &file).await; + assert_eq!(status, StatusCode::FORBIDDEN); + refused(status, &json, "bob's"); + // From another site. + let mut request = Request::post("/api/mods/submit") + .header(header::COOKIE, client.cookie_header()) + .header(header::ORIGIN, "https://evil.test") + .header("x-csrf-token", &csrf) + .body(Body::empty()) + .unwrap(); + request.headers_mut().insert( + header::CONTENT_TYPE, + "multipart/form-data; boundary=x".parse().unwrap(), + ); + let (status, ..) = client.send(request).await; + assert_eq!(status, StatusCode::FORBIDDEN); + let m = mock.lock().unwrap(); + assert!( + m.assets.is_empty() && m.pulls.is_empty(), + "nothing reached GitHub" + ); +} diff --git a/docs/api/mod-index.md b/docs/api/mod-index.md index afa6e8944..beb6ec2c8 100644 --- a/docs/api/mod-index.md +++ b/docs/api/mod-index.md @@ -129,6 +129,8 @@ reason = "Sent chat messages it didn't declare." date = "2026-10-24" ``` +The submission form's service (`crates/horizon-mod-registry`) writes these entries: it signs the creator in with GitHub, runs the file's checks, uploads the file to the repository's `staging` release as `.` and opens the pull request. Only an entry's `owners` publish updates through it. + Unknown keys are refused. Its CI runs `cargo-horizon-mod` (`crates/cargo-horizon-mod`): | Command | What it does | From 1776d7ca5ce3f0de51c41006c045460ba2c40ae0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sat, 24 Oct 2026 23:46:24 +0200 Subject: [PATCH 14/50] Shader packs, clean room 10: post-processing over Horizon's world The first slice that draws: with HORIZON_SHADER_PACK=, the pack in shaderpacks/ (folder or zip) runs its prepare, deferred, composite and final programs over the world, under the hand and the HUD. Gbuffers programs come later, so the composites read Horizon's own picture. - The world renders into its own target (as with a resolution scale); its colour becomes colortex0 and its reverse-Z depth becomes depthtex0 in GL's window depth (1 - d for the same planes), rows flipped to GL's order. depthtex1 and depthtex2 read the same depth for now. - colortex buffers in the formats the pack asked for (the nearest one wgpu renders to), created only when a program uses them, each with two textures: programs read main and write alt, the buffers a program wrote flip after it, flip.* keys and deferred_pre/composite_pre apply, and the mapping carries over to the next frame. Clears at the start of the frame with the pack's colours or the defaults. - noisetex (256x256 white noise, repeat), a lit stand-in behind shadow samplers until shadow maps exist, a 1x1 stand-in for other names; the frame uniforms of the previous commit and the translator's own block. - final draws onto the window; without it colortex0 is shown. - Pipelines are built through validation scopes: a program wgpu refuses is reported with the full error and skipped. The dimension's folder is reloaded when the player changes dimension. Nothing changes without the environment variable: players see no change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 4 + crates/horizon-client/Cargo.toml | 4 +- .../horizon-client/src/game/shader_packs.rs | 58 +- crates/horizon-client/src/gfx.rs | 84 +- crates/horizon-client/src/gfx/pack_post.rs | 866 ++++++++++++++++++ crates/horizon-client/src/gfx/pack_post.wgsl | 47 + crates/horizon-client/src/gfx/world_post.rs | 9 +- 7 files changed, 1057 insertions(+), 15 deletions(-) create mode 100644 crates/horizon-client/src/gfx/pack_post.rs create mode 100644 crates/horizon-client/src/gfx/pack_post.wgsl diff --git a/Cargo.lock b/Cargo.lock index 7b1770094..46aa212a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -428,6 +428,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ + "serde", + "termcolor", "unicode-width", ] @@ -1266,6 +1268,7 @@ dependencies = [ "horizon-mods", "horizon-protocol", "horizon-server-files", + "horizon-shaders", "horizon-telemetry", "horizon-theme", "horizon-threads", @@ -4176,6 +4179,7 @@ dependencies = [ "hashbrown 0.17.1", "js-sys", "log", + "naga", "parking_lot", "portable-atomic", "profiling", diff --git a/crates/horizon-client/Cargo.toml b/crates/horizon-client/Cargo.toml index 35e9bee5c..80bc03344 100644 --- a/crates/horizon-client/Cargo.toml +++ b/crates/horizon-client/Cargo.toml @@ -16,11 +16,13 @@ horizon-world = { path = "../horizon-world" } horizon-protocol = { path = "../horizon-protocol" } # server.properties, eula.txt and the user lists, shared with horizon-server. horizon-server-files = { path = "../horizon-server-files" } +# Shader packs: reading them and turning their GLSL into naga modules. +horizon-shaders = { path = "../horizon-shaders" } horizon-friends = { path = "../horizon-friends" } # Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md). # Their runtime is the `mods` feature. horizon-mods = { path = "../horizon-mods", default-features = false } -wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl"] } +wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] } winit = "0.30.13" pollster = "0.4" bytemuck = { version = "1.24", features = ["derive"] } diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index e2cff8902..721a1cb58 100644 --- a/crates/horizon-client/src/game/shader_packs.rs +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -1,16 +1,66 @@ //! Shader packs, for development until the Shader Packs page exists: `HORIZON_SHADER_PACK=` -//! (the runtime that uses it comes next). What the pack's uniforms need from the game. +//! runs `shaderpacks/` (a folder or a zip beside `options.txt`) over the world. The +//! programs of the current dimension's folder are loaded when the dimension changes (spec 1.2). use horizon_client::camera::CameraFluid; use horizon_client::gfx::pack_uniforms::PackInputs; +use horizon_shaders::{Macros, Pack, Properties, dimension_folder, load_post}; -use super::Game; +use super::{Dim, Game}; impl Game<'_> { - /// This frame's inputs to the pack's uniforms. + /// Loads the pack for the current dimension when it changed, and returns this frame's + /// inputs to the pack's uniforms. pub(super) fn shader_pack_inputs(&mut self, camera: glam::DVec3, partial: f32) -> PackInputs { - if std::env::var_os("HORIZON_SHADER_PACK").is_none() { + let Some(name) = std::env::var_os("HORIZON_SHADER_PACK") else { return PackInputs::default(); + }; + let dimension = match self.dim { + Dim::Overworld => 0, + Dim::Nether => -1, + Dim::End => 1, + }; + if let Some(renderer) = &mut self.renderer + && renderer.shader_pack_dimension() != Some(dimension) + { + let name = name.to_string_lossy(); + let started = std::time::Instant::now(); + let path = std::env::current_dir() + .unwrap_or_default() + .join("shaderpacks") + .join(&*name); + match Pack::open(&path) { + Ok(pack) => { + let macros = Macros::standard(); + let text = pack.read_text("shaders.properties").unwrap_or_default(); + let properties = Properties::parse(&text, ¯os); + for note in &properties.notes { + log::warn!( + "shader pack {name}: shaders.properties:{}: {}", + note.line, + note.message + ); + } + let folder = dimension_folder(&pack, dimension); + let post = load_post(&pack, &folder, &properties, ¯os); + if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post)) + { + for note in notes { + log::warn!("shader pack {name}: {note}"); + } + log::info!( + "shader pack {name}: folder `{folder}`, {} programs ({}) in {} ms", + programs.len(), + programs.join(" "), + started.elapsed().as_millis() + ); + } + } + Err(e) => { + log::warn!("shader pack {name}: {e}"); + renderer.set_shader_pack(dimension, None); + } + } } let lerp = |(old, new): (f32, f32)| old + (new - old) * partial; let at = camera.floor().as_ivec3(); diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 02cac38d6..08fb9061e 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -68,6 +68,7 @@ mod flat; pub mod lightning; mod lod; mod occlusion; +mod pack_post; pub mod pack_uniforms; mod page_runs; pub mod panorama; @@ -88,6 +89,7 @@ fn world_pass_after_culling<'e>( encoder: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, depth: &wgpu::TextureView, + keep_depth: bool, ) -> wgpu::RenderPass<'e> { encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Horizon world, after culling"), @@ -104,7 +106,12 @@ fn world_pass_after_culling<'e>( view: depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Discard, + // A shader pack reads it as depthtex0. + store: if keep_depth { + wgpu::StoreOp::Store + } else { + wgpu::StoreOp::Discard + }, }), stencil_ops: None, }), @@ -459,6 +466,8 @@ pub struct Renderer { occlusion: occlusion::Occlusion, /// Resolution scale and FXAA (Horizon settings). world_post: world_post::Post, + /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. + pack_post: Option<(i32, Option)>, pub hud: HudRenderer, /// Vanilla's menu background blur, made on first use. menu_blur: Option, @@ -2303,6 +2312,7 @@ impl Renderer { block_entity_revision: next_block_entity_revision(), occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, + pack_post: None, hud, menu_blur: None, info, @@ -2721,6 +2731,32 @@ impl Renderer { self.world_post.scale = scale.clamp(0.25, 2.0); self.world_post.antialias = antialias; } + /// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`. + /// Returns the programs that run and the notes of loading them. + pub fn set_shader_pack( + &mut self, + dimension: i32, + pack: Option, + ) -> Option<(Vec, Vec)> { + let post = pack.map(|post| { + pack_post::PackPost::new( + &self.device, + &self.queue, + post, + self.config.format.remove_srgb_suffix(), + ) + }); + let report = post.as_ref().map(|p| { + let programs = p.programs().into_iter().map(str::to_owned).collect(); + (programs, p.notes.clone()) + }); + self.pack_post = Some((dimension, post)); + report + } + /// The dimension the shader pack was last set for. + pub fn shader_pack_dimension(&self) -> Option { + self.pack_post.as_ref().map(|p| p.0) + } /// The Mipmap Levels option: block textures sample at most `levels` /// reduced copies (0: none). pub fn set_mipmap_levels(&mut self, levels: u32) { @@ -3582,9 +3618,12 @@ impl Renderer { timer.begin(&mut encoder); } // The world's targets: the screen, or the scaled target of `post`. - let scaled = self - .world_post - .prepare(&self.device, [self.config.width, self.config.height]); + let pack_post = !frame.title && self.pack_post.as_ref().is_some_and(|p| p.1.is_some()); + let scaled = self.world_post.prepare( + &self.device, + [self.config.width, self.config.height], + pack_post, + ); let (world_view, world_depth) = match &scaled { Some((color, depth)) => (color, depth), None => (&view_target, &self.depth), @@ -3621,8 +3660,8 @@ impl Renderer { view: world_depth, depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0.0), - // Phase 2 of occlusion culling draws over it. - store: if cull.is_some() { + // Phase 2 of occlusion culling draws over it; a shader pack reads it. + store: if cull.is_some() || pack_post { wgpu::StoreOp::Store } else { wgpu::StoreOp::Discard @@ -3681,7 +3720,12 @@ impl Renderer { drop(pass); cull.test_pass(&mut encoder); flat.flatten_phase2(&mut encoder); - pass = world_pass_after_culling(&mut encoder, world_view, world_depth); + pass = world_pass_after_culling( + &mut encoder, + world_view, + world_depth, + pack_post, + ); pass.set_bind_group(0, &self.group, &[]); pass.set_pipeline(&flat.solid); flat.draw_phase2(&mut pass, vertices); @@ -3705,7 +3749,12 @@ impl Renderer { cull.test(&mut pass); drop(pass); cull.test_pass(&mut encoder); - pass = world_pass_after_culling(&mut encoder, world_view, world_depth); + pass = world_pass_after_culling( + &mut encoder, + world_view, + world_depth, + pack_post, + ); pass.set_bind_group(0, &self.group, &[]); pass.set_pipeline(&self.solid); pass.set_index_buffer( @@ -3864,7 +3913,24 @@ impl Renderer { ); } } - if scaled.is_some() { + if let Some((color, depth)) = &scaled + && pack_post + && let Some((_, Some(post))) = &mut self.pack_post + { + let size = self.world_post.size().unwrap_or([1, 1]); + post.run( + &self.device, + &self.queue, + &mut encoder, + color, + depth, + &view_target, + frame, + &frame.pack, + size, + [self.config.width, self.config.height], + ); + } else if scaled.is_some() { self.world_post.finish(&mut encoder, &view_target); } if hand_quads > 0 { diff --git a/crates/horizon-client/src/gfx/pack_post.rs b/crates/horizon-client/src/gfx/pack_post.rs new file mode 100644 index 000000000..e8ecde11e --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_post.rs @@ -0,0 +1,866 @@ +//! A shader pack's post-processing over Horizon's own world (spec 2.1 rows 4, 6, 8, 9; 6.1; 6.2; +//! 6.4): the world renders into its own target as with a resolution scale, its colour becomes +//! colortex0 and its depth `depthtex0`, then the pack's `prepare`, `deferred`, `composite` and +//! `final` programs run in order, and `final` draws onto the screen under the hand and HUD. +//! Gbuffers programs come later; until then the pack's composites read Horizon's picture. +//! +//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read +//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The +//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was +//! written last (OQ-8). Buffers are cleared at the start of the frame, "main" only. +//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so +//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth. + +use std::collections::HashMap; + +use horizon_shaders::{Format, Group, Kind, Post}; + +use super::pack_uniforms::{self, FrameUniforms, PackInputs}; + +const BUFFERS: usize = horizon_shaders::BUFFERS; + +/// The texture behind a pack sampler name. +#[derive(Clone, Copy, PartialEq)] +enum Source { + Color(usize), + Depth, + Noise, + /// A 1×1 stand-in for names this slice does not provide yet (custom textures...). + Dummy, + /// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow. + Shadow, +} + +struct Binding { + source: Source, + binding: u32, + /// A filtering sampler (the texture is filterable). + filtering: bool, +} + +struct Pass { + name: String, + group: Group, + pipeline: wgpu::RenderPipeline, + layout: wgpu::BindGroupLayout, + ubo: wgpu::Buffer, + block: Vec, + /// (index into `pack_uniforms::NAMES`, declared type, offset). + uniforms: Vec<(Option, String, u32)>, + samplers: Vec, + /// (location, buffer) of each output that reaches a buffer. + writes: Vec<(u32, usize)>, + /// Buffers flipped after the pass. + flip: u16, + /// Buffers sampled, for the bind group cache key. + reads: u16, + groups: HashMap, +} + +struct Targets { + size: [u32; 2], + /// A and B of each buffer used. + color: [Option<[wgpu::TextureView; 2]>; BUFFERS], + depth: wgpu::TextureView, +} + +pub struct PackPost { + pub notes: Vec, + passes: Vec, + deferred_pre: u16, + composite_pre: u16, + formats: [wgpu::TextureFormat; BUFFERS], + clears: [Option<[f32; 4]>; BUFFERS], + used: u16, + /// Bit n set: B is buffer n's "main". + main: u16, + targets: Option, + noise: wgpu::TextureView, + dummy: wgpu::TextureView, + /// The stand-in shadow map and its comparison sampler; cleared on the first frame. + shadow: (wgpu::TextureView, wgpu::Sampler), + shadow_cleared: bool, + linear: wgpu::Sampler, + nearest: wgpu::Sampler, + repeat: wgpu::Sampler, + blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout), + /// Shows colortex0 when the pack has no `final` (OQ-5). + copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout), + uniforms: FrameUniforms, +} + +/// The wgpu format of a pack format (6.7), the nearest one wgpu can render to. +fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat { + use wgpu::TextureFormat as T; + let pick = |r, rg, rgba| match f.channels { + 1 => r, + 2 => rg, + _ => rgba, + }; + let half = pick(T::R16Float, T::Rg16Float, T::Rgba16Float); + match (f.kind, f.bits) { + (Kind::Unorm, 10) => T::Rgb10a2Unorm, + (Kind::Unorm, 16) if features.contains(wgpu::Features::TEXTURE_FORMAT_16BIT_NORM) => { + pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm) + } + (Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm), + (Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float), + (Kind::Float, 11) if features.contains(wgpu::Features::RG11B10UFLOAT_RENDERABLE) => { + T::Rg11b10Ufloat + } + (Kind::Int, 8) => pick(T::R8Sint, T::Rg8Sint, T::Rgba8Sint), + (Kind::Int, 16) => pick(T::R16Sint, T::Rg16Sint, T::Rgba16Sint), + (Kind::Int, _) => pick(T::R32Sint, T::Rg32Sint, T::Rgba32Sint), + (Kind::Uint, 8) => pick(T::R8Uint, T::Rg8Uint, T::Rgba8Uint), + (Kind::Uint, 10) => T::Rgb10a2Uint, + (Kind::Uint, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint), + (Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint), + // Snorm, 16-bit norm without the feature, RGB9_E5: half floats hold them. + _ => half, + } +} + +fn source(name: &str) -> Source { + match name { + "depthtex0" | "gdepthtex" | "depthtex1" | "depthtex2" => Source::Depth, + "noisetex" => Source::Noise, + n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color), + } +} + +/// 256×256 RGB white noise (6.4), from a fixed seed so every run looks the same. +fn noise(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView { + let mut state = 0x2545_f491_4f6c_dd1du64; + let texels: Vec = (0..256 * 256 * 4) + .map(|i| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + if i % 4 == 3 { 255 } else { (state >> 56) as u8 } + }) + .collect(); + texture(device, queue, "Shader pack noisetex", 256, &texels) +} + +fn texture( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + side: u32, + texels: &[u8], +) -> wgpu::TextureView { + let size = wgpu::Extent3d { + width: side, + height: side, + depth_or_array_layers: 1, + }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + tex.as_image_copy(), + texels, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(side * 4), + rows_per_image: None, + }, + size, + ); + tex.create_view(&Default::default()) +} + +fn sampler( + device: &wgpu::Device, + filter: wgpu::FilterMode, + address: wgpu::AddressMode, +) -> wgpu::Sampler { + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Shader pack"), + address_mode_u: address, + address_mode_v: address, + address_mode_w: address, + mag_filter: filter, + min_filter: filter, + ..Default::default() + }) +} + +/// Runs `create` and returns its validation error, if any. +fn checked(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result { + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let value = create(); + match pollster::block_on(scope.pop()) { + Some(e) => { + // The details are in the error's sources. + let mut message = e.to_string(); + let mut source = std::error::Error::source(&e); + while let Some(s) = source { + message = format!("{message}: {s}"); + source = s.source(); + } + Err(message) + } + None => Ok(value), + } +} + +/// A full-screen helper pipeline of ours (`pack_post.wgsl`). +fn helper( + device: &wgpu::Device, + vertex: &str, + entry: &str, + entries: &[wgpu::BindGroupLayoutEntry], + targets: &[Option], +) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("Shader pack helpers"), + source: wgpu::ShaderSource::Wgsl(include_str!("pack_post.wgsl").into()), + }); + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(entry), + entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(entry), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(entry), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some(vertex), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(entry), + compilation_options: Default::default(), + targets, + }), + multiview_mask: None, + cache: None, + }); + (pipeline, layout) +} + +fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty, + count: None, + } +} + +fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry { + entry( + binding, + wgpu::BindingType::Texture { + sample_type, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + ) +} + +fn target(format: wgpu::TextureFormat) -> Option { + Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + }) +} + +impl PackPost { + /// Builds the pipelines of `post` for a screen of `screen` format. + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + post: Post, + screen: wgpu::TextureFormat, + ) -> Self { + let features = device.features(); + let formats: [wgpu::TextureFormat; BUFFERS] = + std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features)); + let clears = std::array::from_fn(|i| { + let b = &post.config.buffers[i]; + b.clear.then(|| { + b.clear_color + .unwrap_or(if i == 1 { [1.0; 4] } else { [0.0; 4] }) + }) + }); + let mut notes = post.notes; + notes.extend(post.config.notes); + let mask = + |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0); + let mut used = 1u16; + let mut passes = Vec::new(); + for p in post.programs { + let interface = &p.program.interface; + let mut entries = vec![entry( + 0, + wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + )]; + let mut samplers = Vec::new(); + let mut reads = 0u16; + for s in &interface.samplers { + let mut src = source(&s.name); + let float = wgpu::TextureSampleType::Float { filterable: true }; + let mut sample_type = match src { + Source::Color(n) => formats[n] + .sample_type(None, Some(features)) + .unwrap_or(float), + Source::Depth => wgpu::TextureSampleType::Float { filterable: false }, + _ => float, + }; + let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. }); + if s.ty == "sampler2DShadow" { + (src, sample_type) = (Source::Shadow, wgpu::TextureSampleType::Depth); + } + let wanted = match s.ty.as_str() { + "sampler2DShadow" => true, + "sampler2D" => !int, + "isampler2D" => sample_type == wgpu::TextureSampleType::Sint, + "usampler2D" => sample_type == wgpu::TextureSampleType::Uint, + _ => false, + }; + if !wanted { + notes.push(format!( + "{}: `{} {}` is not provided yet", + p.name, s.ty, s.name + )); + (src, sample_type) = (Source::Dummy, float); + } + let filtering = sample_type == float; + if let Source::Color(n) = src { + reads |= 1 << n; + } + entries.push(texture_entry(s.binding, sample_type)); + entries.push(entry( + s.binding + 1, + wgpu::BindingType::Sampler(if src == Source::Shadow { + wgpu::SamplerBindingType::Comparison + } else if filtering { + wgpu::SamplerBindingType::Filtering + } else { + wgpu::SamplerBindingType::NonFiltering + }), + )); + samplers.push(Binding { + source: src, + binding: s.binding, + filtering, + }); + } + let final_pass = p.group == Group::Final; + let mut targets = Vec::new(); + let mut writes = Vec::new(); + for &location in &interface.outputs { + let l = location as usize; + if targets.len() <= l { + targets.resize(l + 1, None); + } + if final_pass { + if location == 0 { + targets[0] = target(screen); + } + } else if let Some(&buffer) = p.buffers.get(l) { + targets[l] = target(formats[buffer as usize]); + writes.push((location, buffer as usize)); + } + } + if !final_pass && writes.is_empty() { + notes.push(format!("{}: writes no buffer; skipped", p.name)); + continue; + } + let written = writes.iter().fold(0u16, |m, w| m | 1 << w.1); + let kept = p + .flips + .iter() + .filter(|f| !f.1) + .fold(0u16, |m, f| m | 1 << f.0); + let flip = if final_pass { + 0 + } else { + (written & !kept) | mask(&p.flips) + }; + used |= reads | written | flip; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some(&p.name), + entries: &entries, + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&p.name), + bind_group_layouts: &[Some(&layout)], + ..Default::default() + }); + let created = checked(device, || { + let module = |m: naga::Module| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&p.name), + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)), + }) + }; + let (vs, fs) = (module(p.program.vertex), module(p.program.fragment)); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&p.name), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &vs, + entry_point: Some("main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &fs, + entry_point: Some("main"), + compilation_options: Default::default(), + targets: &targets, + }), + multiview_mask: None, + cache: None, + }) + }); + let pipeline = match created { + Ok(p) => p, + Err(e) => { + notes.push(format!("{}: skipped: {e}", p.name)); + continue; + } + }; + let interface = &p.program.interface; + passes.push(Pass { + ubo: device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&p.name), + size: u64::from(interface.uniform_size.max(16)), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + block: vec![0; interface.uniform_size as usize], + uniforms: interface + .uniforms + .iter() + .map(|u| (pack_uniforms::index(&u.name), u.ty.clone(), u.offset)) + .collect(), + name: p.name, + group: p.group, + pipeline, + layout, + samplers, + writes, + flip, + reads, + groups: HashMap::new(), + }); + } + let float = wgpu::TextureSampleType::Float { filterable: true }; + let blit = helper( + device, + "vs_full", + "fs_blit", + &[ + texture_entry(0, float), + texture_entry(1, wgpu::TextureSampleType::Depth), + ], + &[target(formats[0]), target(wgpu::TextureFormat::R32Float)], + ); + let copy = helper( + device, + "vs_uv", + "fs_copy", + &[ + texture_entry(0, float), + entry( + 2, + wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + ), + ], + &[target(screen)], + ); + let white = [255u8; 4]; + Self { + notes, + passes, + deferred_pre: mask(&post.deferred_pre), + composite_pre: mask(&post.composite_pre), + formats, + clears, + used, + main: 0, + targets: None, + noise: noise(device, queue), + dummy: texture(device, queue, "Shader pack stand-in", 1, &white), + shadow: ( + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("Shader pack shadow stand-in"), + 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::Depth32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }) + .create_view(&Default::default()), + device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("Shader pack shadow"), + compare: Some(wgpu::CompareFunction::LessEqual), + ..Default::default() + }), + ), + shadow_cleared: false, + linear: sampler( + device, + wgpu::FilterMode::Linear, + wgpu::AddressMode::ClampToEdge, + ), + nearest: sampler( + device, + wgpu::FilterMode::Nearest, + wgpu::AddressMode::ClampToEdge, + ), + repeat: sampler(device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat), + blit, + copy, + uniforms: FrameUniforms::default(), + } + } + + /// The names of the programs that run, in order. + pub fn programs(&self) -> Vec<&str> { + self.passes.iter().map(|p| p.name.as_str()).collect() + } + + fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) { + if self.targets.as_ref().is_some_and(|t| t.size == size) { + return; + } + let make = |label: &str, format| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let color = std::array::from_fn(|i| { + (self.used & 1 << i != 0).then(|| { + let label = format!("colortex{i}"); + [make(&label, self.formats[i]), make(&label, self.formats[i])] + }) + }); + self.targets = Some(Targets { + size, + color, + depth: make("depthtex0", wgpu::TextureFormat::R32Float), + }); + for pass in &mut self.passes { + pass.groups.clear(); + } + } + + /// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws + /// the result onto `screen`. + #[allow(clippy::too_many_arguments)] + pub fn run( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + encoder: &mut wgpu::CommandEncoder, + world: &wgpu::TextureView, + world_depth: &wgpu::TextureView, + screen: &wgpu::TextureView, + frame: &super::Frame, + inputs: &PackInputs, + size: [u32; 2], + window: [u32; 2], + ) { + self.resize(device, size); + self.uniforms.update(frame, inputs, size); + let Some(targets) = &self.targets else { return }; + let main = |m: u16, i: usize| usize::from(m & 1 << i != 0); + let view = |m: u16, i: usize, alt: bool| { + targets.color[i] + .as_ref() + .map(|t| &t[main(m, i) ^ usize::from(alt)]) + }; + + if !self.shadow_cleared { + self.shadow_cleared = true; + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack shadow stand-in"), + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &self.shadow.0, + depth_ops: Some(wgpu::Operations { + load: wgpu::LoadOp::Clear(1.0), + store: wgpu::StoreOp::Store, + }), + stencil_ops: None, + }), + ..Default::default() + }); + } + // The clears, eight buffers to a pass; colortex0 is the world. + let clears: Vec<(usize, [f32; 4])> = (1..BUFFERS) + .filter(|&i| targets.color[i].is_some()) + .filter_map(|i| Some((i, self.clears[i]?))) + .collect(); + for chunk in clears.chunks(8) { + let attachments: Vec<_> = chunk + .iter() + .map(|&(i, c)| { + Some(wgpu::RenderPassColorAttachment { + view: view(self.main, i, false).expect("used buffer"), + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: c[0].into(), + g: c[1].into(), + b: c[2].into(), + a: c[3].into(), + }), + store: wgpu::StoreOp::Store, + }, + }) + }) + .collect(); + encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack clear"), + color_attachments: &attachments, + ..Default::default() + }); + } + + // The world into colortex0 and depthtex0, flipped to GL's rows. + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack world"), + layout: &self.blit.1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(world), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(world_depth), + }, + ], + }); + fn attachment(view: &wgpu::TextureView) -> Option> { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + }) + } + { + let colortex0 = view(self.main, 0, false).expect("colortex0"); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack world"), + color_attachments: &[attachment(colortex0), attachment(&targets.depth)], + ..Default::default() + }); + pass.set_pipeline(&self.blit.0); + pass.set_bind_group(0, &group, &[]); + pass.draw(0..3, 0..1); + } + + let fog = self.uniforms.fog(); + let mut seen = (false, false); + let mut shown = false; + for pass in &mut self.passes { + if pass.group == Group::Deferred && !seen.0 { + seen.0 = true; + self.main ^= self.deferred_pre; + } + if pass.group == Group::Composite && !seen.1 { + seen.1 = true; + self.main ^= self.composite_pre; + } + let final_pass = pass.group == Group::Final; + let out = if final_pass { window } else { size }; + // Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's. + let ours = [ + [ + out[0] as f32, + out[1] as f32, + 1.0 / out[0] as f32, + 1.0 / out[1] as f32, + ], + fog[0].to_array(), + fog[1].to_array(), + ]; + let head: &[u8] = bytemuck::cast_slice(&ours); + let n = head.len().min(pass.block.len()); + pass.block[..n].copy_from_slice(&head[..n]); + for (index, ty, offset) in &pass.uniforms { + if let Some(v) = index.and_then(|i| self.uniforms.values.get(i)) { + v.write(ty, &mut pass.block, *offset as usize); + } + } + if !pass.block.is_empty() { + queue.write_buffer(&pass.ubo, 0, &pass.block); + } + let key = self.main & pass.reads; + let group = pass.groups.entry(key).or_insert_with(|| { + let mut entries = vec![wgpu::BindGroupEntry { + binding: 0, + resource: pass.ubo.as_entire_binding(), + }]; + for s in &pass.samplers { + let filter = if s.filtering { + &self.linear + } else { + &self.nearest + }; + let (texture, sampler) = match s.source { + Source::Color(i) => (view(key, i, false).unwrap_or(&self.dummy), filter), + Source::Depth => (&targets.depth, &self.nearest), + Source::Noise => (&self.noise, &self.repeat), + Source::Dummy => (&self.dummy, &self.linear), + Source::Shadow => (&self.shadow.0, &self.shadow.1), + }; + entries.push(wgpu::BindGroupEntry { + binding: s.binding, + resource: wgpu::BindingResource::TextureView(texture), + }); + entries.push(wgpu::BindGroupEntry { + binding: s.binding + 1, + resource: wgpu::BindingResource::Sampler(sampler), + }); + } + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&pass.name), + layout: &pass.layout, + entries: &entries, + }) + }); + let mut attachments: Vec> = Vec::new(); + if final_pass { + attachments.push(attachment(screen)); + shown = true; + } else { + for &(location, buffer) in &pass.writes { + let l = location as usize; + if attachments.len() <= l { + attachments.resize(l + 1, None); + } + attachments[l] = view(self.main, buffer, true).and_then(attachment); + } + } + { + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass.name), + color_attachments: &attachments, + ..Default::default() + }); + rp.set_pipeline(&pass.pipeline); + rp.set_bind_group(0, &*group, &[]); + rp.draw(0..6, 0..1); + } + self.main ^= pass.flip; + } + if !shown { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack colortex0"), + layout: &self.copy.1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView( + view(self.main, 0, false).expect("colortex0"), + ), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(&self.linear), + }, + ], + }); + let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("Shader pack colortex0"), + color_attachments: &[attachment(screen)], + ..Default::default() + }); + rp.set_pipeline(&self.copy.0); + rp.set_bind_group(0, &group, &[]); + rp.draw(0..3, 0..1); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wgpu::TextureFormat as T; + + #[test] + fn pack_formats_map_to_renderable_ones() { + let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty()); + assert_eq!(f("RGBA"), T::Rgba8Unorm); + assert_eq!(f("RGB8"), T::Rgba8Unorm, "no three-channel formats in wgpu"); + assert_eq!(f("RG16F"), T::Rg16Float); + assert_eq!(f("R32F"), T::R32Float); + assert_eq!(f("RGB10_A2"), T::Rgb10a2Unorm); + assert_eq!(f("R32UI"), T::R32Uint); + assert_eq!(f("RGBA16"), T::Rgba16Float, "16-bit norm needs a feature"); + assert_eq!(f("RGBA8_SNORM"), T::Rgba16Float, "snorm is not renderable"); + assert_eq!(f("R11F_G11F_B10F"), T::Rgba16Float); + let all = + wgpu::Features::TEXTURE_FORMAT_16BIT_NORM | wgpu::Features::RG11B10UFLOAT_RENDERABLE; + let g = |name| texture_format(Format::parse(name).unwrap(), all); + assert_eq!(g("RGBA16"), T::Rgba16Unorm); + assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat); + } + + #[test] + fn sampler_names() { + assert!(source("gaux1") == Source::Color(4)); + assert!(source("gdepthtex") == Source::Depth); + assert!(source("noisetex") == Source::Noise); + assert!(source("shadowcolor0") == Source::Dummy); + } +} diff --git a/crates/horizon-client/src/gfx/pack_post.wgsl b/crates/horizon-client/src/gfx/pack_post.wgsl new file mode 100644 index 000000000..a20762c0f --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_post.wgsl @@ -0,0 +1,47 @@ +// Helpers of a shader pack's post-processing (pack_post.rs): the world into colortex0 and +// depthtex0, and colortex0 onto the screen for packs without `final`. + +@vertex +fn vs_full(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + return vec4(uv * 2.0 - 1.0, 0.0, 1.0); +} + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var world_depth: texture_depth_2d; + +struct World { + @location(0) color: vec4, + @location(1) depth: vec4, +} + +// Pack buffers keep GL's rows (row 0 at the bottom), and GL's window depth: Horizon's +// reverse-Z depth is 1 - d for the same near and far planes. +@fragment +fn fs_blit(@builtin(position) p: vec4) -> World { + let size = vec2(textureDimensions(source)); + let at = vec2(i32(p.x), size.y - 1 - i32(p.y)); + var out: World; + out.color = textureLoad(source, at, 0); + out.depth = vec4(1.0 - textureLoad(world_depth, at, 0), 0.0, 0.0, 1.0); + return out; +} + +@group(0) @binding(2) var linear: sampler; + +struct Full { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +@vertex +fn vs_uv(@builtin(vertex_index) i: u32) -> Full { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + // Screen top (uv.y = 0 in wgpu) shows the buffer's top row, GL's last. + return Full(vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0), vec2(uv.x, 1.0 - uv.y)); +} + +@fragment +fn fs_copy(v: Full) -> @location(0) vec4 { + return textureSample(source, linear, v.uv); +} diff --git a/crates/horizon-client/src/gfx/world_post.rs b/crates/horizon-client/src/gfx/world_post.rs index 934fed6d6..17609d27d 100644 --- a/crates/horizon-client/src/gfx/world_post.rs +++ b/crates/horizon-client/src/gfx/world_post.rs @@ -101,12 +101,14 @@ impl Post { /// The world's colour and depth targets this frame, or `None` to draw /// it straight to the screen. + /// `always`: a target even at scale 1 without FXAA (a shader pack reads it). pub fn prepare( &mut self, device: &wgpu::Device, window: [u32; 2], + always: bool, ) -> Option<(wgpu::TextureView, wgpu::TextureView)> { - if self.scale == 1.0 && !self.antialias { + if self.scale == 1.0 && !self.antialias && !always { self.target = None; return None; } @@ -155,6 +157,11 @@ impl Post { .map(|t| (t.color.clone(), t.depth.clone())) } + /// The size of the world's own target, when it has one. + pub fn size(&self) -> Option<[u32; 2]> { + self.target.as_ref().map(|t| t.size) + } + /// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`. pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup { device.create_bind_group(&wgpu::BindGroupDescriptor { From 282ecf16a974bc0c6de5c18b35d7f278ea9279c8 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 00:45:16 +0200 Subject: [PATCH 15/50] Tests: fix boss-event verify failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit boss_event_round_trips missed its #[test] attribute, failing clippy's dead-code lint; unknown_packets_keep_their_payload decoded boss_event, which this PR gives a real decoder, so the garbage payload no longer comes back Unknown — it uses player_look_at, which still has none. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-protocol/src/packets/play.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index 7dd8156c4..1a2880f5e 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -2880,10 +2880,11 @@ mod tests { #[test] fn unknown_packets_keep_their_payload() { - let p = Clientbound::decode(crate::ids::play::clientbound::BOSS_EVENT, &[1, 2, 3]).unwrap(); + let p = + Clientbound::decode(crate::ids::play::clientbound::PLAYER_LOOK_AT, &[1, 2, 3]).unwrap(); assert_eq!( p, - Clientbound::Unknown(crate::ids::play::clientbound::BOSS_EVENT, vec![1, 2, 3]) + Clientbound::Unknown(crate::ids::play::clientbound::PLAYER_LOOK_AT, vec![1, 2, 3]) ); assert_eq!(p.encode()[1..], [1, 2, 3]); } @@ -2980,6 +2981,7 @@ mod tests { w.varint(101); assert!(BookPages::read(&mut Reader::new(&w.data)).is_err()); } + #[test] fn boss_event_round_trips() { let add = Clientbound::BossEvent(BossEvent { id: 0x0123_4567_89ab_cdef_fedc_ba98_7654_3210, From 466b1de4c4d19f6632c79ecca13a09645be34f25 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:46:07 +0200 Subject: [PATCH 16/50] Shader packs: fewer passes and no needless loads in post-processing - A buffer is cleared only when a program reads it before one writes all of it; the clears left ride in the blit pass as attachments the blit shader leaves alone, instead of passes of their own. - The blit writes depthtex0 only when some program reads it. - Attachments a pass overwrites whole (the blit's colortex0 and depth, every composite output, final's window) start cleared rather than loaded, which spares a tiled GPU reading them back first. - The blit's bindings are kept until the world target changes size. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx/pack_post.rs | 173 +++++++++++++------ crates/horizon-client/src/gfx/pack_post.wgsl | 7 + 2 files changed, 125 insertions(+), 55 deletions(-) diff --git a/crates/horizon-client/src/gfx/pack_post.rs b/crates/horizon-client/src/gfx/pack_post.rs index e8ecde11e..d5aa9fb7c 100644 --- a/crates/horizon-client/src/gfx/pack_post.rs +++ b/crates/horizon-client/src/gfx/pack_post.rs @@ -54,6 +54,8 @@ struct Pass { flip: u16, /// Buffers sampled, for the bind group cache key. reads: u16, + /// Buffers written. + written: u16, groups: HashMap, } @@ -84,6 +86,13 @@ pub struct PackPost { nearest: wgpu::Sampler, repeat: wgpu::Sampler, blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout), + /// The blit also writes depthtex0 (some program reads it) and clears these buffers. + blit_depth: bool, + blit_clears: Vec, + /// Buffers cleared in passes of their own (more than the blit's eight attachments). + other_clears: Vec, + /// The blit's bindings, for the world target of this size. + blit_group: Option<([u32; 2], wgpu::BindGroup)>, /// Shows colortex0 when the pack has no `final` (OQ-5). copy: (wgpu::RenderPipeline, wgpu::BindGroupLayout), uniforms: FrameUniforms, @@ -285,6 +294,18 @@ fn target(format: wgpu::TextureFormat) -> Option { }) } +fn attach( + view: &wgpu::TextureView, + ops: wgpu::Operations, +) -> Option> { + Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + depth_slice: None, + ops, + }) +} + impl PackPost { /// Builds the pipelines of `post` for a screen of `screen` format. pub fn new( @@ -471,19 +492,55 @@ impl PackPost { writes, flip, reads, + written, groups: HashMap::new(), }); } + // A clear only matters when a program reads the buffer before one writes all of it + // (composite passes cover their whole target); the others are skipped. + let mut cleared = Vec::new(); + for b in 1..BUFFERS { + let bit = 1u16 << b; + let first = passes + .iter() + .find(|p| (p.reads | p.flip | p.written) & bit != 0); + if clears[b].is_some() + && first.is_some_and(|p| p.reads & bit != 0 || p.written & bit == 0) + { + cleared.push(b); + } + } + let blit_depth = passes + .iter() + .any(|p| p.samplers.iter().any(|s| s.source == Source::Depth)); + let room = 8 - 1 - usize::from(blit_depth); + let other_clears = cleared.split_off(room.min(cleared.len())); let float = wgpu::TextureSampleType::Float { filterable: true }; + let mut blit_targets = vec![target(formats[0])]; + if blit_depth { + blit_targets.push(target(wgpu::TextureFormat::R32Float)); + } + // Cleared by the pass's load operation; the shader writes nothing there. + blit_targets.extend(cleared.iter().map(|&b| { + Some(wgpu::ColorTargetState { + format: formats[b], + blend: None, + write_mask: wgpu::ColorWrites::empty(), + }) + })); let blit = helper( device, "vs_full", - "fs_blit", + if blit_depth { + "fs_blit" + } else { + "fs_blit_color" + }, &[ texture_entry(0, float), texture_entry(1, wgpu::TextureSampleType::Depth), ], - &[target(formats[0]), target(wgpu::TextureFormat::R32Float)], + &blit_targets, ); let copy = helper( device, @@ -507,6 +564,10 @@ impl PackPost { formats, clears, used, + blit_depth, + blit_clears: cleared, + other_clears, + blit_group: None, main: 0, targets: None, noise: noise(device, queue), @@ -638,30 +699,27 @@ impl PackPost { ..Default::default() }); } - // The clears, eight buffers to a pass; colortex0 is the world. - let clears: Vec<(usize, [f32; 4])> = (1..BUFFERS) - .filter(|&i| targets.color[i].is_some()) - .filter_map(|i| Some((i, self.clears[i]?))) - .collect(); - for chunk in clears.chunks(8) { + let clear_op = |b: usize| { + let c = self.clears[b].unwrap_or_default(); + wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: c[0].into(), + g: c[1].into(), + b: c[2].into(), + a: c[3].into(), + }), + store: wgpu::StoreOp::Store, + } + }; + // Overwritten whole: clearing lets a tiled GPU skip loading the old content. + let overwrite = wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }; + for chunk in self.other_clears.chunks(8) { let attachments: Vec<_> = chunk .iter() - .map(|&(i, c)| { - Some(wgpu::RenderPassColorAttachment { - view: view(self.main, i, false).expect("used buffer"), - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Clear(wgpu::Color { - r: c[0].into(), - g: c[1].into(), - b: c[2].into(), - a: c[3].into(), - }), - store: wgpu::StoreOp::Store, - }, - }) - }) + .map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b))) .collect(); encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Shader pack clear"), @@ -670,41 +728,45 @@ impl PackPost { }); } - // The world into colortex0 and depthtex0, flipped to GL's rows. - let group = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("Shader pack world"), - layout: &self.blit.1, - entries: &[ - wgpu::BindGroupEntry { - binding: 0, - resource: wgpu::BindingResource::TextureView(world), - }, - wgpu::BindGroupEntry { - binding: 1, - resource: wgpu::BindingResource::TextureView(world_depth), - }, - ], - }); - fn attachment(view: &wgpu::TextureView) -> Option> { - Some(wgpu::RenderPassColorAttachment { - view, - resolve_target: None, - depth_slice: None, - ops: wgpu::Operations { - load: wgpu::LoadOp::Load, - store: wgpu::StoreOp::Store, - }, - }) + // The world into colortex0 and depthtex0, flipped to GL's rows, and the clears. + if self.blit_group.as_ref().is_none_or(|g| g.0 != size) { + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("Shader pack world"), + layout: &self.blit.1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(world), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(world_depth), + }, + ], + }); + self.blit_group = Some((size, group)); } { - let colortex0 = view(self.main, 0, false).expect("colortex0"); + let mut attachments = vec![attach( + view(self.main, 0, false).expect("colortex0"), + overwrite, + )]; + if self.blit_depth { + attachments.push(attach(&targets.depth, overwrite)); + } + for &b in &self.blit_clears { + attachments.push(attach( + view(self.main, b, false).expect("used buffer"), + clear_op(b), + )); + } let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Shader pack world"), - color_attachments: &[attachment(colortex0), attachment(&targets.depth)], + color_attachments: &attachments, ..Default::default() }); pass.set_pipeline(&self.blit.0); - pass.set_bind_group(0, &group, &[]); + pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]); pass.draw(0..3, 0..1); } @@ -780,7 +842,7 @@ impl PackPost { }); let mut attachments: Vec> = Vec::new(); if final_pass { - attachments.push(attachment(screen)); + attachments.push(attach(screen, overwrite)); shown = true; } else { for &(location, buffer) in &pass.writes { @@ -788,7 +850,8 @@ impl PackPost { if attachments.len() <= l { attachments.resize(l + 1, None); } - attachments[l] = view(self.main, buffer, true).and_then(attachment); + attachments[l] = + view(self.main, buffer, true).and_then(|v| attach(v, overwrite)); } } { @@ -822,7 +885,7 @@ impl PackPost { }); let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("Shader pack colortex0"), - color_attachments: &[attachment(screen)], + color_attachments: &[attach(screen, overwrite)], ..Default::default() }); rp.set_pipeline(&self.copy.0); diff --git a/crates/horizon-client/src/gfx/pack_post.wgsl b/crates/horizon-client/src/gfx/pack_post.wgsl index a20762c0f..30345b00f 100644 --- a/crates/horizon-client/src/gfx/pack_post.wgsl +++ b/crates/horizon-client/src/gfx/pack_post.wgsl @@ -27,6 +27,13 @@ fn fs_blit(@builtin(position) p: vec4) -> World { return out; } +// The same without depthtex0, for packs that never read it. +@fragment +fn fs_blit_color(@builtin(position) p: vec4) -> @location(0) vec4 { + let size = vec2(textureDimensions(source)); + return textureLoad(source, vec2(i32(p.x), size.y - 1 - i32(p.y)), 0); +} + @group(0) @binding(2) var linear: sampler; struct Full { From a2a91f98f96c3270dfaf600d9d3ddf06830dbd5c Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:53:03 +0200 Subject: [PATCH 17/50] Shader packs, clean room 11: translating gbuffers programs translate_gbuffers places a vertex-input glue the game writes (it knows its own vertex formats) in front of a pack's gbuffers program, sets the compatibility matrices from gbufferModelView/gbufferProjection and the lightmap texture matrix, and gives declared attributes the game's values (zero when it has none). Nothing calls it yet. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-shaders/src/lib.rs | 7 +- crates/horizon-shaders/src/translate/mod.rs | 149 +++++++++++++++++- .../horizon-shaders/src/translate/rewrite.rs | 16 +- crates/horizon-shaders/src/translate/scan.rs | 8 +- 4 files changed, 168 insertions(+), 12 deletions(-) diff --git a/crates/horizon-shaders/src/lib.rs b/crates/horizon-shaders/src/lib.rs index fa39de067..e31eaec6f 100644 --- a/crates/horizon-shaders/src/lib.rs +++ b/crates/horizon-shaders/src/lib.rs @@ -7,7 +7,7 @@ //! - [`Properties`]: `shaders.properties` and the id files, with their conditional directives (3.1, 1.5). //! - [`Lang`]: the pack's translated names (8.4). //! - [`preprocess`]: a stage file through `#include` and the GLSL preprocessor, lines mapped back (1.4, 1.5). -//! - [`translate_composite`]: a composite-style program into naga modules and its interface (1.6, 2.2). +//! - [`translate_composite`], [`translate_gbuffers`]: a program into naga modules and its interface (1.6, 2.2). //! - [`Config`] and [`draw_buffers`]: the buffer settings and output lists stage files carry (1.7, 6.1). //! - [`load_post`]: a dimension's `prepare`, `deferred`, `composite` and `final` programs, in order (2.1). //! - [`Macros`] and [`eval_condition`]: the macros packs test, and the `#if` arithmetic (1.5). @@ -32,4 +32,7 @@ pub use pack::{Pack, PackError}; pub use post::{Group, Post, PostProgram, dimension_folder, load_post}; pub use preprocess::{Diagnostic, Origin, Preprocessed, preprocess}; pub use properties::{Entry, Note, Properties}; -pub use translate::{Error, Interface, Program, Sampler, Target, Uniform, translate_composite}; +pub use translate::{ + Error, GbuffersInputs, Interface, Program, Sampler, Target, Uniform, translate_composite, + translate_gbuffers, +}; diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs index c6435025d..e09abe9f3 100644 --- a/crates/horizon-shaders/src/translate/mod.rs +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -86,18 +86,74 @@ pub enum Target { Window, } +/// What a gbuffers-style program's vertex stage reads. The game writes it: it knows its vertex +/// formats, the translator only places it. +#[derive(Clone, Debug, Default)] +pub struct GbuffersInputs { + /// GLSL declarations: the vertex inputs and any bindings of set 1. + pub declarations: String, + /// GLSL statements that set `hz_Vertex`, `hz_Color`, `hz_MultiTexCoord0`, + /// `hz_MultiTexCoord1` and `hz_Normal` (the compatibility inputs), run first in `main`. + pub body: String, + /// The value of each attribute a pack may declare (`mc_Entity`, `vaPosition`...), as a GLSL + /// expression of the declared type's size; an attribute not listed reads zero. + pub attributes: Vec<(String, String)>, +} + /// Translates a composite-style program (`composite`, `deferred`, `final`...). pub fn translate_composite( vertex: &Preprocessed, fragment: &Preprocessed, target: Target, +) -> Result> { + translate(vertex, fragment, target, None) +} + +/// Translates a gbuffers-style program (`gbuffers_terrain`...) drawing the game's geometry +/// described by `inputs`, into `target` (the pack's buffers). +pub fn translate_gbuffers( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, + inputs: &GbuffersInputs, +) -> Result> { + translate(vertex, fragment, target, Some(inputs)) +} + +/// The matrices of the compatibility profile in a gbuffers-style program, from the frame's. +const GBUFFERS_MATRICES: &str = " hz_ModelViewMatrix = gbufferModelView;\n \ + hz_ProjectionMatrix = gbufferProjection;\n \ + hz_ModelViewProjectionMatrix = gbufferProjection * gbufferModelView;\n \ + hz_ModelViewMatrixInverse = gbufferModelViewInverse;\n \ + hz_ProjectionMatrixInverse = gbufferProjectionInverse;\n \ + hz_NormalMatrix = mat3(gbufferModelView);\n \ + mat4 hz_light = mat4(vec4(1.0 / 256.0, 0.0, 0.0, 0.0), vec4(0.0, 1.0 / 256.0, 0.0, 0.0), vec4(0.0, 0.0, 1.0, 0.0), vec4(1.0 / 32.0, 1.0 / 32.0, 0.0, 1.0));\n \ + hz_TextureMatrix = mat4[8](mat4(1.0), hz_light, hz_light, mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n"; + +fn translate( + vertex: &Preprocessed, + fragment: &Preprocessed, + target: Target, + gbuffers: Option<&GbuffersInputs>, ) -> Result> { let mut v = Stage::scan(vertex, ShaderStage::Vertex); let mut f = Stage::scan(fragment, ShaderStage::Fragment); + v.gbuffers = gbuffers.is_some(); + f.gbuffers = gbuffers.is_some(); let mut errors: Vec = v.errors.drain(..).chain(f.errors.drain(..)).collect(); // One uniform block and one set of samplers for both stages. let mut uniforms: BTreeMap = BTreeMap::new(); + if gbuffers.is_some() { + for name in [ + "gbufferModelView", + "gbufferModelViewInverse", + "gbufferProjection", + "gbufferProjectionInverse", + ] { + uniforms.insert(name.to_owned(), "mat4".to_owned()); + } + } let mut samplers: BTreeMap = BTreeMap::new(); for stage in [&v, &f] { for (name, ty, at) in &stage.uniforms { @@ -169,17 +225,37 @@ pub fn translate_composite( ) .unwrap(); } + if let (Some(inputs), ShaderStage::Vertex) = (gbuffers, stage.kind) { + h.push_str(&inputs.declarations); + h.push('\n'); + } h.push_str(&stage.prelude()); h }; let flip = if target == Target::Buffers { "-" } else { "" }; let fog = " hz_Fog = hz_FogParameters(hz_fog_color, hz_fog.x, hz_fog.y, hz_fog.z, hz_fog.w);\n"; - let mut vertex_main = String::from( - "\nvoid main() {\n vec2 hz_p = hz_quad[gl_VertexIndex];\n hz_Vertex = vec4(hz_p, 0.0, 1.0);\n \ - hz_MultiTexCoord0 = hz_Vertex;\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 0.0, 1.0);\n", - ); - for (name, value) in &v.attributes { + let mut vertex_main = String::from("\nvoid main() {\n"); + match gbuffers { + None => vertex_main.push_str( + " vec2 hz_p = hz_quad[gl_VertexIndex];\n hz_Vertex = vec4(hz_p, 0.0, 1.0);\n \ + hz_MultiTexCoord0 = hz_Vertex;\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 0.0, 1.0);\n", + ), + Some(inputs) => { + vertex_main.push_str(GBUFFERS_MATRICES); + vertex_main.push_str(&inputs.body); + } + } + for (name, value, ty) in &v.attributes { + let value = match gbuffers { + // Our quad's values for a composite. + None => value.clone(), + // The game's, or zero for anything it doesn't provide. + Some(inputs) => match inputs.attributes.iter().find(|a| &a.0 == name) { + Some((_, given)) => format!("{ty}({given})"), + None => format!("{ty}(0)"), + }, + }; writeln!(vertex_main, " {name} = {value};").unwrap(); } vertex_main.push_str(fog); @@ -192,6 +268,9 @@ pub fn translate_composite( ) .unwrap(); let mut fragment_main = String::from("\nvoid main() {\n"); + if gbuffers.is_some() { + fragment_main.push_str(GBUFFERS_MATRICES); + } fragment_main.push_str(fog); if f.frag_coord { fragment_main.push_str(match target { @@ -444,6 +523,66 @@ mod tests { } } + #[test] + fn gbuffers_programs_read_the_games_vertices() { + let vsh = "#version 120\nattribute vec4 mc_Entity;\nattribute vec2 mc_midTexCoord;\nvarying vec2 uv;\nvarying vec2 light;\n\ + varying vec4 color;\nvarying vec3 normal;\nvarying float id;\nvoid main() {\n \ + gl_Position = ftransform();\n uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy;\n \ + light = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;\n color = gl_Color;\n \ + normal = normalize(gl_NormalMatrix * gl_Normal);\n id = mc_Entity.x + mc_midTexCoord.x;\n}\n"; + let fsh = "#version 120\nuniform sampler2D texture;\nuniform sampler2D lightmap;\nuniform mat4 gbufferModelView;\n\ + varying vec2 uv;\nvarying vec2 light;\nvarying vec4 color;\nvarying vec3 normal;\nvarying float id;\n\ + void main() {\n vec4 c = texture2D(texture, uv) * texture2D(lightmap, light) * color;\n \ + /* DRAWBUFFERS:02 */\n gl_FragData[0] = c * gl_FragCoord.z;\n \ + gl_FragData[1] = vec4(normal * 0.5 + 0.5, id);\n}\n"; + let files: Vec<(String, &str)> = + [("gbuffers_terrain.vsh", vsh), ("gbuffers_terrain.fsh", fsh)] + .iter() + .map(|(p, t)| (format!("shaders/{p}"), *t)) + .collect(); + let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (p.as_str(), *t)).collect(); + let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap(); + let m = Macros::standard(); + let inputs = GbuffersInputs { + declarations: "layout(location = 0) in vec3 game_pos;\nlayout(location = 1) in uvec2 game_uv;".into(), + body: " hz_Vertex = vec4(game_pos, 1.0);\n hz_MultiTexCoord0 = vec4(vec2(game_uv) / 32768.0, 0.0, 1.0);\n \ + hz_MultiTexCoord1 = vec4(240.0, 240.0, 0.0, 1.0);\n hz_Color = vec4(1.0);\n hz_Normal = vec3(0.0, 1.0, 0.0);\n" + .into(), + attributes: vec![("mc_midTexCoord".into(), "vec2(game_uv) / 32768.0".into())], + }; + let p = translate_gbuffers( + &preprocess(&pack, "gbuffers_terrain.vsh", &m), + &preprocess(&pack, "gbuffers_terrain.fsh", &m), + Target::Buffers, + &inputs, + ) + .unwrap_or_else(|e| panic!("{e:?}")); + let names: Vec<&str> = p + .interface + .uniforms + .iter() + .map(|u| u.name.as_str()) + .collect(); + for name in [ + "gbufferModelView", + "gbufferProjection", + "gbufferModelViewInverse", + ] { + assert!(names.contains(&name), "{name} missing from {names:?}"); + } + assert_eq!(p.interface.outputs, vec![0, 1]); + let inputs: Vec<&str> = p.vertex.entry_points[0] + .function + .arguments + .iter() + .filter_map(|a| a.name.as_deref()) + .collect(); + assert!( + inputs.contains(&"game_pos") && inputs.contains(&"game_uv"), + "{inputs:?}" + ); + } + #[test] fn errors_point_at_the_pack_file_and_line() { let fsh = diff --git a/crates/horizon-shaders/src/translate/rewrite.rs b/crates/horizon-shaders/src/translate/rewrite.rs index 312575139..504da5a80 100644 --- a/crates/horizon-shaders/src/translate/rewrite.rs +++ b/crates/horizon-shaders/src/translate/rewrite.rs @@ -18,12 +18,22 @@ impl Stage<'_> { vec3 hz_modf(vec3 x, out vec3 i) { i = trunc(x); return x - i; }\n\ vec4 hz_modf(vec4 x, out vec4 i) { i = trunc(x); return x - i; }\n\ struct hz_FogParameters { vec4 color; float density; float start; float end; float scale; };\n\ - hz_FogParameters hz_Fog;\n\ - const mat4 hz_ModelViewMatrix = mat4(1.0); const mat4 hz_ProjectionMatrix = mat4(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, -1.0, -1.0, 0.0, 1.0);\n\ + hz_FogParameters hz_Fog;\n", + ); + if self.gbuffers { + // Set by our `main` from the frame's matrices (gbuffers.rs). + p.push_str( + "mat4 hz_ModelViewMatrix; mat4 hz_ProjectionMatrix; mat4 hz_ModelViewProjectionMatrix;\n\ + mat4 hz_ModelViewMatrixInverse; mat4 hz_ProjectionMatrixInverse; mat3 hz_NormalMatrix; mat4 hz_TextureMatrix[8];\n", + ); + } else { + p.push_str( + "const mat4 hz_ModelViewMatrix = mat4(1.0); const mat4 hz_ProjectionMatrix = mat4(2.0, 0.0, 0.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, -1.0, -1.0, 0.0, 1.0);\n\ const mat4 hz_ModelViewProjectionMatrix = hz_ProjectionMatrix; const mat4 hz_ModelViewMatrixInverse = mat4(1.0); const mat3 hz_NormalMatrix = mat3(1.0);\n\ const mat4 hz_ProjectionMatrixInverse = mat4(0.5, 0.0, 0.0, 0.0, 0.0, 0.5, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.5, 0.5, 0.0, 1.0);\n\ const mat4 hz_TextureMatrix[8] = mat4[8](mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n", - ); + ); + } if self.kind == ShaderStage::Vertex { p.push_str( "const vec2 hz_quad[6] = vec2[6](vec2(0.0, 0.0), vec2(1.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 0.0), vec2(1.0, 1.0), vec2(0.0, 1.0));\n\ diff --git a/crates/horizon-shaders/src/translate/scan.rs b/crates/horizon-shaders/src/translate/scan.rs index 1fd91dd94..6789893f4 100644 --- a/crates/horizon-shaders/src/translate/scan.rs +++ b/crates/horizon-shaders/src/translate/scan.rs @@ -29,7 +29,10 @@ pub(super) struct Stage<'p> { pub(super) ins: Vec, pub(super) outs: Vec, /// Vertex inputs, now private globals, and the value our `main` gives each. - pub(super) attributes: Vec<(String, String)>, + /// Vertex inputs: name, our composite value, type. + pub(super) attributes: Vec<(String, String, String)>, + /// A gbuffers-style program: the matrices are the frame's, not the quad's. + pub(super) gbuffers: bool, /// The pack's fragment outputs, declared again with their locations. pub(super) frag_outs: Vec<(u32, String)>, /// The pack's functions that take samplers: which parameters are samplers. @@ -119,6 +122,7 @@ impl<'p> Stage<'p> { sampler_functions: HashMap::new(), outputs: BTreeSet::new(), frag_coord: false, + gbuffers: false, replaced: HashMap::new(), inits: Vec::new(), errors: Vec::new(), @@ -315,7 +319,7 @@ impl<'p> Stage<'p> { "uniform" => self.uniforms.push((name, format!("{ty}{array}"), ty_at)), _ if input && !fragment => { let value = attribute_value(&name, &ty); - self.attributes.push((name, value)); + self.attributes.push((name, value, ty.clone())); self.removed.pop(); // A private global of ours now: `in`/`attribute` and qualifiers go. for &w in &words[..k] { From f31c18aa6c06b206440d1dd1fe5784c437f304be Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:02:25 +0200 Subject: [PATCH 18/50] Shader packs, clean room 12: the block atlas packs sample Packs sample blocks from one 2D atlas; Horizon keeps them in sprite arrays. While a pack is loaded, every layer and its mip levels are copied into a grid, and the animator reports the layers it rewrote so only those are copied again. The sprite arrays gain COPY_SRC. The gbuffers pass binds it in the next slice. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/gfx.rs | 32 ++++ crates/horizon-client/src/gfx/animate.rs | 10 + crates/horizon-client/src/gfx/pack_atlas.rs | 197 ++++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 crates/horizon-client/src/gfx/pack_atlas.rs diff --git a/crates/horizon-client/src/gfx.rs b/crates/horizon-client/src/gfx.rs index 08fb9061e..9f05adb65 100644 --- a/crates/horizon-client/src/gfx.rs +++ b/crates/horizon-client/src/gfx.rs @@ -68,6 +68,7 @@ mod flat; pub mod lightning; mod lod; mod occlusion; +mod pack_atlas; mod pack_post; pub mod pack_uniforms; mod page_runs; @@ -468,6 +469,8 @@ pub struct Renderer { world_post: world_post::Post, /// The shader pack's post-processing, and the dimension (legacy id) it was loaded for. pack_post: Option<(i32, Option)>, + /// The block sprites as one atlas, while a shader pack is loaded. + pack_atlas: Option, pub hud: HudRenderer, /// Vanilla's menu background blur, made on first use. menu_blur: Option, @@ -1316,6 +1319,8 @@ impl Renderer { format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST + // Shader packs' atlas copies them (pack_atlas.rs). + | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::STORAGE_BINDING, view_formats: &[], }, @@ -2313,6 +2318,7 @@ impl Renderer { occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16), world_post, pack_post: None, + pack_atlas: None, hud, menu_blur: None, info, @@ -2746,6 +2752,25 @@ impl Renderer { self.config.format.remove_srgb_suffix(), ) }); + if post.is_none() { + self.pack_atlas = None; + } else if self.pack_atlas.is_none() { + let mut encoder = self.device.create_command_encoder(&Default::default()); + let layers = self + .sprite_textures + .iter() + .map(|t| t.depth_or_array_layers()) + .sum(); + self.pack_atlas = pack_atlas::PackAtlas::new( + &self.device, + &mut encoder, + &self.sprite_textures, + sprite_split(), + layers, + self.sprite_textures[0].width(), + ); + self.queue.submit([encoder.finish()]); + } let report = post.as_ref().map(|p| { let programs = p.programs().into_iter().map(str::to_owned).collect(); (programs, p.notes.clone()) @@ -2873,6 +2898,13 @@ impl Renderer { } fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) { self.animations.animate(encoder, &mut self.uploads, tick); + if let Some(atlas) = &self.pack_atlas { + atlas.copy( + encoder, + &self.sprite_textures, + self.animations.changed().iter().copied(), + ); + } } pub fn render(&mut self, frame: &Frame) -> Result { let rendered = self.draw_frame(frame); diff --git a/crates/horizon-client/src/gfx/animate.rs b/crates/horizon-client/src/gfx/animate.rs index f4528902b..b7c44429b 100644 --- a/crates/horizon-client/src/gfx/animate.rs +++ b/crates/horizon-client/src/gfx/animate.rs @@ -51,6 +51,8 @@ pub(super) struct SpriteAnimator { pipeline: wgpu::ComputePipeline, /// Edge of the arrays' layers. size: u32, + /// The layers the last `animate` rewrote, counted over all arrays. + changed: Vec, } impl SpriteAnimator { @@ -205,9 +207,15 @@ impl SpriteAnimator { jobs, pipeline, size, + changed: Vec::new(), } } + /// The layers the last `animate` rewrote (for the shader pack atlas). + pub fn changed(&self) -> &[u32] { + &self.changed + } + /// Show every sprite's frame at `tick`, rewriting only the sprites whose /// frame or mix changed (`SpriteContents.AnimationState.tick`). pub fn animate( @@ -245,12 +253,14 @@ impl SpriteAnimator { /// its layer shows; the number of jobs. fn collect(&mut self, tick: u64) -> usize { let mut count = 0; + self.changed.clear(); for sprite in &mut self.sprites { let blend = sprite.animation.blend(tick); if sprite.shown == Some(blend) { continue; } sprite.shown = Some(blend); + self.changed.push(sprite.animation.layer); let job = [ sprite.layer, sprite.frames[blend.frame], diff --git a/crates/horizon-client/src/gfx/pack_atlas.rs b/crates/horizon-client/src/gfx/pack_atlas.rs new file mode 100644 index 000000000..c90fa6ac1 --- /dev/null +++ b/crates/horizon-client/src/gfx/pack_atlas.rs @@ -0,0 +1,197 @@ +//! The block atlas of shader packs (spec 5.1, `atlasSize` in 5.2): packs sample one 2D atlas +//! with atlas coordinates, Horizon keeps block sprites in texture arrays. With a pack, every +//! layer is copied into a grid, row by row from the top left, and an animated layer again +//! whenever its frame changes. + +#[expect(dead_code, reason = "the gbuffers pass binds it (next slice)")] +pub(super) struct PackAtlas { + texture: wgpu::Texture, + pub view: wgpu::TextureView, + /// Sprites per row. + pub columns: u32, + /// A sprite's edge in pixels, at the first level. + pub sprite: u32, + /// Layers per sprite array. + split: u32, +} + +impl PackAtlas { + /// Copies `layers` layers of `arrays` (`split` per array, `sprite` pixels wide, `levels` + /// mip levels); `None` when the grid is larger than the device allows. + pub fn new( + device: &wgpu::Device, + encoder: &mut wgpu::CommandEncoder, + arrays: &[wgpu::Texture], + split: u32, + layers: u32, + sprite: u32, + ) -> Option { + let layers = layers.max(1); + let columns = (f64::from(layers).sqrt().ceil() as u32).max(1); + let rows = layers.div_ceil(columns); + let max = device.limits().max_texture_dimension_2d; + if columns * sprite > max || rows * sprite > max { + return None; + } + // Levels past a sprite's single pixel would mix neighbours: stop there. + let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("Shader pack block atlas"), + size: wgpu::Extent3d { + width: columns * sprite, + height: rows * sprite, + depth_or_array_layers: 1, + }, + mip_level_count: levels, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_DST + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let atlas = Self { + texture, + view, + columns, + sprite, + split, + }; + atlas.copy(encoder, arrays, 0..layers); + Some(atlas) + } + + /// The atlas's size in pixels (`atlasSize`). + #[cfg_attr( + not(test), + expect(dead_code, reason = "the gbuffers pass reads it (next slice)") + )] + pub fn size(&self) -> [u32; 2] { + [self.texture.width(), self.texture.height()] + } + + /// Copies `layers` from the sprite arrays again, every level. + pub fn copy( + &self, + encoder: &mut wgpu::CommandEncoder, + arrays: &[wgpu::Texture], + layers: impl IntoIterator, + ) { + for layer in layers { + let Some(array) = arrays.get((layer / self.split) as usize) else { + continue; + }; + let (column, row) = (layer % self.columns, layer / self.columns); + for level in 0..self.texture.mip_level_count() { + let edge = self.sprite >> level; + encoder.copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { + texture: array, + mip_level: level, + origin: wgpu::Origin3d { + x: 0, + y: 0, + z: layer % self.split, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: level, + origin: wgpu::Origin3d { + x: column * edge, + y: row * edge, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + wgpu::Extent3d { + width: edge, + height: edge, + depth_or_array_layers: 1, + }, + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Two arrays of 2x2 sprites, one layer split; each layer a colour of its own. + #[test] + fn layers_land_in_their_cells() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else { + return; + }; + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let (split, layers, sprite) = (3u32, 5u32, 2u32); + let arrays: Vec = (0..2) + .map(|a| { + let count = (layers - a * split).min(split); + let pixels: Vec = (0..count) + .flat_map(|l| [(a * split + l) as u8 * 40, 0, 0, 255].repeat(4)) + .collect(); + wgpu::util::DeviceExt::create_texture_with_data( + &device, + &queue, + &wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: sprite, + height: sprite, + depth_or_array_layers: count, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::LayerMajor, + &pixels, + ) + }) + .collect(); + let mut encoder = device.create_command_encoder(&Default::default()); + let atlas = PackAtlas::new(&device, &mut encoder, &arrays, split, layers, sprite).unwrap(); + assert_eq!((atlas.columns, atlas.size()), (3, [6, 4])); + let read = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 256 * 4, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + atlas.texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &read, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + wgpu::Extent3d { + width: 6, + height: 4, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + read.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let data = read.slice(..).get_mapped_range().unwrap(); + let red = |x: usize, y: usize| data[y * 256 + x * 4]; + // Layer 4 (second array, its second layer) is in row 1, column 1. + assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]); + assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]); + } +} From 8698b4ad0c7f3d0ec3f21f45efe14a9f750d84bb Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:20:16 +0200 Subject: [PATCH 19/50] Shader packs, clean room 13: loading the terrain program load_post takes the game's terrain vertex inputs and, given them, loads the first program of gbuffers_terrain's fallback chain that translates (gbuffers_terrain, textured_lit, textured, basic). Program loading is shared by the post-processing and gbuffers programs. The translator no longer rewrites a variable, parameter or member that has the name of a sampler (`float specular` beside `uniform sampler2D specular`), which kept Complementary's gbuffers programs from compiling. Co-Authored-By: Claude Opus 5.5 --- .../horizon-client/src/game/shader_packs.rs | 2 +- crates/horizon-shaders/src/post.rs | 185 ++++++++++++------ crates/horizon-shaders/src/translate/mod.rs | 12 ++ .../horizon-shaders/src/translate/rewrite.rs | 19 ++ crates/horizon-shaders/tests/real_packs.rs | 19 +- 5 files changed, 173 insertions(+), 64 deletions(-) diff --git a/crates/horizon-client/src/game/shader_packs.rs b/crates/horizon-client/src/game/shader_packs.rs index 721a1cb58..04af297b6 100644 --- a/crates/horizon-client/src/game/shader_packs.rs +++ b/crates/horizon-client/src/game/shader_packs.rs @@ -42,7 +42,7 @@ impl Game<'_> { ); } let folder = dimension_folder(&pack, dimension); - let post = load_post(&pack, &folder, &properties, ¯os); + let post = load_post(&pack, &folder, &properties, ¯os, None); if let Some((programs, notes)) = renderer.set_shader_pack(dimension, Some(post)) { for note in notes { diff --git a/crates/horizon-shaders/src/post.rs b/crates/horizon-shaders/src/post.rs index f0427303e..cdf7fae80 100644 --- a/crates/horizon-shaders/src/post.rs +++ b/crates/horizon-shaders/src/post.rs @@ -8,10 +8,11 @@ //! dimension when it exists; every stage file is read from it. //! - OQ-29: a program with a geometry stage is reported and treated as missing. //! - A program that fails to translate is reported and treated as missing (2.5). +//! - Terrain draws with the first program of its fallback chain that loads (2.3). use crate::config::{self, Config}; -use crate::{Macros, Pack, Preprocessed, Program, Properties, Target, eval_condition}; -use crate::{preprocess, translate_composite}; +use crate::{GbuffersInputs, Macros, Pack, Preprocessed, Program, Properties, Target}; +use crate::{eval_condition, preprocess, translate_composite, translate_gbuffers}; /// Which group of 2.1 a program belongs to. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -20,8 +21,18 @@ pub enum Group { Deferred, Composite, Final, + /// A gbuffers program: the game draws its geometry with it. + Gbuffers, } +/// `gbuffers_terrain` and its fallbacks (2.3). +const TERRAIN: [&str; 4] = [ + "gbuffers_terrain", + "gbuffers_textured_lit", + "gbuffers_textured", + "gbuffers_basic", +]; + const GROUPS: [(Group, &str); 4] = [ (Group::Prepare, "prepare"), (Group::Deferred, "deferred"), @@ -47,6 +58,8 @@ pub struct Post { /// The flips of the virtual `deferred_pre` and `composite_pre` (6.1.6). pub deferred_pre: Vec<(u32, bool)>, pub composite_pre: Vec<(u32, bool)>, + /// What draws solid and cutout terrain, when the game asked for it and one loads. + pub terrain: Option, pub config: Config, /// Programs skipped and why, as `program: message` or `file:line: message`. pub notes: Vec, @@ -63,19 +76,30 @@ pub fn dimension_folder(pack: &Pack, id: i32) -> String { } } -/// Loads the post-processing programs of `folder`. -pub fn load_post(pack: &Pack, folder: &str, properties: &Properties, macros: &Macros) -> Post { +/// Loads the post-processing programs of `folder` and, given the game's terrain vertices, the +/// program terrain draws with. +pub fn load_post( + pack: &Pack, + folder: &str, + properties: &Properties, + macros: &Macros, + terrain: Option<&GbuffersInputs>, +) -> Post { let mut post = Post { folder: folder.to_owned(), programs: Vec::new(), deferred_pre: flips(properties, "deferred_pre"), composite_pre: flips(properties, "composite_pre"), + terrain: None, config: Config::default(), notes: Vec::new(), }; - let path = |name: &str, ext: &str| match folder { - "" => format!("{name}.{ext}"), - f => format!("{f}/{name}.{ext}"), + let mut loader = Loader { + pack, + folder, + properties, + macros, + post: &mut post, }; for (group, base) in GROUPS { let names: Vec = if group == Group::Final { @@ -85,63 +109,98 @@ pub fn load_post(pack: &Pack, folder: &str, properties: &Properties, macros: &Ma .chain((1..=99).map(|n| format!("{base}{n}"))) .collect() }; + let target = match group { + Group::Final => Target::Window, + _ => Target::Buffers, + }; for name in names { - let (vsh, fsh) = (path(&name, "vsh"), path(&name, "fsh")); - if !pack.exists(&fsh) || !pack.exists(&vsh) { - continue; + if let Some(p) = loader.load(name, group, |v, f| translate_composite(v, f, target)) { + loader.post.programs.push(p); } - if !enabled(properties, folder, &name, macros) { - continue; - } - if pack.exists(&path(&name, "gsh")) { - post.notes.push(format!( - "{name}: uses a geometry stage (not supported); skipped" - )); - continue; - } - let vertex = preprocess(pack, &vsh, macros); - let fragment = preprocess(pack, &fsh, macros); - let problems: Vec = [&vertex, &fragment] - .iter() - .flat_map(|pp| diagnostics(pp)) - .collect(); - if !problems.is_empty() { - post.notes.extend(problems); - post.notes.push(format!("{name}: skipped")); - continue; - } - let target = match group { - Group::Final => Target::Window, - _ => Target::Buffers, - }; - let program = match translate_composite(&vertex, &fragment, target) { - Ok(p) => p, - Err(errors) => { - post.notes.extend( - errors - .iter() - .map(|e| format!("{}:{}: {}", e.file, e.line, e.message)), - ); - post.notes.push(format!("{name}: skipped")); - continue; - } - }; - post.config.read(&vertex); - post.config.read(&fragment); - let buffers = config::draw_buffers(&fragment).unwrap_or_else(|| (0..8).collect()); - post.programs.push(PostProgram { - flips: flips(properties, &name), - name, - group, - program, - buffers, - }); } } + if let Some(inputs) = terrain { + loader.post.terrain = TERRAIN.iter().find_map(|name| { + loader.load(name.to_string(), Group::Gbuffers, |v, f| { + translate_gbuffers(v, f, Target::Buffers, inputs) + }) + }); + } post.config = std::mem::take(&mut post.config).finish(); post } +struct Loader<'a> { + pack: &'a Pack, + folder: &'a str, + properties: &'a Properties, + macros: &'a Macros, + post: &'a mut Post, +} + +impl Loader<'_> { + /// The program `name` translated by `translate`, or `None` when it is missing, disabled or + /// fails (with a note). + fn load( + &mut self, + name: String, + group: Group, + translate: impl FnOnce(&Preprocessed, &Preprocessed) -> Result>, + ) -> Option { + let path = |ext: &str| match self.folder { + "" => format!("{name}.{ext}"), + f => format!("{f}/{name}.{ext}"), + }; + let (vsh, fsh) = (path("vsh"), path("fsh")); + let notes = &mut self.post.notes; + if !self.pack.exists(&fsh) || !self.pack.exists(&vsh) { + return None; + } + if !enabled(self.properties, self.folder, &name, self.macros) { + return None; + } + if self.pack.exists(&path("gsh")) { + notes.push(format!( + "{name}: uses a geometry stage (not supported); skipped" + )); + return None; + } + let vertex = preprocess(self.pack, &vsh, self.macros); + let fragment = preprocess(self.pack, &fsh, self.macros); + let problems: Vec = [&vertex, &fragment] + .iter() + .flat_map(|pp| diagnostics(pp)) + .collect(); + if !problems.is_empty() { + notes.extend(problems); + notes.push(format!("{name}: skipped")); + return None; + } + let program = match translate(&vertex, &fragment) { + Ok(p) => p, + Err(errors) => { + notes.extend( + errors + .iter() + .map(|e| format!("{}:{}: {}", e.file, e.line, e.message)), + ); + notes.push(format!("{name}: skipped")); + return None; + } + }; + self.post.config.read(&vertex); + self.post.config.read(&fragment); + let buffers = config::draw_buffers(&fragment).unwrap_or_else(|| (0..8).collect()); + Some(PostProgram { + flips: flips(self.properties, &name), + name, + group, + program, + buffers, + }) + } +} + fn diagnostics(pp: &Preprocessed) -> Vec { pp.diagnostics .iter() @@ -248,7 +307,7 @@ mod tests { ); assert_eq!(dimension_folder(&pack, 0), ""); assert_eq!(dimension_folder(&pack, -1), "world-1"); - let post = load_post(&pack, "", &properties, &Macros::standard()); + let post = load_post(&pack, "", &properties, &Macros::standard(), None); let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["deferred", "composite", "composite2", "final"]); let p = &post.programs; @@ -268,10 +327,10 @@ mod tests { let mut macros = Macros::standard(); macros.define("HQ", ""); - let post = load_post(&pack, "world-1", &properties, ¯os); + let post = load_post(&pack, "world-1", &properties, ¯os, None); let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["final"]); - let post = load_post(&pack, "", &properties, ¯os); + let post = load_post(&pack, "", &properties, ¯os, None); assert!(post.programs.iter().any(|p| p.name == "composite3")); } @@ -281,7 +340,13 @@ mod tests { let pack = Pack::open(&dir).unwrap(); let macros = Macros::standard(); let properties = Properties::parse(&pack.read_text("shaders.properties").unwrap(), ¯os); - let post = load_post(&pack, &dimension_folder(&pack, 0), &properties, ¯os); + let post = load_post( + &pack, + &dimension_folder(&pack, 0), + &properties, + ¯os, + None, + ); assert!(post.notes.is_empty(), "{:?}", post.notes); let names: Vec<&str> = post.programs.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["composite", "composite1", "final"]); diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs index e09abe9f3..32eecb3cc 100644 --- a/crates/horizon-shaders/src/translate/mod.rs +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -583,6 +583,18 @@ mod tests { ); } + #[test] + fn locals_may_hide_a_sampler() { + let fsh = "#version 120\nuniform sampler2D specular;\nstruct M { float specular; };\n\ + float f(float x) { float specular = x * 2.0; return specular; }\n\ + void main() { M m = M(0.5); gl_FragColor = texture2D(specular, vec2(0.5)) * f(m.specular); }\n"; + program( + &[("composite.vsh", VSH_120), ("composite.fsh", fsh)], + Target::Buffers, + ) + .unwrap_or_else(|e| panic!("{e:?}")); + } + #[test] fn errors_point_at_the_pack_file_and_line() { let fsh = diff --git a/crates/horizon-shaders/src/translate/rewrite.rs b/crates/horizon-shaders/src/translate/rewrite.rs index 504da5a80..85cb0fcd9 100644 --- a/crates/horizon-shaders/src/translate/rewrite.rs +++ b/crates/horizon-shaders/src/translate/rewrite.rs @@ -73,6 +73,8 @@ impl Stage<'_> { let mut calls: Vec<(&str, &str, usize)> = Vec::new(); // Sampler parameters of the function being written: name → type. let mut params: HashMap = HashMap::new(); + // Names of samplers a local variable or parameter of the function hides. + let mut hidden: Vec<&str> = Vec::new(); let mut depth = 0; let mut skip_until = 0; let next = |i: usize| (i + 1..self.tokens.len()).find(|&j| significant(self.tokens[j])); @@ -99,6 +101,7 @@ impl Stage<'_> { depth -= 1; if depth == 0 { params.clear(); + hidden.clear(); } } "(" => { @@ -147,6 +150,22 @@ impl Stage<'_> { continue; } } + // `float specular`: a variable of the same name as a sampler, from here to the + // function's end; `.specular` a member. + if sampler_ty.contains_key(t) && !params.contains_key(t) { + let before = prev(i).map(|j| self.tokens[j]); + if before == Some(".") { + out.push_str(t); + continue; + } + if before.is_some_and(|b| is_ident(b) && !matches!(b, "return" | "case" | "else")) { + hidden.push(t); + } + } + if hidden.contains(&t) { + out.push_str(t); + continue; + } let sampler = params .get(t) .map(String::as_str) diff --git a/crates/horizon-shaders/tests/real_packs.rs b/crates/horizon-shaders/tests/real_packs.rs index 6885b40f7..d0a28d71d 100644 --- a/crates/horizon-shaders/tests/real_packs.rs +++ b/crates/horizon-shaders/tests/real_packs.rs @@ -162,14 +162,27 @@ fn real_packs_read() { for id in [0, -1, 1] { let start = std::time::Instant::now(); let folder = dimension_folder(&pack, id); - let post = load_post(&pack, &folder, &shaders, &Macros::standard()); + // Vertex inputs like the game's (gfx/pack_post.rs writes the real ones). + let terrain = horizon_shaders::GbuffersInputs { + declarations: "layout(location = 0) in vec3 hz_v_pos;".into(), + body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n hz_Color = vec4(1.0);\n".into(), + attributes: vec![("mc_Entity".into(), "vec4(-1.0)".into())], + }; + let post = load_post( + &pack, + &folder, + &shaders, + &Macros::standard(), + Some(&terrain), + ); println!( - " dimension {id} (folder `{folder}`): {} post programs, {} notes, {:.0} ms", + " dimension {id} (folder `{folder}`): {} post programs, terrain {:?}, {} notes, {:.0} ms", post.programs.len(), + post.terrain.as_ref().map(|t| &t.name), post.notes.len() + post.config.notes.len(), start.elapsed().as_secs_f64() * 1000.0 ); - for n in post.notes.iter().chain(&post.config.notes).take(5) { + for n in post.notes.iter().chain(&post.config.notes).take(12) { println!(" {n}"); } } From f500be1b5ed8933af93610c90780fe97d25b8701 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 02:28:27 +0300 Subject: [PATCH 20/50] Title: the main menu labels follow the game language (#1672) The screen built them from literals, so the buttons stayed English in every language while the world select screen next to them was translated. Each has a vanilla key the prepared resources already carry; Friends is Horizon's own and reads horizon.menu.friends with the English text as fallback. --- crates/horizon-client/src/title.rs | 81 ++++++++++++++++++++++-------- 1 file changed, 61 insertions(+), 20 deletions(-) diff --git a/crates/horizon-client/src/title.rs b/crates/horizon-client/src/title.rs index f77ac93eb..19316541b 100644 --- a/crates/horizon-client/src/title.rs +++ b/crates/horizon-client/src/title.rs @@ -581,23 +581,57 @@ impl Title { let cx = (w / 2.0).floor(); let y = (h / 4.0).floor() + 48.0; let mut buttons = vec![ - b(cx - 100.0, y, 200.0, "Singleplayer", true, 1), - b(cx - 100.0, y + 24.0, 200.0, "Multiplayer", true, 4), - b(cx - 100.0, y + 48.0, 200.0, "Minecraft Realms", false, 0), - b(cx - 100.0, y + 96.0, 98.0, "Options...", true, 3), - b(cx + 2.0, y + 96.0, 98.0, "Quit Game", true, 2), - ]; - for (i, (label, enabled, id, icon, size)) in [ - // Horizon's Join a Friend (an invite code) behind - // vanilla's Friends button. - ("Friends", true, 5, "friends/friends", 15.0), - ("Language", true, 6, "icon/language", 15.0), - ( - "Accessibility Settings", + b( + cx - 100.0, + y, + 200.0, + &lang::get_or("menu.singleplayer", "Singleplayer"), true, + 1, + ), + b( + cx - 100.0, + y + 24.0, + 200.0, + &lang::get_or("menu.multiplayer", "Multiplayer"), + true, + 4, + ), + b( + cx - 100.0, + y + 48.0, + 200.0, + &lang::get_or("menu.online", "Minecraft Realms"), + false, + 0, + ), + b( + cx - 100.0, + y + 96.0, + 98.0, + &lang::get_or("menu.options", "Options..."), + true, + 3, + ), + b( + cx + 2.0, + y + 96.0, + 98.0, + &lang::get_or("menu.quit", "Quit Game"), + true, + 2, + ), + ]; + // Horizon's Join a Friend (an invite code) sits behind vanilla's + // Friends button; the other two are vanilla tooltips. + for (i, (key, plain, id, icon)) in [ + ("horizon.menu.friends", "Friends", 5, "friends/friends"), + ("options.language", "Language", 6, "icon/language"), + ( + "options.accessibility", + "Accessibility Settings", 7, "icon/accessibility", - 15.0, ), ] .into_iter() @@ -607,11 +641,11 @@ impl Title { cx - 34.0 + i as f32 * 24.0, y + 72.0, 20.0, - label, - enabled, + &lang::get_or(key, plain), + true, id, ); - button.icon = Some((icon, size)); + button.icon = Some((icon, 15.0)); buttons.push(button); } buttons @@ -621,7 +655,7 @@ impl Title { (w / 2.0).floor() - 154.0, h - 52.0, 150.0, - "Play Selected World", + &lang::get_or("selectWorld.select", "Play Selected World"), selected.is_some(), 10, ), @@ -629,7 +663,7 @@ impl Title { (w / 2.0).floor() + 4.0, h - 52.0, 150.0, - "Create New World", + &lang::get_or("selectWorld.create", "Create New World"), true, 11, ), @@ -657,7 +691,14 @@ impl Title { selected.is_some(), 15, ), - b((w / 2.0).floor() + 83.0, h - 28.0, 71.0, "Back", true, 12), + b( + (w / 2.0).floor() + 83.0, + h - 28.0, + 71.0, + &lang::get_or("gui.back", "Back"), + true, + 12, + ), ], Mode::Create { creative, options, .. From ba09b086a67573debb21c124fe41df35e70897e8 Mon Sep 17 00:00:00 2001 From: socksprox Date: Sun, 25 Oct 2026 01:49:31 +0200 Subject: [PATCH 21/50] Multiplayer: drop the player name from the join loading line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-world overlay said "Joined as {name}; loading terrain..." after Event::Joined. Vanilla's LevelLoadingScreen only ever draws the multiplayer.downloadingTerrain text there — use that translation, no name. Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- crates/horizon-client/src/game/remote.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 45ba4bebe..4aa431810 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -829,7 +829,13 @@ impl Game<'_> { r.joined = true; r.profile = uuid; r.profile_name = name.clone(); - r.status = format!("Joined as {name}; loading terrain..."); + // `multiplayer.downloadingTerrain`, as + // LevelLoadingScreen shows it; no player name. + r.status = horizon_client::text::Text::translate( + "multiplayer.downloadingTerrain", + &[], + ) + .plain(); } } Event::Brand(brand) => { From e30166f23bad89819e505fa1b2abd865cdafd63b Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 02:51:11 +0300 Subject: [PATCH 22/50] Options: Horizon's own captions read a bundled language namespace (#1672) The settings screen's own options (controller, vibration, dead zones and the rest) are not vanilla options and have no vanilla keys, so their captions were English literals. They now read horizon.options.* from assets/horizon/lang, which the client bundles as one more language source placed first, so a pack or the prepared root can still override it. English and Russian ship here. --- .../assets/horizon/lang/en_us.json | 16 +++++++++ .../assets/horizon/lang/ru_ru.json | 16 +++++++++ crates/horizon-client/src/lang.rs | 33 +++++++++++++++++++ crates/horizon-client/src/options_ui.rs | 29 ++++++++-------- 4 files changed, 80 insertions(+), 14 deletions(-) create mode 100644 crates/horizon-client/assets/horizon/lang/en_us.json create mode 100644 crates/horizon-client/assets/horizon/lang/ru_ru.json diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json new file mode 100644 index 000000000..056162553 --- /dev/null +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -0,0 +1,16 @@ +{ + "horizon.menu.friends": "Friends", + "horizon.options.controller": "Controller", + "horizon.options.vibration": "Vibration", + "horizon.options.adaptive_triggers": "Adaptive Triggers", + "horizon.options.lightbar": "Lightbar", + "horizon.options.look_sensitivity": "Look Sensitivity", + "horizon.options.invert_x": "Invert Look X", + "horizon.options.invert_y": "Invert Look Y", + "horizon.options.sneak_button": "Sneak Button", + "horizon.options.move_deadzone": "Move Deadzone", + "horizon.options.look_deadzone": "Look Deadzone", + "horizon.options.button_glyphs": "Button Glyphs", + "horizon.options.menu_navigation": "Menu Navigation", + "horizon.options.on_screen_keyboard": "On-Screen Keyboard" +} diff --git a/crates/horizon-client/assets/horizon/lang/ru_ru.json b/crates/horizon-client/assets/horizon/lang/ru_ru.json new file mode 100644 index 000000000..1152f3669 --- /dev/null +++ b/crates/horizon-client/assets/horizon/lang/ru_ru.json @@ -0,0 +1,16 @@ +{ + "horizon.menu.friends": "Друзья", + "horizon.options.controller": "Геймпад", + "horizon.options.vibration": "Вибрация", + "horizon.options.adaptive_triggers": "Адаптивные триггеры", + "horizon.options.lightbar": "Подсветка", + "horizon.options.look_sensitivity": "Чувствительность обзора", + "horizon.options.invert_x": "Инверсия обзора по X", + "horizon.options.invert_y": "Инверсия обзора по Y", + "horizon.options.sneak_button": "Кнопка приседания", + "horizon.options.move_deadzone": "Мёртвая зона движения", + "horizon.options.look_deadzone": "Мёртвая зона обзора", + "horizon.options.button_glyphs": "Символы кнопок", + "horizon.options.menu_navigation": "Навигация по меню", + "horizon.options.on_screen_keyboard": "Экранная клавиатура" +} diff --git a/crates/horizon-client/src/lang.rs b/crates/horizon-client/src/lang.rs index 15f47285a..f761e1199 100644 --- a/crates/horizon-client/src/lang.rs +++ b/crates/horizon-client/src/lang.rs @@ -29,6 +29,22 @@ pub const DEFAULT: &str = "en_us"; static CURRENT: RwLock>> = RwLock::new(None); +/// Horizon's own strings, compiled in so they need no prepared resources. +struct Bundled; +impl ResourceSource for Bundled { + fn read(&self, path: &str) -> Option> { + let body = match path { + "assets/horizon/lang/en_us.json" => include_str!("../assets/horizon/lang/en_us.json"), + "assets/horizon/lang/ru_ru.json" => include_str!("../assets/horizon/lang/ru_ru.json"), + _ => return None, + }; + Some(body.as_bytes().to_vec()) + } + fn namespaces(&self) -> Vec { + vec!["horizon".into()] + } +} + /// A source of resource files: the prepared root plus packs, highest priority last. pub trait ResourceSource { fn read(&self, path: &str) -> Option>; @@ -86,6 +102,12 @@ impl Language { if code != DEFAULT { codes.push(code); } + // Horizon's own strings first, so a pack or the prepared root can still + // override them. Vanilla has no key for "Controller vibration". + let bundled = Bundled; + let mut all: Vec<&dyn ResourceSource> = vec![&bundled]; + all.extend_from_slice(sources); + let sources = all.as_slice(); let namespaces: std::collections::BTreeSet = sources.iter().flat_map(|s| s.namespaces()).collect(); for c in codes { @@ -204,6 +226,17 @@ mod tests { self.0.get(path).cloned() } } + #[test] + fn bundled_horizon_strings_follow_the_language() { + // No prepared resources at all: the namespace is compiled in. + let empty = Mem(HashMap::new()); + let en = Language::load(&[&empty], "en_us"); + assert_eq!(en.get("horizon.options.controller"), Some("Controller")); + let ru = Language::load(&[&empty], "ru_ru"); + assert_eq!(ru.get("horizon.options.controller"), Some("Геймпад")); + assert_eq!(ru.get("horizon.menu.friends"), Some("Друзья")); + } + #[test] fn layers_selected_language_over_english() { let base = Mem(HashMap::from([ diff --git a/crates/horizon-client/src/options_ui.rs b/crates/horizon-client/src/options_ui.rs index 99a83072f..d344c2df8 100644 --- a/crates/horizon-client/src/options_ui.rs +++ b/crates/horizon-client/src/options_ui.rs @@ -267,20 +267,21 @@ impl Opt { Opt::HighContrastBlockOutlines => "options.accessibility.high_contrast_block_outline", Opt::ForceUnicode => "options.forceUnicodeFont", Opt::JapaneseGlyphs => "options.japaneseGlyphVariants", - // Horizon's own options: English captions (no vanilla keys). - Opt::PadEnabled => "Controller", - Opt::PadVibration => "Vibration", - Opt::PadAdaptiveTriggers => "Adaptive Triggers", - Opt::PadLightbar => "Lightbar", - Opt::PadLookSensitivity => "Look Sensitivity", - Opt::PadInvertX => "Invert Look X", - Opt::PadInvertY => "Invert Look Y", - Opt::PadToggleSneak => "Sneak Button", - Opt::PadMoveDeadzone => "Move Deadzone", - Opt::PadLookDeadzone => "Look Deadzone", - Opt::PadGlyphs => "Button Glyphs", - Opt::PadMenuNav => "Menu Navigation", - Opt::PadKeyboard => "On-Screen Keyboard", + // Horizon's own options: vanilla has no keys for them, so they read + // the namespace the client bundles (assets/horizon/lang). + Opt::PadEnabled => "horizon.options.controller", + Opt::PadVibration => "horizon.options.vibration", + Opt::PadAdaptiveTriggers => "horizon.options.adaptive_triggers", + Opt::PadLightbar => "horizon.options.lightbar", + Opt::PadLookSensitivity => "horizon.options.look_sensitivity", + Opt::PadInvertX => "horizon.options.invert_x", + Opt::PadInvertY => "horizon.options.invert_y", + Opt::PadToggleSneak => "horizon.options.sneak_button", + Opt::PadMoveDeadzone => "horizon.options.move_deadzone", + Opt::PadLookDeadzone => "horizon.options.look_deadzone", + Opt::PadGlyphs => "horizon.options.button_glyphs", + Opt::PadMenuNav => "horizon.options.menu_navigation", + Opt::PadKeyboard => "horizon.options.on_screen_keyboard", } } pub(crate) fn kind(self, s: &Settings) -> Kind { From 75c009e3fc9ce99d3e6e31fa138a7c1656d89640 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:53:03 +0200 Subject: [PATCH 23/50] Boss events: one path from the host's diff to every overlay The host diffed each viewer's boss events into a Change enum, then turned the changes into packets for guests and, through a second match, into setter calls on the window's own overlay; the client turned packets into the same setters a third way. The diff now yields ClientboundBossEventPacket values, guests get them as packets and the local overlay applies them with BossOverlay::apply, the same function a server's packets reach. Gone: Change, Snap, Sent, apply_local, boss_packet and the overlay's nine-argument add and setters. UUIDUtil.uuidFromIntArray joins uuid_ints in lan::playerdata. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/boss_overlay.rs | 160 +++++++---- crates/horizon-client/src/game/bossbars.rs | 282 +++++--------------- crates/horizon-client/src/game/remote.rs | 2 +- crates/horizon-client/src/lan/playerdata.rs | 5 + 4 files changed, 178 insertions(+), 271 deletions(-) diff --git a/crates/horizon-client/src/boss_overlay.rs b/crates/horizon-client/src/boss_overlay.rs index e51a59668..12920e0d0 100644 --- a/crates/horizon-client/src/boss_overlay.rs +++ b/crates/horizon-client/src/boss_overlay.rs @@ -1,6 +1,11 @@ //! `BossHealthOverlay` and `LerpingBossEvent`: the boss bars a client is //! showing, in the order their add packets arrived. -use crate::{hud_overlays::BossBar, text::Text}; +use crate::{ + hud_overlays::BossBar, + scoreboard::{comp_text, tag_to_json}, + text::Text, +}; +use horizon_protocol::packets::play::{BossAction, BossEvent}; /// `LerpingBossEvent.LERP_MILLISECONDS`. const LERP_MS: u128 = 100; @@ -57,61 +62,62 @@ impl BossOverlay { pub fn reset(&mut self) { *self = Self::default(); } - /// `Handler.add`. An id already shown keeps its place. - pub fn add( - &mut self, - id: u128, - name: Text, - progress: f32, - color: u8, - overlay: u8, - darken: bool, - music: bool, - fog: bool, - now: u128, - ) { - let event = Event { - id, + /// `ClientboundBossEventPacket.dispatch` into `BossHealthOverlay`'s + /// handler. An id already shown keeps its place when added again. + pub fn apply(&mut self, packet: &BossEvent, now: u128) { + // An ordinal past the enum shows as its last value. + let color = |c: i32| c.clamp(0, 6) as u8; + let overlay = |o: i32| o.clamp(0, 4) as u8; + let id = packet.id; + if let BossAction::Add { name, progress, - target: progress, - set_at: now, - color: color.min(6), - overlay: overlay.min(4), + color: c, + overlay: o, darken, music, fog, + } = &packet.action + { + let event = Event { + id, + name: comp_text(&tag_to_json(name)), + progress: *progress, + target: *progress, + set_at: now, + color: color(*c), + overlay: overlay(*o), + darken: *darken, + music: *music, + fog: *fog, + }; + match self.events.iter_mut().find(|e| e.id == id) { + Some(slot) => *slot = event, + None => self.events.push(event), + } + return; + } + if let BossAction::Remove = packet.action { + self.events.retain(|e| e.id != id); + return; + } + let Some(e) = self.events.iter_mut().find(|e| e.id == id) else { + return; }; - if let Some(slot) = self.events.iter_mut().find(|e| e.id == id) { - *slot = event; - } else { - self.events.push(event); - } - } - pub fn remove(&mut self, id: u128) { - self.events.retain(|e| e.id != id); - } - pub fn set_progress(&mut self, id: u128, progress: f32, now: u128) { - if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { - e.set_progress(progress, now); - } - } - pub fn set_name(&mut self, id: u128, name: Text) { - if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { - e.name = name; - } - } - pub fn set_style(&mut self, id: u128, color: u8, overlay: u8) { - if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { - e.color = color.min(6); - e.overlay = overlay.min(4); - } - } - pub fn set_properties(&mut self, id: u128, darken: bool, music: bool, fog: bool) { - if let Some(e) = self.events.iter_mut().find(|e| e.id == id) { - e.darken = darken; - e.music = music; - e.fog = fog; + match &packet.action { + BossAction::UpdateProgress { progress } => e.set_progress(*progress, now), + BossAction::UpdateName { name } => e.name = comp_text(&tag_to_json(name)), + BossAction::UpdateStyle { + color: c, + overlay: o, + } => { + e.color = color(*c); + e.overlay = overlay(*o); + } + BossAction::UpdateProperties { darken, music, fog } => { + (e.darken, e.music, e.fog) = (*darken, *music, *fog); + } + BossAction::Add { .. } | BossAction::Remove => {} } } /// `GameRenderer.tick` while the level runs. @@ -157,26 +163,54 @@ impl BossOverlay { #[cfg(test)] mod tests { use super::*; + use horizon_world::nbt::Tag; + + fn add(id: u128, name: &str, progress: f32, darken: bool, fog: bool) -> BossEvent { + BossEvent { + id, + action: BossAction::Add { + name: Tag::String(name.into()), + progress, + color: 5, + overlay: 0, + darken, + music: false, + fog, + }, + } + } + fn progress(id: u128, progress: f32) -> BossEvent { + BossEvent { + id, + action: BossAction::UpdateProgress { progress }, + } + } #[test] fn progress_lerps_over_a_tenth_of_a_second() { let mut overlay = BossOverlay::default(); - overlay.add(1, Text::literal("W"), 0.0, 5, 0, true, false, false, 0); - overlay.set_progress(1, 1.0, 0); + overlay.apply(&add(1, "W", 0.0, true, false), 0); + overlay.apply(&progress(1, 1.0), 0); assert!((overlay.bars(50)[0].progress - 0.5).abs() < 1.0e-5); assert!((overlay.bars(100)[0].progress - 1.0).abs() < 1.0e-5); // A new target starts from the value on screen. - overlay.set_progress(1, 0.0, 50); + overlay.apply(&progress(1, 0.0), 50); assert!((overlay.bars(150)[0].progress - 0.0).abs() < 1.0e-5); } #[test] fn darken_ramps_and_fades() { let mut overlay = BossOverlay::default(); - overlay.add(1, Text::literal("W"), 1.0, 5, 0, true, false, false, 0); + overlay.apply(&add(1, "W", 1.0, true, false), 0); overlay.tick_darken(true); assert!((overlay.darken_amount(1.0) - 0.05).abs() < 1.0e-5); - overlay.remove(1); + overlay.apply( + &BossEvent { + id: 1, + action: BossAction::Remove, + }, + 0, + ); overlay.tick_darken(true); assert!((overlay.darken - 0.0375).abs() < 1.0e-5); assert!(!overlay.should_play_music()); @@ -185,9 +219,11 @@ mod tests { #[test] fn bars_keep_arrival_order() { let mut overlay = BossOverlay::default(); - overlay.add(2, Text::literal("b"), 1.0, 0, 0, false, true, true, 0); - overlay.add(1, Text::literal("a"), 1.0, 0, 0, false, false, false, 0); - overlay.add(2, Text::literal("c"), 0.5, 1, 2, false, true, true, 0); + overlay.apply(&add(2, "b", 1.0, false, true), 0); + overlay.apply(&add(1, "a", 1.0, false, false), 0); + overlay.apply(&add(2, "c", 0.5, false, true), 0); + // An update for a bar this client never got is ignored. + overlay.apply(&progress(9, 0.5), 0); let names: Vec<_> = overlay .bars(0) .into_iter() @@ -195,7 +231,13 @@ mod tests { .collect(); assert_eq!(names, ["c", "a"]); assert!(overlay.should_create_world_fog()); - overlay.remove(2); + overlay.apply( + &BossEvent { + id: 2, + action: BossAction::Remove, + }, + 0, + ); assert!(!overlay.should_create_world_fog()); } } diff --git a/crates/horizon-client/src/game/bossbars.rs b/crates/horizon-client/src/game/bossbars.rs index 85638da4a..d747fd749 100644 --- a/crates/horizon-client/src/game/bossbars.rs +++ b/crates/horizon-client/src/game/bossbars.rs @@ -9,7 +9,7 @@ use horizon_client::{ command::{Arg, Context}, mobs::Species, save, - scoreboard::{self as sb, Comp, comp_text, json_to_tag, tag_to_json}, + scoreboard::{self as sb, Comp, json_to_tag}, scoreboard_cmd::format_list, text::Text, }; @@ -23,7 +23,7 @@ pub(super) struct BossState { pub bars: BossBars, loaded: bool, /// What each viewer was last sent, so a change becomes one packet. - sent: HashMap>, + sent: HashMap>, } fn num(n: impl std::fmt::Display) -> Text { @@ -418,12 +418,11 @@ impl Game<'_> { let mut packets = Vec::new(); for viewer in &viewers { let desired = self.boss_events_for(viewer); - let changes = self.diff_boss_events(viewer, &desired); - if viewer.local { - apply_local(&mut self.boss_overlay, &changes, now); - } else if let Some(conn) = viewer.conn { - for change in changes { - packets.push((conn, boss_packet(change))); + for event in self.diff_boss_events(viewer, desired) { + if viewer.local { + self.boss_overlay.apply(&event, now); + } else if let Some(conn) = viewer.conn { + packets.push((conn, C::BossEvent(event))); } } } @@ -446,7 +445,7 @@ impl Game<'_> { if local && let Some(p) = &self.player { let ints = self.level.player_uuid.unwrap_or_else(|| self.acting_uuid()); out.push(Viewer { - uuid: ints_to_uuid(ints), + uuid: horizon_client::lan::playerdata::ints_uuid(ints), ints, at: [p.position.x, p.position.y, p.position.z], alive: !p.health.dead_or_dying(), @@ -552,8 +551,8 @@ impl Game<'_> { id: b.uuid, name: json_to_tag(&b.name), progress: b.progress(), - color: color_index(&b.color) as u8, - overlay: style_index(&b.style) as u8, + color: color_index(&b.color), + overlay: style_index(&b.style), darken: b.darken, music: b.music, fog: b.fog, @@ -562,108 +561,66 @@ impl Game<'_> { } out } - fn diff_boss_events(&mut self, viewer: &Viewer, desired: &[Offer]) -> Vec { + /// The packets that take `viewer` from what it was last sent to `desired` + /// (`ServerBossEvent`'s broadcasts). + fn diff_boss_events(&mut self, viewer: &Viewer, desired: Vec) -> Vec { let full = viewer.local; let dim = self.dim; let sent = self.bosses.sent.entry(viewer.uuid).or_default(); - let mut changes = Vec::new(); - let gone: Vec = sent - .iter() - .filter(|(id, prev)| { - let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim); - owned && !desired.iter().any(|e| e.id == **id) - }) - .map(|(id, _)| *id) - .collect(); - for id in gone { - sent.remove(&id); - changes.push(Change::Remove(id)); - } - for event in desired { - let snap = event.snap(); - match sent.get(&event.id).map(|s| s.snap.clone()) { - None => { - sent.insert( - event.id, - Sent { - snap, - scope: event.scope, - }, - ); - changes.push(Change::Add(event.clone())); - } - Some(prev) => { - if prev.progress != snap.progress { - changes.push(Change::Progress(event.id, snap.progress)); - } - if prev.name != snap.name { - changes.push(Change::Name(event.id, snap.name.clone())); - } - if prev.color != snap.color || prev.overlay != snap.overlay { - changes.push(Change::Style(event.id, snap.color, snap.overlay)); - } - if prev.darken != snap.darken - || prev.music != snap.music - || prev.fog != snap.fog - { - changes.push(Change::Properties( - event.id, - snap.darken, - snap.music, - snap.fog, - )); - } - sent.insert( - event.id, - Sent { - snap, - scope: event.scope, - }, - ); - } + let mut out = Vec::new(); + let event = |id, action| play::BossEvent { id, action }; + sent.retain(|id, prev| { + let owned = full || prev.scope == Scope::Global || prev.scope == Scope::Level(dim); + let keep = !owned || desired.iter().any(|e| e.id == *id); + if !keep { + out.push(event(*id, BossAction::Remove)); } + keep + }); + for offer in desired { + let Some(prev) = sent.get(&offer.id) else { + out.push(event( + offer.id, + BossAction::Add { + name: offer.name.clone(), + progress: offer.progress, + color: offer.color, + overlay: offer.overlay, + darken: offer.darken, + music: offer.music, + fog: offer.fog, + }, + )); + sent.insert(offer.id, offer); + continue; + }; + let id = offer.id; + if prev.progress != offer.progress { + let progress = offer.progress; + out.push(event(id, BossAction::UpdateProgress { progress })); + } + if prev.name != offer.name { + let name = offer.name.clone(); + out.push(event(id, BossAction::UpdateName { name })); + } + if (prev.color, prev.overlay) != (offer.color, offer.overlay) { + let (color, overlay) = (offer.color, offer.overlay); + out.push(event(id, BossAction::UpdateStyle { color, overlay })); + } + if (prev.darken, prev.music, prev.fog) != (offer.darken, offer.music, offer.fog) { + let (darken, music, fog) = (offer.darken, offer.music, offer.fog); + out.push(event( + id, + BossAction::UpdateProperties { darken, music, fog }, + )); + } + sent.insert(id, offer); } - changes + out } /// `ClientPacketListener.handleBossUpdate`. - pub(super) fn apply_boss_event(&mut self, packet: play::BossEvent) { - let now = boss_overlay::now_millis(); - let ord = |v: i32| v.clamp(0, 255) as u8; - match packet.action { - BossAction::Add { - name, - progress, - color, - overlay, - darken, - music, - fog, - } => self.boss_overlay.add( - packet.id, - component_text(&name), - progress, - ord(color), - ord(overlay), - darken, - music, - fog, - now, - ), - BossAction::Remove => self.boss_overlay.remove(packet.id), - BossAction::UpdateProgress { progress } => { - self.boss_overlay.set_progress(packet.id, progress, now) - } - BossAction::UpdateName { name } => { - self.boss_overlay.set_name(packet.id, component_text(&name)) - } - BossAction::UpdateStyle { color, overlay } => { - self.boss_overlay - .set_style(packet.id, ord(color), ord(overlay)) - } - BossAction::UpdateProperties { darken, music, fog } => self - .boss_overlay - .set_properties(packet.id, darken, music, fog), - } + pub(super) fn apply_boss_event(&mut self, packet: &play::BossEvent) { + self.boss_overlay.apply(packet, boss_overlay::now_millis()); } } @@ -673,27 +630,20 @@ fn insecure_uuid(random: &mut impl RandomSource) -> u128 { let least = random.next_long() & 4_611_686_018_427_387_903 | i64::MIN; ((most as u64 as u128) << 64) | (least as u64 as u128) } -fn ints_to_uuid(ints: [i32; 4]) -> u128 { - ints.iter() - .fold(0u128, |acc, &i| (acc << 32) | u128::from(i as u32)) -} fn translate_tag(key: &str) -> Tag { Tag::compound([("translate", Tag::String(key.into()))]) } -fn color_index(color: &str) -> usize { +fn color_index(color: &str) -> i32 { bossbar::COLORS .iter() .position(|c| *c == color) - .unwrap_or(6) + .unwrap_or(6) as i32 } -fn style_index(style: &str) -> usize { +fn style_index(style: &str) -> i32 { bossbar::STYLES .iter() .position(|s| *s == style) - .unwrap_or(0) -} -fn component_text(tag: &Tag) -> Text { - comp_text(&tag_to_json(tag)) + .unwrap_or(0) as i32 } #[derive(Clone, Copy, PartialEq, Eq)] @@ -701,20 +651,6 @@ enum Scope { Global, Level(Dim), } -#[derive(Clone)] -struct Snap { - name: Tag, - progress: f32, - color: u8, - overlay: u8, - darken: bool, - music: bool, - fog: bool, -} -struct Sent { - snap: Snap, - scope: Scope, -} struct Viewer { uuid: u128, ints: [i32; 4], @@ -725,92 +661,16 @@ struct Viewer { conn: Option, view_chunks: i32, } -#[derive(Clone)] +/// A boss event as one viewer should see it, and as it was last sent. struct Offer { id: u128, name: Tag, progress: f32, - color: u8, - overlay: u8, + /// `BossBarColor` and `BossBarOverlay` ordinals. + color: i32, + overlay: i32, darken: bool, music: bool, fog: bool, scope: Scope, } -impl Offer { - fn snap(&self) -> Snap { - Snap { - name: self.name.clone(), - progress: self.progress, - color: self.color, - overlay: self.overlay, - darken: self.darken, - music: self.music, - fog: self.fog, - } - } -} -enum Change { - Add(Offer), - Remove(u128), - Progress(u128, f32), - Name(u128, Tag), - Style(u128, u8, u8), - Properties(u128, bool, bool, bool), -} -fn apply_local(overlay: &mut boss_overlay::BossOverlay, changes: &[Change], now: u128) { - for change in changes { - match change { - Change::Add(e) => overlay.add( - e.id, - component_text(&e.name), - e.progress, - e.color, - e.overlay, - e.darken, - e.music, - e.fog, - now, - ), - Change::Remove(id) => overlay.remove(*id), - Change::Progress(id, progress) => overlay.set_progress(*id, *progress, now), - Change::Name(id, name) => overlay.set_name(*id, component_text(name)), - Change::Style(id, color, overlay_style) => { - overlay.set_style(*id, *color, *overlay_style) - } - Change::Properties(id, darken, music, fog) => { - overlay.set_properties(*id, *darken, *music, *fog) - } - } - } -} -fn boss_packet(change: Change) -> C { - let (id, action) = match change { - Change::Add(e) => ( - e.id, - BossAction::Add { - name: e.name, - progress: e.progress, - color: i32::from(e.color), - overlay: i32::from(e.overlay), - darken: e.darken, - music: e.music, - fog: e.fog, - }, - ), - Change::Remove(id) => (id, BossAction::Remove), - Change::Progress(id, progress) => (id, BossAction::UpdateProgress { progress }), - Change::Name(id, name) => (id, BossAction::UpdateName { name }), - Change::Style(id, color, overlay) => ( - id, - BossAction::UpdateStyle { - color: i32::from(color), - overlay: i32::from(overlay), - }, - ), - Change::Properties(id, darken, music, fog) => { - (id, BossAction::UpdateProperties { darken, music, fog }) - } - }; - C::BossEvent(play::BossEvent { id, action }) -} diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9c0504a2c..0b11a0882 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2642,7 +2642,7 @@ impl Game<'_> { } // ClientPacketListener.setActionBarText, setTitleText, // setSubtitleText, setTitlesAnimation and handleTitlesClear. - C::BossEvent(p) => self.apply_boss_event(p), + C::BossEvent(p) => self.apply_boss_event(&p), C::SetActionBarText(p) => self.titles.set_overlay(remote_text(&p.text), false), C::SetTitleText(p) => self.titles.set_title(remote_text(&p.text)), C::SetSubtitleText(p) => self.titles.set_subtitle(remote_text(&p.text)), diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 62102ade1..90fe0bcfe 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -27,6 +27,11 @@ pub fn uuid_ints(uuid: u128) -> [i32; 4] { uuid as i32, ] } +/// `UUIDUtil.uuidFromIntArray`. +pub fn ints_uuid(ints: [i32; 4]) -> u128 { + ints.iter() + .fold(0, |acc, &i| acc << 32 | u128::from(i as u32)) +} /// Whether a saved file exists for this player. pub fn exists(world: &Path, uuid: u128) -> bool { From 4d3f3b639183a2550713c818b862fd9d6b4d57bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Dr=C3=A4ger?= <20014538+sdraeger@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:02:57 -0700 Subject: [PATCH 24/50] Load fireballs from NBT as live projectiles --- crates/horizon-client/src/entity_nbt.rs | 91 +++++++++++++++++++ crates/horizon-client/src/game/commands.rs | 39 ++++---- .../horizon-client/src/game/commands/data.rs | 5 +- .../horizon-client/src/game/lan_entities.rs | 15 +-- crates/horizon-client/src/game/persist.rs | 13 ++- crates/horizon-client/src/game/scoreboard.rs | 2 +- crates/horizon-client/src/projectiles.rs | 28 +++++- 7 files changed, 153 insertions(+), 40 deletions(-) diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 49ea776ba..3c014e59b 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1155,6 +1155,62 @@ pub fn arrow_from_nbt( Some((p, tag.get("inBlockState").cloned())) } +/// `LargeFireball` and `SmallFireball`: `AbstractHurtingProjectile`'s +/// `acceleration_power`, `Projectile`'s owner and flags, `ExplosionPower`. +pub fn fireball_to_nbt( + p: &Projectile, + uuid: [i32; 4], + owner: Option<[i32; 4]>, + raw: Option<&Tag>, +) -> Option { + let power = match p.kind { + ProjectileKind::LargeFireball { power } => Some(power), + ProjectileKind::SmallFireball => None, + _ => return None, + }; + let base = Base { + id: p.kind.id(), + uuid, + position: p.position, + motion: p.velocity, + rotation: [p.yaw, p.pitch], + on_ground: false, + fall_distance: 0.0, + }; + let mut tag = base.tag(raw); + put( + &mut tag, + "acceleration_power", + Tag::Double(p.acceleration.to_bits()), + ); + put(&mut tag, "LeftOwner", Tag::Byte(i8::from(p.left_owner))); + if let Some(power) = power { + put(&mut tag, "ExplosionPower", Tag::Byte(power as i8)); + } + if let Some(owner) = owner { + put(&mut tag, "Owner", Tag::IntArray(owner.to_vec())); + } + Some(tag) +} + +pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option { + let kind = match kind(tag)?.as_str() { + "minecraft:fireball" => ProjectileKind::LargeFireball { + power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32, + }, + "minecraft:small_fireball" => ProjectileKind::SmallFireball, + _ => return None, + }; + let owner = uuid_from_tag(tag.get("Owner")); + let from_player = player.is_some() && owner == player; + let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player)); + p.velocity = motion(tag); + [p.yaw, p.pitch] = rotation(tag); + p.left_owner = flag(tag, "LeftOwner"); + p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1); + Some(p) +} + /// `FallingBlockEntity`: BlockState, Time, DropItem, HurtEntities. #[allow(clippy::too_many_arguments)] pub fn falling_block_to_nbt( @@ -2139,3 +2195,38 @@ mod sheep_nbt_tests { assert_eq!((back.extra.color, back.extra.sheared), (14, true)); } } + +#[cfg(test)] +mod fireball_nbt_tests { + use super::*; + #[test] + fn a_fireball_round_trips_its_flight() { + let raw = Tag::compound([ + ("id", Tag::String("minecraft:fireball".into())), + ("Pos", doubles(&[1.0, 70.0, -2.5])), + ("Motion", doubles(&[0.0, -0.5, 1.0])), + ("ExplosionPower", Tag::Byte(4)), + ("acceleration_power", Tag::Double(0.0f64.to_bits())), + ]); + let p = fireball_from_nbt(&raw, None).unwrap(); + assert_eq!(p.kind, ProjectileKind::LargeFireball { power: 4 }); + assert_eq!((p.velocity.z, p.acceleration), (1.0, 0.0)); + let back = fireball_to_nbt(&p, [1, 2, 3, 4], None, None).unwrap(); + assert_eq!(number(back.get("ExplosionPower")), Some(4)); + assert_eq!(float(back.get("acceleration_power")), Some(0.0)); + assert_eq!( + read_doubles::<3>(back.get("Motion")), + Some([0.0, -0.5, 1.0]) + ); + // A default fireball keeps vanilla's power and acceleration. + let plain = Tag::compound([ + ("id", Tag::String("minecraft:fireball".into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + let p = fireball_from_nbt(&plain, None).unwrap(); + assert_eq!( + (p.kind, p.acceleration), + (ProjectileKind::LargeFireball { power: 1 }, 0.1) + ); + } +} diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index a229cbdc9..3cb3fcbf5 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -42,7 +42,8 @@ pub(super) enum Target { Mob(u32), Item(u32), Orb(usize), - Arrow(usize), + /// By index in `creatures.projectiles`. + Projectile(usize), Stand(u32), Frame(u32), Painting(u32), @@ -327,7 +328,7 @@ impl Game<'_> { .orbs .get(i) .map(|o| [o.position.x, o.position.y, o.position.z]), - Target::Arrow(i) => self + Target::Projectile(i) => self .creatures .projectiles .get(i) @@ -382,7 +383,11 @@ impl Game<'_> { }), Target::Item(_) => "minecraft:item".into(), Target::Orb(_) => "minecraft:experience_orb".into(), - Target::Arrow(_) => "minecraft:arrow".into(), + Target::Projectile(i) => self + .creatures + .projectiles + .get(i) + .map_or_else(String::new, |p| p.kind.id().into()), Target::Stand(_) => "minecraft:armor_stand".into(), Target::Frame(id) => { if self.item_frames.iter().any(|f| f.id == id && f.glow) { @@ -503,6 +508,7 @@ impl Game<'_> { _ => {} } if s.kind != 's' { + use horizon_client::projectiles::ProjectileKind as K; candidates.extend(self.player_candidates(s)); candidates.extend( self.mobs @@ -517,8 +523,12 @@ impl Game<'_> { .projectiles .iter() .enumerate() - .filter(|(_, p)| p.arrow.is_some()) - .map(|(i, _)| Target::Arrow(i)), + // The kinds /data can write (entity_nbt). + .filter(|(_, p)| { + p.arrow.is_some() + || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) + }) + .map(|(i, _)| Target::Projectile(i)), ); candidates.extend(self.armor_stands.iter().map(|a| Target::Stand(a.id))); candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id))); @@ -1116,7 +1126,7 @@ impl Game<'_> { o.previous = v; } } - Target::Arrow(i) => { + Target::Projectile(i) => { if let Some(a) = self.creatures.projectiles.get_mut(i) { a.position = v; a.previous = v; @@ -1540,14 +1550,10 @@ impl Game<'_> { self.orbs[i].age = u32::MAX / 2; } } - Target::Arrow(i) => { - if let Some(a) = self - .creatures - .projectiles - .get_mut(i) - .and_then(|p| p.arrow.as_mut()) - { - a.removed = true; + // cmd_kill goes from the highest index down. + Target::Projectile(i) => { + if i < self.creatures.projectiles.len() { + self.creatures.projectiles.remove(i); } } Target::Stand(id) => self.armor_stands.retain(|a| a.id != id), @@ -1569,7 +1575,7 @@ impl Game<'_> { let mut ordered = targets.clone(); ordered.sort_by_key(|t| { std::cmp::Reverse(match t { - Target::Orb(i) | Target::Arrow(i) => *i, + Target::Orb(i) | Target::Projectile(i) => *i, _ => 0, }) }); @@ -1577,9 +1583,6 @@ impl Game<'_> { self.kill_target(t); } self.orbs.retain(|o| o.age < u32::MAX / 2); - self.creatures - .projectiles - .retain(|p| !p.arrow.as_ref().is_some_and(|a| a.removed)); let message = if targets.len() == 1 { Text::translate("commands.kill.success.single", &[names[0].clone()]) } else { diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 89e3d6397..da6f6f3bc 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -113,7 +113,7 @@ impl Game<'_> { self.other_to_nbt(&o, uuid) } Target::Orb(i) => entity_nbt::orb_to_nbt(self.orbs.get(i)?, random_uuid()), - Target::Arrow(i) => { + Target::Projectile(i) => { let p = self.creatures.projectiles.get(i)?; let owner = if p.owner == Some(horizon_client::mobs::Who::Player) { player_uuid @@ -125,7 +125,8 @@ impl Game<'_> { .as_ref() .and_then(|a| a.last_state) .map(|s| self.state_to_nbt(s)); - entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None)? + entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, None) + .or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, None))? } }) } diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index db0c3e8b2..dd272719a 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -321,20 +321,7 @@ impl Game<'_> { } for p in self.creatures.projectiles.iter().filter(|p| p.id != 0) { use horizon_client::projectiles::ProjectileKind as K; - let kind = match &p.kind { - K::Arrow { .. } => "minecraft:arrow", - K::Snowball => "minecraft:snowball", - K::Egg => "minecraft:egg", - K::EnderPearl => "minecraft:ender_pearl", - K::SmallFireball => "minecraft:small_fireball", - K::LargeFireball { .. } => "minecraft:fireball", - K::LlamaSpit => "minecraft:llama_spit", - K::WindCharge => "minecraft:wind_charge", - K::BreezeWindCharge => "minecraft:breeze_wind_charge", - K::ShulkerBullet { .. } => "minecraft:shulker_bullet", - K::WitherSkull { .. } => "minecraft:wither_skull", - K::Potion { .. } => "minecraft:splash_potion", - }; + let kind = p.kind.id(); let mut e = state(p.id, kind, at(p.position), p.yaw, false); e.pitch = p.pitch; e.velocity = at(p.velocity); diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index ad9d7fe7e..d3afbf6ce 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -156,6 +156,8 @@ impl Game<'_> { kind.as_str(), "minecraft:experience_orb" | "minecraft:arrow" + | "minecraft:fireball" + | "minecraft:small_fireball" | "minecraft:falling_block" | "minecraft:end_crystal" | "minecraft:ender_dragon" @@ -195,6 +197,11 @@ impl Game<'_> { self.creatures.projectiles.push(p); } } + "minecraft:fireball" | "minecraft:small_fireball" => { + if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) { + self.creatures.projectiles.push(p); + } + } "minecraft:falling_block" => { if let Some((state, position, velocity, age, fall_distance)) = entity_nbt::falling_block_from_nbt(&tag) @@ -642,13 +649,15 @@ impl Game<'_> { } else { None }; - let (uuid, raw) = anon(&self.persist, "minecraft:arrow", p.position); + let (uuid, raw) = anon(&self.persist, p.kind.id(), p.position); let in_block = p .arrow .as_ref() .and_then(|a| a.last_state) .map(|s| self.state_to_nbt(s)); - if let Some(tag) = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) { + let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref()) + .or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref())); + if let Some(tag) = tag { out.entry(chunk_of(p.position)).or_default().push(tag); } } diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index 4905fb80f..227a09a6c 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -180,7 +180,7 @@ impl Game<'_> { | Target::Cart(id) | Target::Other(id) => self.persist.raw.get(&id), Target::Player => self.level.player_extra.as_ref(), - Target::Guest(_) | Target::Orb(_) | Target::Arrow(_) => None, + Target::Guest(_) | Target::Orb(_) | Target::Projectile(_) => None, }; match raw.and_then(|r| r.get("Tags")) { Some(Tag::List(l)) => l diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index 093482a08..8244da4d9 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -61,6 +61,25 @@ pub enum ProjectileKind { color: u32, }, } +impl ProjectileKind { + /// Its entity type. + pub fn id(&self) -> &'static str { + match self { + Self::Arrow { .. } => "minecraft:arrow", + Self::Snowball => "minecraft:snowball", + Self::Egg => "minecraft:egg", + Self::EnderPearl => "minecraft:ender_pearl", + Self::SmallFireball => "minecraft:small_fireball", + Self::LargeFireball { .. } => "minecraft:fireball", + Self::LlamaSpit => "minecraft:llama_spit", + Self::WindCharge => "minecraft:wind_charge", + Self::BreezeWindCharge => "minecraft:breeze_wind_charge", + Self::ShulkerBullet { .. } => "minecraft:shulker_bullet", + Self::WitherSkull { .. } => "minecraft:wither_skull", + Self::Potion { .. } => "minecraft:splash_potion", + } + } +} #[derive(Clone, Debug, PartialEq)] pub struct Projectile { /// The entity id a server tracks it by (0 until it is tracked). @@ -83,6 +102,8 @@ pub struct Projectile { pub bonus: crate::mobs::gear::ArrowBonus, /// Arrows' `AbstractArrow` state (ticked by `arrows::tick`). pub arrow: Option>, + /// `AbstractHurtingProjectile.accelerationPower`. + pub acceleration: f64, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -146,6 +167,7 @@ impl Projectile { stuck: false, left_owner: false, bonus: Default::default(), + acceleration: 0.1, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( @@ -571,9 +593,9 @@ impl Projectile { let v = self.velocity; let len = (v.x * v.x + v.y * v.y + v.z * v.z).sqrt().max(1.0e-9); self.velocity = Vec3::new( - (v.x + v.x / len * 0.1) * inertia, - (v.y + v.y / len * 0.1) * inertia, - (v.z + v.z / len * 0.1) * inertia, + (v.x + v.x / len * self.acceleration) * inertia, + (v.y + v.y / len * self.acceleration) * inertia, + (v.z + v.z / len * self.acceleration) * inertia, ); } // Arrows tick with `arrows::tick`. From a93c1507f9ff1ee64b33f0298913ee7947ae2f5d Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 03:16:56 +0300 Subject: [PATCH 25/50] Launcher: a version removal retries a locked file and leaves the UI thread remove_dir_all stops at the first file another process still holds and leaves the rest deleted, so a removal that worked reported os error 5. Retry five times 250ms apart and treat a missing folder as done. The window froze because the deletion ran from the update loop; it now runs on a thread and reports back with Msg::Removed, as install already did. --- launcher/src/backend.rs | 23 ++++++++++++++++++++++- launcher/src/main.rs | 31 ++++++++++++++++++++++++------- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index d12dcd13c..02131cb42 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -558,7 +558,26 @@ pub fn install(release: &Release, progress: impl Fn(f32)) -> Result<(), String> } pub fn remove(tag: &str) -> Result<(), String> { - fs::remove_dir_all(version_dir(tag)).map_err(|e| e.to_string()) + let dir = version_dir(tag); + // Windows keeps a handle on a file for a moment after the game, a scan or the launcher's own + // reader lets go of it, and remove_dir_all stops at the first one: a removal that already + // took most of the folder reported os error 5 while the rest of it did go. Retry, and treat a + // folder that is already gone as done. + let mut last = None; + for attempt in 0..5 { + match fs::remove_dir_all(&dir) { + Ok(()) => return Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(e) => last = Some(e), + } + if attempt < 4 { + std::thread::sleep(std::time::Duration::from_millis(250)); + } + } + Err(match last { + Some(e) => e.to_string(), + None => format!("could not remove {}", dir.display()), + }) } /// The Minecraft version a Horizon build targets, from its research-lock.json. @@ -2170,6 +2189,8 @@ pub enum Msg { Links(Links), Progress(String, f32), Installed(String, Result<(), String>), + /// A removal finished off the UI thread. + Removed(String, Result<(), String>), Line(Job, String), Exit(Job, Result<(), String>), Updates(Result), diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 4a68ecdef..fd7e3d343 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -144,6 +144,9 @@ struct Launcher { releases: Load>, installed: Vec, installing: Option<(String, f32)>, + /// The version being deleted: the removal runs off the UI thread, so the window has to know + /// which row is busy until `Msg::Removed` arrives. + removing: Option, install_error: Option<(String, String)>, confirm_remove: Option, picker_open: bool, @@ -286,6 +289,7 @@ impl Launcher { releases: Load::Loading, installed, installing: None, + removing: None, install_error: None, confirm_remove: None, picker_open: false, @@ -516,6 +520,16 @@ impl Launcher { self.bar = (self.bar.1, p); self.installing = Some((tag, p)); } + Msg::Removed(tag, result) => { + self.removing = None; + self.installed = backend::installed(); + self.settings.selected = Settings::load() + .selected + .or_else(|| self.installed.first().cloned()); + if let Err(e) = result { + self.install_error = Some((tag, e)); + } + } Msg::Installed(tag, result) => { self.installing = None; self.bar = (0., 0.); @@ -725,14 +739,16 @@ impl Launcher { } fn remove(&mut self, tag: &str) { - if let Err(e) = backend::remove_version(tag) { - self.install_error = Some((tag.to_string(), e)); - } + // Same shape as install: the folder is hundreds of megabytes and Windows may hold a file + // for a moment, so the deletion goes to a thread instead of stalling the window. + let (tag, tx) = (tag.to_string(), self.tx.clone()); + self.removing = Some(tag.clone()); self.confirm_remove = None; - self.installed = backend::installed(); - self.settings.selected = Settings::load() - .selected - .or_else(|| self.installed.first().cloned()); + self.install_error = None; + std::thread::spawn(move || { + let result = backend::remove_version(&tag); + let _ = tx.send(Msg::Removed(tag, result)); + }); } fn prepare(&mut self) { @@ -2921,6 +2937,7 @@ impl Launcher { let keep = |i: usize| { let tag = &releases[i].tag; self.installing.as_ref().is_some_and(|(t, _)| t == tag) + || self.removing.as_deref() == Some(tag.as_str()) || self.install_error.as_ref().is_some_and(|(t, _)| t == tag) || self.confirm_remove.as_ref() == Some(tag) }; From b0a5334cb53c84db4369aae4e41de7b623e9c50c Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:17:28 +0100 Subject: [PATCH 26/50] Creative inventory: highlight hovered hotbar slots The item-tab hotbar loop only set the tooltip, so the nine slots never showed the white hover fill the 5x9 grid uses. Apply the same fill, including over empty slots, before picking the tooltip item. Fixes #1341 Co-Authored-By: opencode (deepseek-v4.1-flash-free) --- crates/horizon-client/src/game.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index e25732bdd..a0b1f6e47 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -9541,13 +9541,13 @@ impl Game<'_> { let (x, y) = (ox + 9.0 + c as f32 * 18.0, oy + 112.0); let stack = self.inventory[c].clone(); self.draw_slot_item(batch, drag.as_ref(), c, stack.clone(), x, y); - if let Some(stack) = stack - && self.cursor.0 >= x + if self.cursor.0 >= x && self.cursor.0 < x + 16.0 && self.cursor.1 >= y && self.cursor.1 < y + 16.0 { - hovered = Some(stack); + batch.fill(&self.gui.atlas, x, y, 16.0, 16.0, rgba(255, 255, 255, 128)); + hovered = stack; } } let scrollable = self.creative_screen.rows(&self.pack.names) > 5; From e55b5b7c00fc05667eaa431af0e1626957565b86 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 02:21:03 +0200 Subject: [PATCH 27/50] Boss events: custom bars first, as a joining player gets them CustomBossEvents.onPlayerConnect adds a joining player to its bars before any entity, dragon fight or raid tells it about theirs. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/bossbars.rs | 35 +++++++++++----------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/crates/horizon-client/src/game/bossbars.rs b/crates/horizon-client/src/game/bossbars.rs index d747fd749..51b64604f 100644 --- a/crates/horizon-client/src/game/bossbars.rs +++ b/crates/horizon-client/src/game/bossbars.rs @@ -474,9 +474,26 @@ impl Game<'_> { } out } - /// Dragon, withers, the raid and custom bars, in that order. + /// Custom bars (`CustomBossEvents.onPlayerConnect` sends them as a player + /// joins), then the dragon, withers and the raid. fn boss_events_for(&self, viewer: &Viewer) -> Vec { let mut out = Vec::new(); + for b in &self.bosses.bars.bars { + if !b.visible || !b.players.contains(&viewer.ints) { + continue; + } + out.push(Offer { + id: b.uuid, + name: json_to_tag(&b.name), + progress: b.progress(), + color: color_index(&b.color), + overlay: style_index(&b.style), + darken: b.darken, + music: b.music, + fog: b.fog, + scope: Scope::Global, + }); + } if viewer.here && let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive) { @@ -543,22 +560,6 @@ impl Game<'_> { scope: Scope::Level(self.dim), }); } - for b in &self.bosses.bars.bars { - if !b.visible || !b.players.contains(&viewer.ints) { - continue; - } - out.push(Offer { - id: b.uuid, - name: json_to_tag(&b.name), - progress: b.progress(), - color: color_index(&b.color), - overlay: style_index(&b.style), - darken: b.darken, - music: b.music, - fog: b.fog, - scope: Scope::Global, - }); - } out } /// The packets that take `viewer` from what it was last sent to `desired` From 6240124557c9f12f2dfaba4c9cb4636919d616a9 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:21:59 +0100 Subject: [PATCH 28/50] Slow Falling clears the fall distance so landing does not hurt The effect capped downward gravity but left the fall distance to accumulate, so the landing tick still dealt fall damage. Clear it at the start of check_fall_damage, where both local travel and the server thread's replay of a guest's move pass. Co-Authored-By: opencode (deepseek-v4.1-flash-free) --- crates/horizon-client/src/motion.rs | 43 +++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 0a626b1e7..5f91fee3a 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1753,6 +1753,9 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { state: BlockStateId, position: BlockPos, ) -> Result<()> { + if self.player.status.slow_falling { + self.player.fall_distance = 0.0; + } if self.player.spawn_extra_particles_on_fall && grounded && self.player.fall_distance > 0.0 { self.player.events.push(MotionEvent::SmashLanding { @@ -2861,6 +2864,46 @@ mod tests { } } #[test] + fn slow_falling_clears_the_fall_distance_so_landing_does_not_hurt() { + let root = 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 = Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let binding = crate::collisions::load(&root, assets.registry).unwrap(); + let resources = MotionResources::load(&root, &assets).unwrap(); + for slow_falling in [false, true] { + let mut scene = World::test_scene(&assets, &render, 2).unwrap(); + let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 9.0, 20.5)).unwrap(); + player.fall_distance = 30.0; + player.status.slow_falling = slow_falling; + let mut w = MotionWorld { + entities: &[], + player: &mut player, + scene: &mut scene, + assets: &assets, + collision_binding: &binding, + resources: &resources, + }; + for _ in 0..200 { + if w.player.ground.on_ground { + break; + } + w.player_movement_tick([0.0, 0.0], false).unwrap(); + } + assert!(w.player.ground.on_ground); + if slow_falling { + assert_eq!(w.player.health.health(), 20.0); + assert_eq!(w.player.fall_distance, 0.0); + } else { + assert!(w.player.health.health() < 20.0); + } + } + } + #[test] fn a_riptide_spin_lies_flat_and_ends_on_the_first_entity_it_touches() { let root = Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client") From 6d1f6965d23ec297186704fcdc43df40ea17a4f0 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 03:57:00 +0300 Subject: [PATCH 29/50] Packs: an untouched vanilla directory is mirrored on Windows too (#1688) link cannot symlink a directory on Windows, so mirror has to recurse into one itself, but it only did so when a pack had filters. An untouched directory fell through to the link branch and vanished from the overlay, which took textures/font and the panorama with it and made a pack that overrides a font crash the client before its window. --- crates/horizon-client/src/packs.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/horizon-client/src/packs.rs b/crates/horizon-client/src/packs.rs index 5b4d11e82..1e012fd90 100644 --- a/crates/horizon-client/src/packs.rs +++ b/crates/horizon-client/src/packs.rs @@ -517,7 +517,11 @@ fn build_overlay_uncached( } } } else if touched.contains(&child) - || (vanilla.join(&child).is_dir() && !filters.is_empty()) + // Windows cannot link a directory the way unix does (`link` above), so an + // untouched one has to be mirrored: skipping it hid textures/font and the + // title panorama from the overlay, and a font json that names those sheets + // then failed to load (#1688). + || (vanilla.join(&child).is_dir() && (cfg!(not(unix)) || !filters.is_empty())) { mirror( &child, From a96dccf6918aa18b509b42a217dc0388a7fb5ec6 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 03:57:31 +0300 Subject: [PATCH 30/50] Save: the write paths report a failure instead of dropping it The moderation lists on a dedicated server, command storage, boss bars, the world clocks, the difficulty and the emergency save before a crash all used let _ = and lost the error. write_dat also swallowed the rename that moves the current file aside, which made the following rename fail on the existing path with nothing reporting it. --- crates/horizon-client/src/command_storage.rs | 6 ++++-- crates/horizon-client/src/game/bossbars.rs | 6 ++++-- crates/horizon-client/src/game/chat.rs | 8 ++++++-- crates/horizon-client/src/game/debug_keys.rs | 4 +++- crates/horizon-client/src/game/dedicated.rs | 17 +++++++++++++---- crates/horizon-client/src/save.rs | 5 ++++- 6 files changed, 34 insertions(+), 12 deletions(-) diff --git a/crates/horizon-client/src/command_storage.rs b/crates/horizon-client/src/command_storage.rs index ce6e88f5c..560423e90 100644 --- a/crates/horizon-client/src/command_storage.rs +++ b/crates/horizon-client/src/command_storage.rs @@ -55,7 +55,7 @@ impl CommandStorage { .iter() .map(|(k, v)| (k.as_str().into(), v.clone())) .collect(); - let _ = save::write_dat( + if let Err(e) = save::write_dat( &file(root, namespace), Tag::compound([ ( @@ -64,7 +64,9 @@ impl CommandStorage { ), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]), - ); + ) { + log::warn!("command storage {namespace} was not written: {e}"); + } } } /// `get`: an empty compound when unset. diff --git a/crates/horizon-client/src/game/bossbars.rs b/crates/horizon-client/src/game/bossbars.rs index ec7b72632..f03257f18 100644 --- a/crates/horizon-client/src/game/bossbars.rs +++ b/crates/horizon-client/src/game/bossbars.rs @@ -47,13 +47,15 @@ impl Game<'_> { if self.bosses.bars.bars.is_empty() && !self.bossbar_path().exists() { return; } - let _ = save::write_dat( + if let Err(e) = save::write_dat( &self.bossbar_path(), Tag::compound([ ("data", self.bosses.bars.pack(self.level.player_uuid)), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]), - ); + ) { + log::warn!("boss bars were not written: {e}"); + } } pub(in crate::game) fn bossbar_exists(&self, id: &str) -> bool { self.bosses.bars.get(id).is_some() diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index f760bd82f..d06d303ae 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -816,8 +816,12 @@ impl Game<'_> { ("data", data), ("DataVersion", Tag::Int(horizon_world::DATA_VERSION)), ]); - let _ = horizon_client::save::write_dat(&path, root); - let _ = write_difficulty(&self.world_root, self.ui.difficulty); + if let Err(e) = horizon_client::save::write_dat(&path, root) { + log::warn!("the world clocks were not written: {e}"); + } + if let Err(e) = write_difficulty(&self.world_root, self.ui.difficulty) { + log::warn!("the difficulty was not written: {e}"); + } } /// Death message in chat (CombatTracker.getDeathMessage, as far as the /// cause is known to the client). diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index e0ad706de..61e2b2f05 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -185,7 +185,9 @@ impl Game<'_> { // Minecraft.emergencySaveAndCrash: the world is saved, then the // game stops; main writes the crash report. let crash = horizon_client::crash::DebugCrash::new(); - let _ = self.save_game(); + if let Err(e) = self.save_game() { + log::error!("the emergency save before the crash failed: {e}"); + } return Err(crash.into()); } } diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index eee12fe3f..4e91b9704 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -225,14 +225,23 @@ impl Lists { }; // DedicatedPlayerList's constructor order. warn("user banlist", l.bans.load()); - let _ = l.bans.save(); + warn( + "user banlist write", + l.bans.save().map_err(|e| e.to_string()), + ); warn("ip banlist", l.ip_bans.load()); - let _ = l.ip_bans.save(); + warn( + "ip banlist write", + l.ip_bans.save().map_err(|e| e.to_string()), + ); warn("operators list", l.ops.load()); warn("white-list", l.white.load()); - let _ = l.ops.save(); + warn("operators write", l.ops.save().map_err(|e| e.to_string())); if !l.white.file.exists() { - let _ = l.white.save(); + warn( + "white-list write", + l.white.save().map_err(|e| e.to_string()), + ); } l } diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 7819a9b0b..9dc7f62fd 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -1913,7 +1913,10 @@ pub fn write_dat(path: &Path, tag: Tag) -> Result<()> { Some(stem) => path.with_file_name(format!("{stem}.dat_old")), None => path.with_file_name(format!("{file}_old")), }; - let _ = fs::rename(path, old); + // Vanilla's safeReplaceFile moves the current file aside first. This used to be + // swallowed, and the rename below then failed on the existing path with nothing + // reporting it: a save that could not happen looked like one that did. + fs::rename(path, old).map_err(|e| format!("{}: {e}", path.display()))?; } fs::rename(temporary, path)?; Ok(()) From c10261f008e4a80344bfb8c3f14232c6ffffe632 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 03:58:02 +0300 Subject: [PATCH 31/50] Cleanup: one predicate property helper, a Windows-only allow, tolerant locks in the bots predicate_property was the same fifteen lines in block_feature and state_provider; it is worldgen::bound_property now. The mojang link helpers are unix-only in use but worth testing everywhere, so they are allowed to look dead elsewhere, which clears the launcher's -D warnings red on Windows. The bots harness recovers from a poisoned lock instead of panicking. --- crates/horizon-bots/src/main.rs | 99 +++++++++++++------ crates/horizon-world/src/worldgen.rs | 17 ++++ .../src/worldgen/block_feature.rs | 12 +-- .../src/worldgen/state_provider.rs | 12 +-- launcher/src/mojang.rs | 4 + 5 files changed, 92 insertions(+), 52 deletions(-) diff --git a/crates/horizon-bots/src/main.rs b/crates/horizon-bots/src/main.rs index 090daeb4e..666ead349 100644 --- a/crates/horizon-bots/src/main.rs +++ b/crates/horizon-bots/src/main.rs @@ -329,7 +329,7 @@ fn read( ) { use ids::play::clientbound as cb; use ids::play::serverbound as sb; - let send = |body: Vec| writer.lock().unwrap().send(&body); + let send = |body: Vec| writer.lock().unwrap_or_else(|e| e.into_inner()).send(&body); let mut seen = HashSet::new(); let mut batch = BatchSize::new(); let mut teleports = 0u64; @@ -338,7 +338,7 @@ fn read( Ok(packet) => packet, Err(e) => { if bot.online.swap(false, Ordering::SeqCst) { - *bot.error.lock().unwrap() = Some(e.to_string()); + *bot.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(e.to_string()); } return; } @@ -356,7 +356,12 @@ fn read( cb::LEVEL_CHUNK_WITH_LIGHT => { bot.chunks.fetch_add(1, Ordering::Relaxed); if let (Ok(x), Ok(z)) = (r.i32(), r.i32()) { - if !bot.held.lock().unwrap().insert((x, z)) { + if !bot + .held + .lock() + .unwrap_or_else(|e| e.into_inner()) + .insert((x, z)) + { bot.resent.fetch_add(1, Ordering::Relaxed); } if seen.insert((x, z)) { @@ -368,7 +373,11 @@ fn read( cb::FORGET_LEVEL_CHUNK => { if let Ok(play::Clientbound::ForgetLevelChunk(p)) = play::Clientbound::decode(id, &body) - && bot.held.lock().unwrap().remove(&(p.pos[0], p.pos[1])) + && bot + .held + .lock() + .unwrap_or_else(|e| e.into_inner()) + .remove(&(p.pos[0], p.pos[1])) { bot.forgotten.fetch_add(1, Ordering::Relaxed); } @@ -389,7 +398,7 @@ fn read( } cb::PLAYER_POSITION => match play::Clientbound::decode(id, &body) { Ok(play::Clientbound::PlayerPosition(p)) => { - let mut at = bot.teleport.lock().unwrap(); + let mut at = bot.teleport.lock().unwrap_or_else(|e| e.into_inner()); let previous = at.unwrap_or([0.0; 3]); // Relative flags: X 1, Y 2, Z 4. let pos: [f64; 3] = std::array::from_fn(|i| { @@ -420,17 +429,18 @@ fn read( }, cb::DISCONNECT | cb::START_CONFIGURATION => { bot.online.store(false, Ordering::SeqCst); - *bot.error.lock().unwrap() = Some(if id == cb::DISCONNECT { - format!( - "disconnected: {}", - String::from_utf8_lossy(&body) - .chars() - .filter(|c| !c.is_control()) - .collect::() - ) - } else { - "sent back to configuration".into() - }); + *bot.error.lock().unwrap_or_else(|e| e.into_inner()) = + Some(if id == cb::DISCONNECT { + format!( + "disconnected: {}", + String::from_utf8_lossy(&body) + .chars() + .filter(|c| !c.is_control()) + .collect::() + ) + } else { + "sent back to configuration".into() + }); return; } _ => Ok(()), @@ -455,7 +465,7 @@ fn run_bot( let connection = match join(&o, &name) { Ok(c) => c, Err(e) => { - *bot.error.lock().unwrap() = Some(e); + *bot.error.lock().unwrap_or_else(|e| e.into_inner()) = Some(e); return; } }; @@ -469,12 +479,18 @@ fn run_bot( use ids::play::serverbound as sb; // Wait for the spawn position. let spawn = loop { - if let Some(at) = *bot.teleport.lock().unwrap() { + if let Some(at) = *bot.teleport.lock().unwrap_or_else(|e| e.into_inner()) { break at; } if !bot.online.load(Ordering::SeqCst) || asked.elapsed() > Duration::from_secs(60) { - if bot.error.lock().unwrap().is_none() { - *bot.error.lock().unwrap() = Some("no spawn position within 60 s".into()); + if bot + .error + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_none() + { + *bot.error.lock().unwrap_or_else(|e| e.into_inner()) = + Some("no spawn position within 60 s".into()); } return; } @@ -482,7 +498,13 @@ fn run_bot( }; bot.joined_after_ms .store(asked.elapsed().as_millis() as u64, Ordering::SeqCst); - let send = |body: Vec| writer.lock().unwrap().send(&body).is_ok(); + let send = |body: Vec| { + writer + .lock() + .unwrap_or_else(|e| e.into_inner()) + .send(&body) + .is_ok() + }; send(raw(sb::PLAYER_LOADED, |_| {})); let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D)); let mut pos = spawn; @@ -491,17 +513,30 @@ fn run_bot( let mut target = pos; let mut next = Instant::now(); loop { - if until.lock().unwrap().is_some_and(|u| Instant::now() >= u) + if until + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some_and(|u| Instant::now() >= u) || !bot.online.load(Ordering::SeqCst) { break; } // A teleport from the server wins over where the bot thought it was. - if let Some(at) = bot.teleport.lock().unwrap().take() { + if let Some(at) = bot + .teleport + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + { pos = at; } let here = ((pos[0].floor() as i32) >> 4, (pos[2].floor() as i32) >> 4); - if !bot.held.lock().unwrap().contains(&here) { + if !bot + .held + .lock() + .unwrap_or_else(|e| e.into_inner()) + .contains(&here) + { bot.waited.fetch_add(1, Ordering::Relaxed); } else if pos[1] < cruise { pos[1] = (pos[1] + 0.5).min(cruise); @@ -558,7 +593,7 @@ fn run_bot( } // Offline first, so the reader doesn't count the closed connection as a failure. bot.online.store(false, Ordering::SeqCst); - writer.lock().unwrap().shutdown(); + writer.lock().unwrap_or_else(|e| e.into_inner()).shutdown(); } fn behaviour_of(mode: &str, index: usize) -> Behaviour { @@ -614,11 +649,12 @@ fn main() { }; let failed = bots .iter() - .filter(|b| b.error.lock().unwrap().is_some()) + .filter(|b| b.error.lock().unwrap_or_else(|e| e.into_inner()).is_some()) .count(); if first.is_none() && online > 0 { first = Some(Instant::now()); - *until.lock().unwrap() = Some(Instant::now() + Duration::from_secs(o.seconds)); + *until.lock().unwrap_or_else(|e| e.into_inner()) = + Some(Instant::now() + Duration::from_secs(o.seconds)); } println!( "{}", @@ -636,10 +672,13 @@ fn main() { }) ); previous_chunks = chunks; - let over = until.lock().unwrap().is_some_and(|u| Instant::now() >= u); + let over = until + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_some_and(|u| Instant::now() >= u); let all_failed = failed == o.bots; if over || all_failed || (first.is_none() && start.elapsed() > Duration::from_secs(120)) { - *until.lock().unwrap() = Some(Instant::now()); + *until.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now()); break; } } @@ -669,7 +708,7 @@ fn main() { "forgotten": b.forgotten.load(Ordering::Relaxed), "pulled_back": b.pulled_back.load(Ordering::Relaxed), "waited_ticks": b.waited.load(Ordering::Relaxed), - "error": *b.error.lock().unwrap(), + "error": *b.error.lock().unwrap_or_else(|e| e.into_inner()), })).collect::>(), }); if o.report.as_deref() == Some("-") { diff --git a/crates/horizon-world/src/worldgen.rs b/crates/horizon-world/src/worldgen.rs index 63de74d24..294f96336 100644 --- a/crates/horizon-world/src/worldgen.rs +++ b/crates/horizon-world/src/worldgen.rs @@ -103,6 +103,23 @@ impl std::fmt::Display for DecodeError { } } impl std::error::Error for DecodeError {} + +/// The bound value of one physical block property, for the two decode contexts that read +/// block predicates (`block_feature` and `state_provider`). +pub(crate) fn bound_property( + values: Option<&[crate::block_properties::BoundBlockProperty; 4]>, + property: crate::block_properties::BlockProperty, +) -> Result { + use crate::block_properties::BlockProperty; + let values = values.ok_or(DecodeError::Field("predicate block property registry"))?; + let index = match property { + BlockProperty::Solid => 0, + BlockProperty::Replaceable => 1, + BlockProperty::Liquid => 2, + BlockProperty::SolidRender => 3, + }; + Ok(values[index].clone()) +} impl From for DecodeError { fn from(value: serde_json::Error) -> Self { Self::Json(value) diff --git a/crates/horizon-world/src/worldgen/block_feature.rs b/crates/horizon-world/src/worldgen/block_feature.rs index 1ee122772..d943bd1be 100644 --- a/crates/horizon-world/src/worldgen/block_feature.rs +++ b/crates/horizon-world/src/worldgen/block_feature.rs @@ -192,17 +192,7 @@ impl super::block_predicate::PredicateBlockResolver for FeatureBlockBindings<'_> &self, property: BlockProperty, ) -> Result { - let values = self - .physical_properties - .as_ref() - .ok_or(DecodeError::Field("predicate block property registry"))?; - Ok(values[match property { - BlockProperty::Solid => 0, - BlockProperty::Replaceable => 1, - BlockProperty::Liquid => 2, - BlockProperty::SolidRender => 3, - }] - .clone()) + super::bound_property(self.physical_properties.as_ref(), property) } fn predicate_biome_set( &self, diff --git a/crates/horizon-world/src/worldgen/state_provider.rs b/crates/horizon-world/src/worldgen/state_provider.rs index da39cd246..e261192f9 100644 --- a/crates/horizon-world/src/worldgen/state_provider.rs +++ b/crates/horizon-world/src/worldgen/state_provider.rs @@ -186,17 +186,7 @@ impl PredicateBlockResolver for StateProviderBindings<'_> { &self, property: BlockProperty, ) -> Result { - let values = self - .physical_properties - .as_ref() - .ok_or(DecodeError::Field("predicate block property registry"))?; - Ok(values[match property { - BlockProperty::Solid => 0, - BlockProperty::Replaceable => 1, - BlockProperty::Liquid => 2, - BlockProperty::SolidRender => 3, - }] - .clone()) + super::bound_property(self.physical_properties.as_ref(), property) } fn predicate_biome_set( &self, diff --git a/launcher/src/mojang.rs b/launcher/src/mojang.rs index 070db43aa..27f9c3f0b 100644 --- a/launcher/src/mojang.rs +++ b/launcher/src/mojang.rs @@ -181,12 +181,16 @@ pub fn java_runtime( Ok(home) } +// Used by the unix link step below; the path-traversal check itself is tested on every +// platform, so these stay compiled and are allowed to look unused elsewhere. +#[cfg_attr(not(unix), allow(dead_code))] fn name_in<'a>(home: &Path, path: &'a Path) -> &'a Path { path.strip_prefix(home).unwrap_or(path) } /// Whether a link at `link` (relative to the runtime's home) pointing at `target` stays in the /// home. +#[cfg_attr(not(unix), allow(dead_code))] fn link_stays_inside(link: &Path, target: &str) -> bool { let mut depth = link.components().count().saturating_sub(1); for c in Path::new(target).components() { From 961af30997b43f76ff02cfe0b5dac24c59366cc0 Mon Sep 17 00:00:00 2001 From: Alexander Krylov Date: Sun, 25 Oct 2026 04:34:05 +0300 Subject: [PATCH 32/50] Clippy: clear the two warnings that the whole tree fails on The update tests' scratch helper only feeds unix-gated tests, and receive_decorated takes the box the queue already put the protos in. --- crates/horizon-client/src/streaming.rs | 4 ++++ crates/horizon-server/src/update.rs | 2 ++ 2 files changed, 6 insertions(+) diff --git a/crates/horizon-client/src/streaming.rs b/crates/horizon-client/src/streaming.rs index 4cad224fd..17af59746 100644 --- a/crates/horizon-client/src/streaming.rs +++ b/crates/horizon-client/src/streaming.rs @@ -637,6 +637,10 @@ impl Streaming { }); self.shared.ready.notify_one(); } + #[expect( + clippy::boxed_local, + reason = "the box is `Done::Decorated`'s, so moving it out copies nothing" + )] fn receive_decorated(&mut self, x: i32, z: i32, region: Box<[Proto; 9]>) { self.generation_scan.exhausted = false; self.decorating -= 1; diff --git a/crates/horizon-server/src/update.rs b/crates/horizon-server/src/update.rs index 8c87c66bc..0f3f57080 100644 --- a/crates/horizon-server/src/update.rs +++ b/crates/horizon-server/src/update.rs @@ -781,6 +781,8 @@ mod tests { use super::*; use serde_json::json; + // Only the unix tests build an install tree; the path helper goes with them. + #[cfg_attr(not(unix), allow(dead_code))] fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "horizon-server-update-{name}-{}", From e820e9e1af18b7ec556f9bfc7c8f202c7aac3100 Mon Sep 17 00:00:00 2001 From: rdbtCVS <87152661+rdbtCVS@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:40:39 +0000 Subject: [PATCH 33/50] Website: use one navigation bar across pages Co-Authored-By: OpenCode gpt-6.1-sol --- website/README.md | 4 ++++ website/build.mjs | 22 ++++++++++++-------- website/scripts/shots.mjs | 3 --- website/src/chrome.js | 8 ++++---- website/src/docs-page.js | 5 +++++ website/src/docs.html | 20 ++++++------------ website/src/index.html | 42 +------------------------------------- website/src/main.js | 8 +++----- website/src/style.css | 43 +++++++++++++-------------------------- 9 files changed, 50 insertions(+), 105 deletions(-) create mode 100644 website/src/docs-page.js diff --git a/website/README.md b/website/README.md index 8ac6e3910..9d1f8dc8d 100644 --- a/website/README.md +++ b/website/README.md @@ -10,6 +10,9 @@ stone texture and a pixel landscape drawn in code (`scripts/art.mjs`), and short releases, the community links and the contributors. Plain HTML, CSS and JavaScript; no framework and no runtime dependencies. +Home, Releases, Benchmarks and every Docs page share the header and phone menu rendered by +`build.mjs`, with the same links, theme switch and Download button. + ## Layout ``` @@ -29,6 +32,7 @@ src/main.js the home page: download buttons, recent releases, links, cont src/chrome.js the light/dark switch, the phone menu and GitHub fetching, shared by the pages src/releases-page.js the Releases page: the channel filter and the refresh from GitHub src/benchmarks-page.js the Benchmarks page: players' results from the telemetry server, the picker +src/docs-page.js the Docs pages: the shared theme switch and phone menu src/bench.js the Benchmarks tables, shared by build and page src/hardware.js the spec table estimates use (GPU TFLOPS and bandwidth, CPU single-thread score) src/estimate.js estimates for unmeasured hardware from the nearest measured machine diff --git a/website/build.mjs b/website/build.mjs index f871d5dbb..8b0954054 100644 --- a/website/build.mjs +++ b/website/build.mjs @@ -170,7 +170,7 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32])); const names = {}; const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all)); // Dependencies before what imports them. -const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js"]; +const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js"]; for (const file of modules) { const code = rewrite(await readFile(join(site, "src", file), "utf8")); names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`; @@ -201,13 +201,16 @@ const baked = { const unavailable = `

The release notes could not be loaded here.

Read them on GitHub`; -// The top bar and the footer of the pages beside the home page (Releases, Benchmarks). -const navLinks = [["/#about", "About"], ["/releases", "Releases"], ["/benchmarks", "Benchmarks"], ["/docs/", "Docs"]]; -const siteNav = (current) => `