diff --git a/.github/workflows/launcher.yml b/.github/workflows/launcher.yml
index 062d584861..88d0753d56 100644
--- a/.github/workflows/launcher.yml
+++ b/.github/workflows/launcher.yml
@@ -8,6 +8,9 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
+# Linux ships two archives of one binary: the .tar.gz the launcher updates itself from, and an
+# AppImage (packaging/linux/appimage.sh) for players without a distribution package, which replaces
+# itself instead (launcher/src/backend.rs).
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
@@ -19,7 +22,7 @@ on:
push:
branches: [main, launcher, 'launcher-*']
paths:
- # Every crate the launcher builds in, as tools/release_train.py's LAUNCHER_PATHS.
+ # Everything the launcher is built and packaged from, as tools/release_train.py's LAUNCHER_PATHS.
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
@@ -29,6 +32,8 @@ on:
- 'crates/horizon-release-sign/**'
- 'crates/horizon-telemetry/**'
- 'crates/horizon-theme/**'
+ - 'packaging/linux/**'
+ - 'packaging/icons/**'
- '.github/workflows/launcher.yml'
schedule:
- cron: '7-59/10 * * * *'
@@ -161,7 +166,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
- sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev
+ sudo apt-get install -y libxkbcommon-x11-dev libwayland-dev libx11-xcb-dev libfontconfig-dev libvulkan-dev libasound2-dev patchelf
- name: Metal shaders compiled now, or at run time without the Metal toolchain
# GPUI compiles its Metal shaders when it builds; the Mac mini has Xcode without the Metal toolchain
# (xcodebuild -downloadComponent MetalToolchain), so there they are compiled when the launcher starts.
@@ -254,6 +259,13 @@ jobs:
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
+ version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
+ if [[ "$RUNNER_OS" == "Linux" ]]; then
+ # The .tar.gz below is what the launcher updates itself from; the AppImage is what a
+ # player downloads and double-clicks, and it replaces itself instead
+ # (packaging/linux/appimage.sh, launcher/src/backend.rs).
+ ../packaging/linux/appimage.sh "$BIN/${{ matrix.binary }}" "$version" out
+ fi
mkdir -p dist
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
diff --git a/.github/workflows/native-hook-stamp.yml b/.github/workflows/native-hook-stamp.yml
new file mode 100644
index 0000000000..3ca07a4732
--- /dev/null
+++ b/.github/workflows/native-hook-stamp.yml
@@ -0,0 +1,55 @@
+name: native hook stamp
+# Opens a PR that numbers pending fragments after they merge. Only pushes to
+# main run main's own tool; fragments are read as data, not PR code.
+
+on:
+ push:
+ branches: [main]
+ paths:
+ - 'tools/native-hook-fragments/*.json'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: native-hook-stamp
+ cancel-in-progress: false
+
+jobs:
+ stamp:
+ if: github.repository == 'horizon-rust/horizon'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ timeout-minutes: 10
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: main
+ fetch-depth: 0
+ - name: Open a stamp pull request
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ set -eu
+ branch=automation/native-hook-stamp
+ if [ -n "$(gh pr list --state open --head "$branch" --json number --jq '.[0].number // empty')" ]; then
+ echo "A stamp pull request is already open."
+ exit 0
+ fi
+ python3 tools/stamp_native_hooks.py
+ if git diff --quiet; then
+ echo "No fragments to stamp."
+ exit 0
+ fi
+ git config user.name 'github-actions[bot]'
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
+ git checkout -b "$branch"
+ git add -u
+ git commit -m 'Stamp native hook API fragments'
+ if git ls-remote --exit-code --heads origin "$branch" >/dev/null; then
+ git fetch origin "+refs/heads/$branch:refs/remotes/origin/$branch"
+ fi
+ git push --force-with-lease origin "$branch"
+ gh pr create --base main --head "$branch" --title 'Stamp native hook API fragments' --body 'Assign API numbers to the pending native hook fragments. Close and reopen this PR to run CI.'
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b2b37f4ff1..f9cae781e7 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -113,6 +113,10 @@ it (edited on every push, never repeated): talk to its author in the thread befo
can see.
- **Tick "Allow edits by maintainers".** When something small is left (a lint, a rebase, a nit) we push the fixup
ourselves instead of sending it back to you; only design questions come back.
+- **Once we've pushed to your branch, merge main into it; don't rebase or force-push.** A rebase or force-push
+ replaces our commits with yours and erases the fixes we added (it happened on #5188 and #5203). Tell your agent
+ too: `git merge origin/main`, never `git rebase` or `git push --force` on a branch a maintainer has touched. If
+ our fixes get erased anyway, we finish the work in our own pull request that keeps your commits and credits you.
- **Tick its kind** in the template: `parity` (Horizon now does what vanilla does), `bug` (a crash or breakage),
`perf`, `feature` (new for players), `extra` (Horizon-only, off by default), `tooling` or `docs`. A workflow sets
that label, and area labels (`launcher`, `mobs`, `worldgen`, `rendering`...) from the paths you change.
diff --git a/Cargo.lock b/Cargo.lock
index 068c3e15d0..aa12187477 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1183,6 +1183,7 @@ dependencies = [
"cpal",
"flate2",
"glam",
+ "half",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1353,14 +1354,17 @@ dependencies = [
name = "horizon-server"
version = "0.1.0"
dependencies = [
+ "base64",
"chrono",
"horizon-anticheat",
"horizon-bench",
+ "horizon-core",
"horizon-release-sign",
"horizon-server-files",
"libc",
"serde",
"serde_json",
+ "sha1",
"sha2",
"toml",
"toml_edit",
@@ -1398,8 +1402,10 @@ dependencies = [
name = "horizon-shaders"
version = "0.1.0"
dependencies = [
+ "half",
"horizon-core",
"naga",
+ "png",
"zip",
]
diff --git a/ROADMAP.md b/ROADMAP.md
index 66b4377576..089e87e0f3 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -742,13 +742,13 @@ Open: the GUI Scale is clamped to 2 after a restart under Hyprland, while XWayla
-#### AppImage and a Nix flake
+#### A Nix flake
-Install Horizon on any Linux distribution as an AppImage, and on NixOS through a Nix flake.
+Install Horizon on NixOS through a Nix flake.
Technical
-A player's idea (#horizon-ideas). An AppImage of the launcher and game from the release workflow, and a flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA), the way horizon-parity's NixOS setup runs them today.
+A player's idea (#horizon-ideas). The launcher's AppImage ships with each launcher release ([packaging/linux/appimage.sh](packaging/linux/appimage.sh)): one file with the libraries GPUI needs, which replaces itself when it updates. A flake.nix packaging the release binaries with their runtime libraries (Vulkan loader, X11/Wayland, ALSA) is still missing, the way horizon-parity's NixOS setup runs them today.
diff --git a/crates/horizon-client-core/src/potions.rs b/crates/horizon-client-core/src/potions.rs
index 17312d7ff6..832d1fc017 100644
--- a/crates/horizon-client-core/src/potions.rs
+++ b/crates/horizon-client-core/src/potions.rs
@@ -118,6 +118,12 @@ pub fn effect(id: &str) -> Option<&'static EffectDef> {
None => mod_effect(id),
}
}
+/// A content mod's effect id (`:`), not vanilla's with or
+/// without `minecraft:`.
+#[inline]
+pub fn is_mod_effect(id: &str) -> bool {
+ !id.starts_with("minecraft:") && id.bytes().any(|b| b == b':')
+}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
@@ -365,6 +371,8 @@ mod tests {
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
+ assert!(is_mod_effect(glow.id) && !is_mod_effect("speed"));
+ assert!(!is_mod_effect(namespaced("speed")));
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
diff --git a/crates/horizon-client-input/src/lib.rs b/crates/horizon-client-input/src/lib.rs
index 53d8f889ad..f5e8509e25 100644
--- a/crates/horizon-client-input/src/lib.rs
+++ b/crates/horizon-client-input/src/lib.rs
@@ -10,5 +10,6 @@ pub mod interaction_input;
pub mod key_names;
pub mod keybinds;
pub mod keymapping;
+pub mod modifiers;
pub mod mouse;
pub mod window;
diff --git a/crates/horizon-client-input/src/modifiers.rs b/crates/horizon-client-input/src/modifiers.rs
new file mode 100644
index 0000000000..f5e183660f
--- /dev/null
+++ b/crates/horizon-client-input/src/modifiers.rs
@@ -0,0 +1,232 @@
+//! OS modifier reconciliation and modifiers synthesized by agent control.
+
+use crate::{
+ keybinds::{KeyBinds, key_code},
+ keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys},
+};
+use std::collections::HashSet;
+use winit::keyboard::{KeyCode, ModifiersState};
+
+const MODIFIERS: [(ModifiersState, [KeyCode; 2]); 4] = [
+ (
+ ModifiersState::SHIFT,
+ [KeyCode::ShiftLeft, KeyCode::ShiftRight],
+ ),
+ (
+ ModifiersState::CONTROL,
+ [KeyCode::ControlLeft, KeyCode::ControlRight],
+ ),
+ (ModifiersState::ALT, [KeyCode::AltLeft, KeyCode::AltRight]),
+ (
+ ModifiersState::SUPER,
+ [KeyCode::SuperLeft, KeyCode::SuperRight],
+ ),
+];
+
+/// A synthesized key updates the same modifier word as window events.
+/// Releasing one side must preserve the other side's hold.
+pub fn set_key(
+ modifiers: &mut ModifiersState,
+ physical: &mut HashSet,
+ key: KeyCode,
+ down: bool,
+) {
+ let Some((bit, pair)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&key)) else {
+ return;
+ };
+ if down {
+ physical.insert(key);
+ } else {
+ physical.remove(&key);
+ }
+ modifiers.set(*bit, pair.iter().any(|key| physical.contains(key)));
+}
+
+/// A modifier held for one synthesized click (the pad's Y, an agent's
+/// shift-click): what [`restore`] puts back once the click is done.
+#[derive(Clone, Copy, Debug)]
+pub struct Held {
+ key: KeyCode,
+ was_down: bool,
+ bit: bool,
+}
+
+/// Holds `key` in both the physical keys and the modifier word, which is
+/// what screens read (`InputWithModifiers.hasShiftDown`).
+pub fn hold(modifiers: &mut ModifiersState, physical: &mut HashSet, key: KeyCode) -> Held {
+ let bit = MODIFIERS
+ .iter()
+ .find(|(_, pair)| pair.contains(&key))
+ .is_some_and(|(bit, _)| modifiers.contains(*bit));
+ let held = Held {
+ key,
+ was_down: physical.contains(&key),
+ bit,
+ };
+ set_key(modifiers, physical, key, true);
+ held
+}
+
+/// Undoes [`hold`]: a key or bit that was already down stays down.
+pub fn restore(modifiers: &mut ModifiersState, physical: &mut HashSet, held: Held) {
+ if !held.was_down {
+ physical.remove(&held.key);
+ }
+ if let Some((bit, _)) = MODIFIERS.iter().find(|(_, pair)| pair.contains(&held.key)) {
+ modifiers.set(*bit, held.bit);
+ }
+}
+
+/// Recover missing modifier releases without releasing a rebound key or a pad.
+/// ToggleKeyMapping itself preserves keyboard and controller toggles on release.
+pub fn reconcile(
+ modifiers: ModifiersState,
+ physical: &mut HashSet,
+ bindings: &KeyBinds,
+ toggles: &mut ToggleKeys,
+ options: [bool; 2],
+ pad_held: [bool; 2],
+) {
+ for (bit, pair) in MODIFIERS {
+ if modifiers.contains(bit) {
+ continue;
+ }
+ for key in pair {
+ if !physical.remove(&key) {
+ continue;
+ }
+ for (slot, name) in [(SNEAK, "key.sneak"), (SPRINT, "key.sprint")] {
+ if key_code(bindings.get(name)) == Some(key) && !pad_held[slot] {
+ toggles.key(slot, KeyAction::Release, false, options[slot]);
+ }
+ }
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn modifier_taps_release_both_bits() {
+ let mut modifiers = ModifiersState::empty();
+ let mut physical = HashSet::new();
+ for (bit, pair) in MODIFIERS {
+ for key in pair {
+ set_key(&mut modifiers, &mut physical, key, true);
+ assert!(modifiers.contains(bit));
+ set_key(&mut modifiers, &mut physical, key, false);
+ assert!(modifiers.is_empty());
+ assert!(physical.is_empty());
+ }
+ }
+ }
+
+ #[test]
+ fn releasing_one_side_keeps_the_other_modifier_held() {
+ let mut modifiers = ModifiersState::empty();
+ let mut physical = HashSet::new();
+ for (bit, [left, right]) in MODIFIERS {
+ set_key(&mut modifiers, &mut physical, left, true);
+ set_key(&mut modifiers, &mut physical, right, true);
+ set_key(&mut modifiers, &mut physical, left, false);
+ assert!(modifiers.contains(bit));
+ set_key(&mut modifiers, &mut physical, right, false);
+ assert!(modifiers.is_empty());
+ }
+ }
+
+ #[test]
+ fn a_held_click_restores_the_previous_state() {
+ let mut modifiers = ModifiersState::empty();
+ let mut physical = HashSet::new();
+ let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
+ assert!(modifiers.shift_key());
+ restore(&mut modifiers, &mut physical, held);
+ assert!(modifiers.is_empty());
+ assert!(physical.is_empty());
+
+ // Right Shift already held, or the OS word set with no physical key.
+ for start in [HashSet::from([KeyCode::ShiftRight]), HashSet::new()] {
+ let mut modifiers = ModifiersState::SHIFT;
+ let mut physical = start.clone();
+ let held = hold(&mut modifiers, &mut physical, KeyCode::ShiftLeft);
+ restore(&mut modifiers, &mut physical, held);
+ assert_eq!(modifiers, ModifiersState::SHIFT);
+ assert_eq!(physical, start);
+ }
+ }
+
+ #[test]
+ fn lost_releases_respect_rebindings_and_pad_holds() {
+ for slot in [SNEAK, SPRINT] {
+ for rebound in [false, true] {
+ for pad in [false, true] {
+ for toggle in [false, true] {
+ let mut bindings = KeyBinds::default();
+ let (name, modifier) = if slot == SNEAK {
+ ("key.sneak", KeyCode::ShiftLeft)
+ } else {
+ ("key.sprint", KeyCode::ControlLeft)
+ };
+ if rebound {
+ bindings.set(name, "key.keyboard.c");
+ }
+ let mut physical = HashSet::from([modifier]);
+ if rebound {
+ physical.insert(KeyCode::KeyC);
+ }
+ let mut toggles = ToggleKeys::default();
+ toggles.key(slot, KeyAction::Press, false, toggle);
+ let mut options = [false; 2];
+ options[slot] = toggle;
+ let mut pad_held = [false; 2];
+ pad_held[slot] = pad;
+ reconcile(
+ ModifiersState::ALT,
+ &mut physical,
+ &bindings,
+ &mut toggles,
+ options,
+ pad_held,
+ );
+ assert_eq!(toggles.is_down(slot), rebound || pad || toggle);
+ assert!(!physical.contains(&modifier));
+ assert_eq!(physical.contains(&KeyCode::KeyC), rebound);
+ }
+ }
+ }
+ }
+ }
+
+ #[test]
+ fn unrelated_modifier_change_does_not_release_an_action() {
+ let mut toggles = ToggleKeys::default();
+ toggles.key(SNEAK, KeyAction::Press, false, false);
+ reconcile(
+ ModifiersState::ALT,
+ &mut HashSet::new(),
+ &KeyBinds::default(),
+ &mut toggles,
+ [false; 2],
+ [false; 2],
+ );
+ assert!(toggles.is_down(SNEAK));
+ }
+
+ #[test]
+ fn lost_modifier_release_preserves_a_controller_toggle() {
+ let mut toggles = ToggleKeys::default();
+ toggles.pad_toggle(SNEAK, false);
+ reconcile(
+ ModifiersState::empty(),
+ &mut HashSet::from([KeyCode::ShiftLeft]),
+ &KeyBinds::default(),
+ &mut toggles,
+ [false; 2],
+ [false; 2],
+ );
+ assert!(toggles.is_down(SNEAK));
+ }
+}
diff --git a/crates/horizon-client-render/Cargo.toml b/crates/horizon-client-render/Cargo.toml
index 75943891b6..0cb9062384 100644
--- a/crates/horizon-client-render/Cargo.toml
+++ b/crates/horizon-client-render/Cargo.toml
@@ -33,6 +33,7 @@ glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
+half = "2"
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
diff --git a/crates/horizon-client-render/src/assets.rs b/crates/horizon-client-render/src/assets.rs
index b920654a4c..85e2f338a7 100644
--- a/crates/horizon-client-render/src/assets.rs
+++ b/crates/horizon-client-render/src/assets.rs
@@ -235,6 +235,9 @@ impl RenderAssets {
for stage in 0..10 {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
+ if draws {
+ crate::native_emissive::load(root, &mut sprites);
+ }
sprites.save_disk_cache();
if draws {
sprites.finish();
diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs
index 86ad0d5fea..7420a7253d 100644
--- a/crates/horizon-client-render/src/audio.rs
+++ b/crates/horizon-client-render/src/audio.rs
@@ -1336,6 +1336,13 @@ impl Audio {
self.stop_instance(id);
self.release(id);
}
+ /// `SoundBufferLibrary.enumerate`: the decoded buffers kept and their bytes.
+ pub fn cache_stats(&self) -> (usize, usize) {
+ self.backend
+ .cache
+ .lock()
+ .map_or((0, 0), |c| (c.entries.len(), c.bytes))
+ }
/// Every sound event id (for command completion).
/// The engine's table sizes and the mixer's channels (`status`).
pub fn census(&self) -> serde_json::Value {
diff --git a/crates/horizon-client-render/src/auth.rs b/crates/horizon-client-render/src/auth.rs
index 2e9066f879..b69c0d1cfa 100644
--- a/crates/horizon-client-render/src/auth.rs
+++ b/crates/horizon-client-render/src/auth.rs
@@ -272,6 +272,192 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
+/// The account's chat key pair (ProfileKeyPair): the private key that signs
+/// its chat, the public key with the services' signature over it, and when
+/// to fetch a new one (refreshedAfter, epoch milliseconds). Like the access
+/// token it stays in memory: vanilla's profilekeys file is never written.
+pub struct ChatKeyPair {
+ pub private: horizon_protocol::rsa::RsaPrivateKey,
+ pub public: horizon_protocol::secure_chat::ProfileKey,
+ pub refreshed_after: i64,
+}
+impl std::fmt::Debug for ChatKeyPair {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("ChatKeyPair")
+ .field("expires_at", &self.public.expires_at)
+ .field("refreshed_after", &self.refreshed_after)
+ .finish_non_exhaustive()
+ }
+}
+impl ChatKeyPair {
+ /// ProfileKeyPair.dueRefresh.
+ pub fn due_refresh(&self, now: i64) -> bool {
+ self.refreshed_after < now
+ }
+}
+
+/// Epoch milliseconds now.
+pub fn now_millis() -> i64 {
+ SystemTime::now()
+ .duration_since(SystemTime::UNIX_EPOCH)
+ .map_or(0, |d| d.as_millis() as i64)
+}
+
+/// Instant.parse for the services' UTC times
+/// (`2022-07-30T23:05:24.548227Z`): epoch milliseconds.
+pub fn parse_instant(text: &str) -> Option {
+ let text = text.strip_suffix('Z')?;
+ let (date, time) = text.split_once('T')?;
+ let mut date = date.splitn(3, '-').map(|p| p.parse::().ok());
+ let (y, m, d) = (date.next()??, date.next()??, date.next()??);
+ let (hms, fraction) = time.split_once('.').unwrap_or((time, ""));
+ let mut hms = hms.splitn(3, ':').map(|p| p.parse::().ok());
+ let (h, min, s) = (hms.next()??, hms.next()??, hms.next()??);
+ if !(1..=12).contains(&m) || !(1..=31).contains(&d) || h > 23 || min > 59 || s > 60 {
+ return None;
+ }
+ if !fraction.bytes().all(|b| b.is_ascii_digit()) {
+ return None;
+ }
+ let millis = format!("{fraction:0<3}")[..3].parse::().ok()?;
+ // Days from 1970-01-01 (Howard Hinnant's days_from_civil).
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = y.div_euclid(400);
+ let yoe = y - era * 400;
+ let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ let days = era * 146_097 + doe - 719_468;
+ Some(((days * 24 + h) * 60 + min) * 60_000 + s * 1000 + millis)
+}
+
+/// Crypt.stringToPemRsaPrivateKey / stringToRsaPublicKey: the base64
+/// between a PEM block's armour lines.
+fn pem_der(pem: &str) -> Option> {
+ let body: String = pem
+ .lines()
+ .filter(|l| !l.starts_with("-----"))
+ .flat_map(|l| l.chars().filter(|c| !c.is_whitespace()))
+ .collect();
+ crate::skins::base64_decode(&body)
+}
+
+/// KeyPairResponse from the certificates endpoint, as
+/// AccountProfileKeyPairManager.fetchProfileKeyPair reads it: the PEM key
+/// pair, publicKeySignatureV2 (the signature over the profile id too),
+/// expiresAt and refreshedAfter. None for anything malformed, or a private
+/// key that isn't the public key's other half.
+pub fn parse_certificates(body: &str) -> Option {
+ use horizon_protocol::rsa::{RsaPrivateKey, RsaPublicKey};
+ let v: serde_json::Value = serde_json::from_str(body).ok()?;
+ let field = |path: &str| v.pointer(path).and_then(|s| s.as_str());
+ let private = RsaPrivateKey::from_der(&pem_der(field("/keyPair/privateKey")?)?).ok()?;
+ let key = pem_der(field("/keyPair/publicKey")?)?;
+ if RsaPublicKey::from_der(&key).ok()? != private.public {
+ return None;
+ }
+ let signature = crate::skins::base64_decode(field("/publicKeySignatureV2")?)?;
+ Some(ChatKeyPair {
+ private,
+ public: horizon_protocol::secure_chat::ProfileKey {
+ expires_at: parse_instant(field("/expiresAt")?)?,
+ key,
+ signature,
+ },
+ refreshed_after: parse_instant(field("/refreshedAfter")?)?,
+ })
+}
+
+/// UserApiService.getKeyPair: the certificates endpoint, POSTed with the
+/// access token.
+pub fn fetch_chat_keys(session: &AuthSession) -> Result {
+ let doc = discovery()?;
+ let url = endpoint(&doc, "player", "getCertificates").ok_or(JoinError::Unavailable)?;
+ let body = match agent()
+ .post(&url)
+ .set("Authorization", &format!("Bearer {}", session.access_token))
+ .set("Content-Type", "application/json; charset=utf-8")
+ .send_string("")
+ {
+ Ok(response) => response.into_string().unwrap_or_default(),
+ Err(ureq::Error::Status(status, response)) => {
+ return Err(JoinError::from_response(
+ status,
+ &response.into_string().unwrap_or_default(),
+ ));
+ }
+ Err(ureq::Error::Transport(_)) => return Err(JoinError::Unavailable),
+ };
+ parse_certificates(&body)
+ .ok_or_else(|| JoinError::Other("Malformed profile key pair response".to_owned()))
+}
+
+/// AccountProfileKeyPairManager.MINIMUM_PROFILE_KEY_REFRESH_INTERVAL.
+const KEY_RETRY: Duration = Duration::from_secs(60 * 60);
+
+/// The chat key pair as AccountProfileKeyPairManager keeps it: fetched on a
+/// background thread, kept until it is due a refresh, and on a failure kept
+/// as it was (the next try waits an hour, or the next join).
+#[derive(Default)]
+struct ChatKeys {
+ current: Option>,
+ fetching: bool,
+ next_try: Option,
+}
+static CHAT_KEYS: Mutex = Mutex::new(ChatKeys {
+ current: None,
+ fetching: false,
+ next_try: None,
+});
+
+/// prepareKeyPair (`now`: on a join) or, from a tick, shouldRefreshKeyPair
+/// then prepareKeyPair: start fetching when there is no key pair or it is
+/// due a refresh. Nothing without a signed-in account.
+pub fn prepare_chat_keys(now: bool) {
+ let Some(session) = current() else {
+ return;
+ };
+ let mut keys = CHAT_KEYS.lock().unwrap();
+ let instant = std::time::Instant::now();
+ if keys.fetching || !now && keys.next_try.is_some_and(|t| instant < t) {
+ return;
+ }
+ keys.next_try = Some(instant + KEY_RETRY);
+ if keys
+ .current
+ .as_ref()
+ .is_some_and(|k| !k.due_refresh(now_millis()))
+ {
+ return;
+ }
+ keys.fetching = true;
+ drop(keys);
+ let spawned = std::thread::Builder::new()
+ .name("Profile key fetch".into())
+ .spawn(move || {
+ let result = fetch_chat_keys(&session);
+ let mut keys = CHAT_KEYS.lock().unwrap();
+ keys.fetching = false;
+ match result {
+ Ok(pair) => keys.current = Some(Arc::new(pair)),
+ Err(e) => log::error!("Failed to retrieve profile key pair: {e:?}"),
+ }
+ });
+ if spawned.is_err() {
+ CHAT_KEYS.lock().unwrap().fetching = false;
+ }
+}
+
+/// The chat key pair fetched so far, unless it has expired.
+pub fn chat_keys() -> Option> {
+ let now = now_millis();
+ CHAT_KEYS
+ .lock()
+ .unwrap()
+ .current
+ .clone()
+ .filter(|k| !k.public.has_expired(now))
+}
+
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
@@ -485,6 +671,104 @@ mod tests {
);
}
+ #[test]
+ fn instants_like_java() {
+ assert_eq!(parse_instant("1970-01-01T00:00:00Z"), Some(0));
+ assert_eq!(parse_instant("1970-01-01T00:00:01.5Z"), Some(1500));
+ // Instant.parse("2022-07-30T23:05:24.548227Z").toEpochMilli().
+ assert_eq!(
+ parse_instant("2022-07-30T23:05:24.548227Z"),
+ Some(1_659_222_324_548)
+ );
+ assert_eq!(
+ parse_instant("2024-02-29T12:00:00Z"),
+ Some(1_709_208_000_000)
+ );
+ assert_eq!(parse_instant("1969-12-31T23:59:59Z"), Some(-1000));
+ for bad in [
+ "2022-07-30T23:05:24",
+ "2022-13-01T00:00:00Z",
+ "2022-07-30 23:05:24Z",
+ "2022-07-30T23:05:24.5x1Z",
+ "",
+ ] {
+ assert_eq!(parse_instant(bad), None, "{bad}");
+ }
+ }
+
+ /// A throwaway 1024-bit key (`openssl genrsa`, PKCS#8 as the services
+ /// send it under "RSA PRIVATE KEY") and its public half.
+ const PRIVATE_B64: &str = "MIICdQIBADANBgkqhkiG9w0BAQEFAASCAl8wggJbAgEAAoGBAKSDqaLOF2Ltt4tzWblkFeNYhdss67BhXTbOMUYjpcPdtf4dzNtgZsTf4WEluvc8pItGWChdpq3xzPC9GL4PZ+Oh1o6VR/wSm20/50QT7V+YcSP1VrVKjDpDSg/UoC1Fmdc/vb9AO8Ip7TxuRYd/uyog/ThQ8aGcWtvvtwPqx4XrAgMBAAECgYA+ZGy2gQDflRZSPeoeZHXkphMF/7BXUWW/vk1zs1tPwEsgV/70iEgRjHL2nA+fapFg/Mm8bWAkghrehmjW1BLq5w8sGdGJCHfE/kauu78jJK0/ME38e9bsSEvv5HMMxyx0mlV8DQu2pzCT71ceT8VD/1pewUbpeZypj5pxtsCIMQJBANXgca/mnAQXTuowI8FqMiald6OuAcAWbOaNrCB5HHWuxH7hFatSqGrgp4YLqHv7bzeodU1y/uxwc+/01khd0rMCQQDE6mPfIdZxHhWkMUAKhMEnrLanfCL0dfyAe+lBpqgPwO3UurCfCaFX5RdYB3fCyvgy5foHN1/I8/Eobp+enrvpAkBgkZnjeXWiZz/Lf/b1AtUoG/g8IHpL2BRNuVQpyWVvQX0ZaNoW5BiFQXCClsr5SBr8f4SPdsfhzBidFOiX/jz/AkBDuk6Bd3T3UR6ma54YQBLjX38vWf330QD4/CV9RCH30Sa8DT4kZT6OKah+yYrCf+cgMoNtqfkS9/xRKMNT3krJAkAaFrAT6HF5Y9b5OLASbbFe7MZ4zNCzuEl6plKNUi+JCHESaU/VCCuYOLoWLeSDFYkZUoSmqiRxH8fVTsBz393x";
+ const PUBLIC_B64: &str = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCkg6mizhdi7beLc1m5ZBXjWIXbLOuwYV02zjFGI6XD3bX+HczbYGbE3+FhJbr3PKSLRlgoXaat8czwvRi+D2fjodaOlUf8EpttP+dEE+1fmHEj9Va1Sow6Q0oP1KAtRZnXP72/QDvCKe08bkWHf7sqIP04UPGhnFrb77cD6seF6wIDAQAB";
+
+ fn pem(label: &str, b64: &str) -> String {
+ let lines: Vec<&str> = b64
+ .as_bytes()
+ .chunks(76)
+ .map(|c| std::str::from_utf8(c).unwrap())
+ .collect();
+ format!(
+ "-----BEGIN {label}-----\n{}\n-----END {label}-----\n",
+ lines.join("\n")
+ )
+ }
+
+ fn certificates(private: &str, public: &str) -> String {
+ serde_json::json!({
+ "keyPair": {
+ "privateKey": pem("RSA PRIVATE KEY", private),
+ "publicKey": pem("RSA PUBLIC KEY", public),
+ },
+ "publicKeySignature": "djE=",
+ "publicKeySignatureV2": "c2ln",
+ "expiresAt": "2022-07-30T23:05:24.548227Z",
+ "refreshedAfter": "2022-07-30T15:05:24.548227Z",
+ })
+ .to_string()
+ }
+
+ #[test]
+ fn certificates_response() {
+ let pair = parse_certificates(&certificates(PRIVATE_B64, PUBLIC_B64)).unwrap();
+ assert_eq!(
+ pair.public.key,
+ crate::skins::base64_decode(PUBLIC_B64).unwrap()
+ );
+ assert_eq!(pair.public.signature, b"sig", "publicKeySignatureV2");
+ assert_eq!(pair.public.expires_at, 1_659_222_324_548);
+ assert_eq!(pair.refreshed_after, 1_659_193_524_548);
+ assert_eq!(pair.private.public_der(), pair.public.key);
+ assert!(pair.due_refresh(1_659_193_524_549));
+ assert!(!pair.due_refresh(1_659_193_524_548));
+ assert!(!format!("{pair:?}").contains("MII"));
+ // A public key that isn't the private key's, and broken fields.
+ let other = horizon_protocol::rsa::RsaPrivateKey::generate(1024).unwrap();
+ let other = base64(&other.public_der());
+ assert!(parse_certificates(&certificates(PRIVATE_B64, &other)).is_none());
+ assert!(parse_certificates(&certificates("bm9wZQ==", PUBLIC_B64)).is_none());
+ assert!(parse_certificates("{}").is_none());
+ assert!(parse_certificates("").is_none());
+ }
+
+ fn base64(bytes: &[u8]) -> String {
+ const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ let mut out = String::new();
+ for c in bytes.chunks(3) {
+ let n = c
+ .iter()
+ .enumerate()
+ .fold(0u32, |n, (i, &b)| n | u32::from(b) << (16 - 8 * i));
+ for i in 0..4 {
+ out.push(if i <= c.len() {
+ char::from(A[(n >> (18 - 6 * i) & 63) as usize])
+ } else {
+ '='
+ });
+ }
+ }
+ out
+ }
+
#[test]
fn discovery_endpoints() {
let doc = serde_json::json!({"discovery": {"session": {"endpoints": {"join": {"uri": "https://s/join"}}}}});
diff --git a/crates/horizon-client-render/src/block_entities.rs b/crates/horizon-client-render/src/block_entities.rs
index 2cce1d603e..ecb6ec1c66 100644
--- a/crates/horizon-client-render/src/block_entities.rs
+++ b/crates/horizon-client-render/src/block_entities.rs
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
-use crate::chunk::block_entity::{BlockEntity, ModContainer};
+use crate::chunk::block_entity::{BlockEntity, ModContainer, ModFluidTanks};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -73,6 +73,9 @@ pub struct BlockEntities {
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap>,
+ /// The fluid tanks of mod blocks' block entities, by block state id;
+ /// built with `states`, like the containers.
+ mod_fluids: FxHashMap>,
}
impl std::ops::Deref for BlockEntities {
@@ -162,6 +165,13 @@ impl BlockEntities {
pub fn mod_container(&self, state: u32) -> Option<&Arc> {
self.mod_containers.get(&state)
}
+ pub fn set_mod_fluids(&mut self, fluids: FxHashMap>) {
+ self.mod_fluids = fluids;
+ }
+ /// The fluid tanks of a mod block's block entity in block state `state`.
+ pub fn mod_fluids(&self, state: u32) -> Option<&Arc> {
+ self.mod_fluids.get(&state)
+ }
fn next(&mut self) -> u32 {
let n = self.next_slot;
diff --git a/crates/horizon-client-render/src/brewing.rs b/crates/horizon-client-render/src/brewing.rs
index 6c60226986..cf625bd057 100644
--- a/crates/horizon-client-render/src/brewing.rs
+++ b/crates/horizon-client-render/src/brewing.rs
@@ -13,7 +13,8 @@ use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
- description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
+ description_id, effect, is_mod_effect, mod_effects, namespaced, potion, potion_color,
+ register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
diff --git a/crates/horizon-client-render/src/chat.rs b/crates/horizon-client-render/src/chat.rs
index 1d3c5e9039..12a654b4f6 100644
--- a/crates/horizon-client-render/src/chat.rs
+++ b/crates/horizon-client-render/src/chat.rs
@@ -1013,6 +1013,53 @@ fn last_word_index(s: &str) -> usize {
}
if prev_space { s.len() } else { out }
}
+/// `ChatScreen.normalizeChatMessage`: `String.trim`, then
+/// `StringUtils.normalizeSpace` (one space for every run of whitespace), then
+/// `StringUtil.trimChatMessage` (the first 256 UTF-16 code units, the length
+/// `ServerboundChatPacket`'s `stringUtf8(256)` decodes). The chat screen sends
+/// this, not what was typed: a line of spaces sends nothing, and a message the
+/// packet cannot hold is cut on the client, as vanilla's is, instead of by the
+/// server.
+pub fn normalize_chat_message(message: &str) -> String {
+ // String.trim: the code points up to U+0020, which is not `str::trim`'s
+ // Unicode whitespace (U+00A0 is not Java whitespace and stays).
+ let trimmed = message.trim_matches(|c: char| c <= ' ');
+ truncate_utf16(&normalize_space(trimmed), 256)
+}
+/// `StringUtils.normalizeSpace`: one space for every run of
+/// `Character.isWhitespace`, and none at either end.
+fn normalize_space(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ let mut space = false;
+ for c in s.chars() {
+ if crate::ui::java_whitespace(c) {
+ space = !out.is_empty();
+ } else {
+ if space {
+ out.push(' ');
+ space = false;
+ }
+ out.push(c);
+ }
+ }
+ out
+}
+/// `StringUtil.truncateStringIfNecessary(s, 256, false)`: Java counts UTF-16
+/// code units (`String.length`), so 129 emoji are cut to 128 of them, where
+/// `chars().take(256)` would keep all 129 (258 units) for the server to
+/// reject. A cut between the halves of a surrogate pair keeps the character
+/// below it: vanilla's `substring` leaves half a pair, which a Java `String`
+/// may hold and a Rust `String` may not.
+fn truncate_utf16(s: &str, max_units: usize) -> String {
+ let mut units = 0;
+ for (byte, c) in s.char_indices() {
+ units += c.len_utf16();
+ if units > max_units {
+ return s[..byte].to_owned();
+ }
+ }
+ s.to_owned()
+}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
pub fn new(
diff --git a/crates/horizon-client-render/src/chat_tests.rs b/crates/horizon-client-render/src/chat_tests.rs
index 79c668073c..794d37b677 100644
--- a/crates/horizon-client-render/src/chat_tests.rs
+++ b/crates/horizon-client-render/src/chat_tests.rs
@@ -578,3 +578,38 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), [marker.clone(), marker]);
assert!(chat.deletions.is_empty());
}
+
+#[test]
+fn a_chat_message_is_trimmed_and_its_whitespace_runs_collapsed() {
+ // ChatScreen.normalizeChatMessage: String.trim, then StringUtils
+ // .normalizeSpace, so a command gets the arguments the player meant.
+ assert_eq!(normalize_chat_message(" hello there "), "hello there");
+ assert_eq!(normalize_chat_message("/msg Alex\thi"), "/msg Alex hi");
+ assert_eq!(normalize_chat_message("a\n\nb\r\nc"), "a b c");
+ assert_eq!(normalize_chat_message("\t \n"), "");
+ assert_eq!(normalize_chat_message(""), "");
+ // Character.isWhitespace is not str::trim's Unicode whitespace: U+00A0 is
+ // no Java whitespace, so it is neither trimmed nor collapsed, while the
+ // ideographic space U+3000 and Java's U+001C separators are.
+ assert_eq!(normalize_chat_message(" \u{a0} hi"), "\u{a0} hi");
+ assert_eq!(normalize_chat_message("a\u{a0}\u{a0}b"), "a\u{a0}\u{a0}b");
+ assert_eq!(normalize_chat_message("a\u{3000}\u{3000}b"), "a b");
+ assert_eq!(normalize_chat_message("a\u{1c}b"), "a b");
+}
+
+#[test]
+fn a_chat_message_is_cut_to_the_256_utf16_units_the_packet_holds() {
+ // StringUtil.trimChatMessage counts String.length(), UTF-16 code units:
+ // 256 ASCII characters are all kept, 257 are cut to 256.
+ assert_eq!(normalize_chat_message(&"a".repeat(256)).len(), 256);
+ assert_eq!(normalize_chat_message(&"a".repeat(300)).len(), 256);
+ // An emoji is two units, so 128 of them fill the packet and the 129th is
+ // dropped: chars().take(256) would keep all 129 and the server would
+ // reject the message.
+ let emoji = normalize_chat_message(&"\u{1f600}".repeat(129));
+ assert_eq!(emoji, "\u{1f600}".repeat(128));
+ assert_eq!(emoji.encode_utf16().count(), 256);
+ // A cut between the halves of a pair keeps the character below it.
+ let mixed = format!("{}\u{1f600}", "b".repeat(255));
+ assert_eq!(normalize_chat_message(&mixed), "b".repeat(255));
+}
diff --git a/crates/horizon-client-render/src/chunk.rs b/crates/horizon-client-render/src/chunk.rs
index 351f71098d..c39e4bd302 100644
--- a/crates/horizon-client-render/src/chunk.rs
+++ b/crates/horizon-client-render/src/chunk.rs
@@ -212,7 +212,7 @@ impl Default for ColumnLayout {
/// Biomes are 4x4x4 containers over the biome table (67 entries: a 7-bit
/// global palette).
-pub const BIOME_STRATEGY: Strategy = Strategy::biomes(7);
+pub const BIOME_STRATEGY: Strategy = Strategy::biomes(8);
/// Bits of the block states' global palette: 16 for vanilla's 35,723 states
/// (what vanilla servers and clients send), more when content mods add states
@@ -391,6 +391,9 @@ pub struct Chunk {
pub light_loaded: bool,
/// Newly generated (not loaded from a save): eligible for generation-time spawns.
pub fresh: bool,
+ /// Finished from a proto chunk just now, in any kind of world, not read
+ /// from a save (`ChunkEvent.Load`'s `isNewChunk`).
+ pub generated: bool,
/// Block entities read from a save, moved into the world on arrival.
pub block_entities: Vec<((i32, i32, i32), block_entity::BlockEntity)>,
/// `CustomName` of the container block entities (shown as the screen title).
@@ -428,6 +431,7 @@ impl Chunk {
lit: false,
light_loaded: false,
fresh: false,
+ generated: false,
block_entities: Vec::new(),
block_names: Vec::new(),
structure_refs: Vec::new(),
diff --git a/crates/horizon-client-render/src/chunk/block_entity.rs b/crates/horizon-client-render/src/chunk/block_entity.rs
index b0636ee5dd..5eb48bcabc 100644
--- a/crates/horizon-client-render/src/chunk/block_entity.rs
+++ b/crates/horizon-client-render/src/chunk/block_entity.rs
@@ -120,6 +120,21 @@ pub struct ModContainer {
pub outputs: Vec,
pub menu: Option,
}
+/// The fluid tanks of a mod block's block entity, in the container's shape:
+/// each tank's capacity in millibuckets, the tanks each face reaches, and
+/// the tanks a fill may reach and a drain may take. The tanks' contents are
+/// not here: they live in the entity's saved compound, under `fluids`, so
+/// they survive restarts and the block's own tick reads them.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ModFluidTanks {
+ /// Each tank's capacity in millibuckets, in tank order.
+ pub tanks: Vec,
+ pub faces: [Vec; 6],
+ /// The tanks a fill may reach (`canPlaceItemThroughFace`).
+ pub inputs: Vec,
+ /// The tanks a drain may take from (`canTakeItemThroughFace`).
+ pub outputs: Vec,
+}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
diff --git a/crates/horizon-client-render/src/collisions.rs b/crates/horizon-client-render/src/collisions.rs
index dba30917dd..6e1460faf1 100644
--- a/crates/horizon-client-render/src/collisions.rs
+++ b/crates/horizon-client-render/src/collisions.rs
@@ -343,8 +343,15 @@ impl EntityMovementWorld for SceneCollisions<'_, '_> {
}
impl DynamicSupportWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
- fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result> {
- Ok(None)
+ /// `ShulkerBoxBlockEntity.isClosed`: whether the lid is still shut. A
+ /// closed box collides as its plain block (`ShulkerBoxBlock.getShape`
+ /// asks the block entity, which has grown once the lid starts to open).
+ fn shulker_is_closed(&mut self, p: [i32; 3]) -> io::Result > {
+ Ok(self
+ .world
+ .shulkers
+ .get(&p)
+ .map(|s| s.status == crate::block_entity_anim::ShulkerStatus::Closed))
}
/// `MovingPistonBlock.getCollisionShape`: the block entity's moving shape
/// (`PistonMovingBlockEntity.getCollisionShape`), outside any push.
@@ -373,11 +380,17 @@ impl PistonCollisionWorld for SceneCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for SceneCollisions<'_, '_> {
+ /// `ShulkerBoxBlockEntity.getBoundingBox`: the box's collision shape
+ /// (`getShape`) is the free shape grown by the lid's progress, which is
+ /// what lets an entity sink into an opening box. None: no such box here.
fn shulker_collision_state(
&mut self,
- _: [i32; 3],
+ p: [i32; 3],
) -> io::Result > {
- Ok(None)
+ Ok(self.world.shulkers.get(&p).map(|s| ShulkerCollisionState {
+ previous_progress: s.progress_old,
+ progress: s.progress,
+ }))
}
}
impl LiquidCollisionWorld for SceneCollisions<'_, '_> {
@@ -497,6 +510,8 @@ impl EntityMovementWorld for LookupCollisions<'_, '_> {
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
+ /// No block-entity data: a lookup world has no shulker box to ask, so the
+ /// box collides as a shut one (`VoxelShape::block()`).
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result > {
Ok(None)
}
@@ -505,6 +520,8 @@ impl DynamicSupportWorld for LookupCollisions<'_, '_> {
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
+ /// No block-entity data: the block table's own shape stands in for the lid
+ /// (`ShulkerBoxBlockEntity.getBoundingBox` needs a block entity).
fn shulker_collision_state(
&mut self,
_: [i32; 3],
@@ -611,3 +628,140 @@ mod border_movement_tests {
);
}
}
+
+#[cfg(test)]
+mod shulker_collision_tests {
+ use super::*;
+ use crate::block_entity_anim::{Shulker, ShulkerStatus, progress_delta_aabb};
+ use horizon_core::geometry::{Aabb, Axis, Direction, Vec3, VoxelShape};
+
+ /// A shulker box facing up at progress 0.0 (closed): collision is a 1x1x1 block.
+ #[test]
+ fn shulker_closed_collision_is_full_block() {
+ let shulker = Shulker {
+ status: ShulkerStatus::Closed,
+ progress: 0.0,
+ progress_old: 0.0,
+ ..Shulker::default()
+ };
+
+ let state = ShulkerCollisionState {
+ previous_progress: shulker.progress_old,
+ progress: shulker.progress,
+ };
+ let aabb = state.bounding_box(Direction::Up);
+
+ // Closed box: height 1.0
+ assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
+ assert!((aabb.max(Axis::Y) - 1.0).abs() < 1.0e-9);
+ // Full horizontal extent
+ assert!((aabb.min(Axis::X) - 0.0).abs() < 1.0e-9);
+ assert!((aabb.max(Axis::X) - 1.0).abs() < 1.0e-9);
+ }
+
+ /// A shulker box facing up at progress 1.0 (fully open): collision is 1.5 tall.
+ #[test]
+ fn shulker_open_collision_grows_upward() {
+ let shulker = Shulker {
+ status: ShulkerStatus::Opened,
+ progress: 1.0,
+ progress_old: 1.0,
+ ..Shulker::default()
+ };
+
+ let state = ShulkerCollisionState {
+ previous_progress: shulker.progress_old,
+ progress: shulker.progress,
+ };
+ let aabb = state.bounding_box(Direction::Up);
+
+ // Fully open: height 1.5 (lid extends 0.5 above the block)
+ assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
+ assert!((aabb.max(Axis::Y) - 1.5).abs() < 1.0e-9);
+ }
+
+ /// A shulker box halfway open: collision height is 1.25.
+ #[test]
+ fn shulker_half_open_collision_height() {
+ let shulker = Shulker {
+ status: ShulkerStatus::Opening,
+ progress: 0.5,
+ progress_old: 0.5,
+ ..Shulker::default()
+ };
+
+ let state = ShulkerCollisionState {
+ previous_progress: shulker.progress_old,
+ progress: shulker.progress,
+ };
+ let aabb = state.bounding_box(Direction::Up);
+
+ // Half open: height 1.25
+ assert!((aabb.min(Axis::Y) - 0.0).abs() < 1.0e-9);
+ assert!((aabb.max(Axis::Y) - 1.25).abs() < 1.0e-9);
+ }
+
+ /// Vanilla clip quirk: an entity whose feet are *inside* the grown lid box
+ /// moving DOWN is not stopped by the box (collision only triggers when
+ /// approaching from outside). An entity ABOVE the box moving DOWN is stopped.
+ #[test]
+ fn shulker_grown_box_allows_clip_when_inside() {
+ // Box at progress 1.0: spans y=0..1.5
+ let shulker = Shulker {
+ status: ShulkerStatus::Opened,
+ progress: 1.0,
+ progress_old: 1.0,
+ ..Shulker::default()
+ };
+ let state = ShulkerCollisionState {
+ previous_progress: shulker.progress_old,
+ progress: shulker.progress,
+ };
+ let aabb = state.bounding_box(Direction::Up);
+ let box_shape = VoxelShape::from_box(aabb).expect("shulker box is a valid shape");
+
+ // Entity box whose feet are at y=0.5 (inside the grown box), height 1.8
+ let entity = Aabb::new(Vec3::new(0.2, 0.5, 0.2), Vec3::new(0.8, 2.3, 0.8));
+
+ // Moving DOWN from inside the box: collide() should NOT stop it
+ // (the entity is already intersecting the box, so the downward movement
+ // sees no face to hit from above).
+ let down = -0.1;
+ let moved_down = box_shape.collide(Axis::Y, entity, down);
+ assert!(
+ (moved_down - down).abs() < 1.0e-9,
+ "entity inside grown box should fall through (clip): moved_down={moved_down}, expected={down}"
+ );
+
+ // Entity box whose feet are at y=1.6 (just ABOVE the grown box)
+ let entity_above = Aabb::new(Vec3::new(0.2, 1.6, 0.2), Vec3::new(0.8, 3.4, 0.8));
+
+ // Moving DOWN from above: collide() SHOULD stop it at y=1.5
+ let moved_down_above = box_shape.collide(Axis::Y, entity_above, -0.2);
+ // The box's top is at y=1.5, entity bottom at 1.6, so max down is -0.1
+ assert!(
+ (moved_down_above - (-0.1)).abs() < 1.0e-9,
+ "entity above grown box should be stopped: moved_down_above={moved_down_above}, expected=-0.1"
+ );
+ }
+
+ /// An entity standing on the lid (y=1.0 when closed, y=1.5 when open)
+ /// and pushed up by the lid: the lid's swept AABB pushes it along the facing.
+ /// This is tested at the integration level via `push_shulker_entities`;
+ /// here we only verify the swept box geometry matches vanilla's
+ /// `Shulker.getProgressDeltaAabb`.
+ #[test]
+ fn shulker_swept_box_matches_vanilla_delta() {
+ // From closed (0.0) to progress 0.5: the lid sweeps from y=0..1 (closed box)
+ // to y=0..1.5 (half open), so the swept volume in world coords at position
+ // [0.5, 0.0, 0.5] (bottom center of block at y=0) is y=1.0..1.5.
+ let delta = progress_delta_aabb(1.0, Direction::Up as u8, 0.0, 0.5, [0.5, 0.0, 0.5]);
+ assert!((delta[1] - 1.0).abs() < 1.0e-9); // minY
+ assert!((delta[4] - 1.5).abs() < 1.0e-9); // maxY
+
+ // From progress 0.5 to 1.0: swept y=1.5..2.0 in world coords
+ let delta2 = progress_delta_aabb(1.0, Direction::Up as u8, 0.5, 1.0, [0.5, 0.0, 0.5]);
+ assert!((delta2[1] - 1.5).abs() < 1.0e-9);
+ assert!((delta2[4] - 2.0).abs() < 1.0e-9);
+ }
+}
diff --git a/crates/horizon-client-render/src/containers.rs b/crates/horizon-client-render/src/containers.rs
index e8f857bdfb..574ed8d412 100644
--- a/crates/horizon-client-render/src/containers.rs
+++ b/crates/horizon-client-render/src/containers.rs
@@ -4,7 +4,7 @@
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
- PotentSulfur, Shelf, Skull, SkullSkin,
+ ModFluidTanks, PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
diff --git a/crates/horizon-client-render/src/datapack.rs b/crates/horizon-client-render/src/datapack.rs
index 679ebe2f23..158d9bdfc6 100644
--- a/crates/horizon-client-render/src/datapack.rs
+++ b/crates/horizon-client-render/src/datapack.rs
@@ -75,6 +75,24 @@ impl DataPack {
other => other["text"].as_str().unwrap_or_default().to_owned(),
}
}
+ /// The `features.enabled` flag ids of `pack.mcmeta`
+ /// (`FeatureFlagsMetadataSection`): the flags the pack asks to turn on.
+ pub fn features(&self) -> Vec {
+ self.mcmeta()["features"]["enabled"]
+ .as_array()
+ .map(|a| {
+ a.iter()
+ .filter_map(|f| f.as_str().map(str::to_owned))
+ .collect()
+ })
+ .unwrap_or_default()
+ }
+ /// The `pack.mcmeta` as JSON, empty when it is missing or unreadable.
+ fn mcmeta(&self) -> serde_json::Value {
+ self.read("pack.mcmeta")
+ .and_then(|b| serde_json::from_slice(&b).ok())
+ .unwrap_or_default()
+ }
}
fn walk(root: &Path, dir: &Path, out: &mut Vec) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -127,14 +145,43 @@ pub fn discover(world: &Path) -> Vec {
packs.sort_by(|a, b| a.id.cmp(&b.id));
packs
}
+/// The resources root's built-in feature packs (`ServerPacksSource`
+/// scanning the game's `data/minecraft/datapacks`): every folder with a
+/// `pack.mcmeta`, a feature pack (`PackSource.FEATURE`). Off until a world
+/// selects them.
+pub fn feature_packs(resources: &Path) -> Vec {
+ let Ok(entries) = std::fs::read_dir(resources.join("data").join("minecraft").join("datapacks"))
+ else {
+ return Vec::new();
+ };
+ let mut packs = Vec::new();
+ for e in entries.flatten() {
+ let path = e.path();
+ if path.join("pack.mcmeta").exists() {
+ packs.push(DataPack {
+ id: e.file_name().to_string_lossy().into_owned(),
+ source: Source::Dir(path),
+ });
+ }
+ }
+ packs.sort_by(|a, b| a.id.cmp(&b.id));
+ packs
+}
+/// The packs of `/datapacks` plus the resources root's feature packs:
+/// the world's whole repository, as `PackRepository.reload` sees it.
+pub fn discover_with_features(world_root: &Path, resources: &Path) -> Vec {
+ let mut packs = discover(world_root);
+ packs.extend(feature_packs(resources));
+ packs
+}
// ---- Create World ------------------------------------------------------------
/// Create World's Data Packs screen (`PackSelectionScreen` on vanilla's
-/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`) and the
-/// built-in one (`resources` holds its `pack.png`), which can be unselected
-/// and stays at the bottom. Horizon has no experiments, so their feature
-/// packs are not offered.
+/// `mcworld-` folder): the folder's packs (`PackSource.WORLD`), the
+/// built-in one (`resources` holds its `pack.png`), and the feature packs
+/// of `resources` (`PackSource.FEATURE`), available at the bottom and off
+/// until picked — the Experiments.
pub fn screen_packs(dir: &Path, resources: &Path) -> Vec {
use crate::{
pack_screen::{Compat, PackInfo, Source, load_icon},
@@ -172,6 +219,23 @@ pub fn screen_packs(dir: &Path, resources: &Path) -> Vec std::io::Result {
std::fs::create_dir_all(&dir)?;
Ok(dir)
}
+/// `PackSelectionScreen.onFilesDrop`'s `PackDetector`: a zip is a pack; a
+/// folder is one when it holds a `pack.mcmeta`, or exactly one folder that
+/// does (a folder zipped by the system, which adds a wrapper). Copied into
+/// `dir`; false when `path` is no pack.
+pub fn copy_pack(path: &Path, dir: &Path) -> bool {
+ fn copy_dir(from: &Path, at: &Path) {
+ let _ = std::fs::create_dir_all(at);
+ for entry in std::fs::read_dir(from).into_iter().flatten().flatten() {
+ let path = entry.path();
+ let to = at.join(entry.file_name());
+ if path.is_dir() {
+ copy_dir(&path, &to);
+ } else {
+ let _ = std::fs::copy(&path, &to);
+ }
+ }
+ }
+ let name = || {
+ path.file_name()
+ .map(|n| n.to_string_lossy().into_owned())
+ .unwrap_or_default()
+ };
+ if path.is_file() {
+ return path
+ .extension()
+ .is_some_and(|e| e.eq_ignore_ascii_case("zip"))
+ && {
+ let _ = std::fs::create_dir_all(dir);
+ std::fs::copy(path, dir.join(name())).is_ok()
+ };
+ }
+ if !path.is_dir() {
+ return false;
+ }
+ if path.join("pack.mcmeta").is_file() {
+ copy_dir(path, &dir.join(name()));
+ return true;
+ }
+ let own: Vec = std::fs::read_dir(path)
+ .into_iter()
+ .flatten()
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| p.is_dir() && p.join("pack.mcmeta").is_file())
+ .collect();
+ match own.as_slice() {
+ [one] => {
+ copy_dir(one, &dir.join(one.file_name().unwrap_or_default()));
+ true
+ }
+ _ => false,
+ }
+}
/// What's in a folder of packs, to notice packs added or removed while the
/// screen shows it (`PackSelectionScreen.Watcher`).
pub fn folder_stamp(dir: &Path) -> Vec<(String, Option)> {
@@ -230,7 +347,11 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option, Vec) {
let packs = discover(world);
- let mut selected = enabled_ids(world, &packs);
+ // No flags here: the recreated world keeps the original's
+ // `enabled_features`, and the flag-aware load re-adds the feature packs
+ // it asks for (`for_recreate`'s selection only seeds the new world's
+ // `datapacks.json`).
+ let mut selected = enabled_ids(world, &packs, &[]);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::(&b).ok())
@@ -461,17 +582,20 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `/:` for a pack file the content tables read:
-/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` or `tags//`, where the id
-/// keeps the sub path so a nested tag kind stays addressable.
+/// `recipe/`, `loot_table/`, `advancement/`, `trade_set/`,
+/// `villager_trade/`, `dialog/` or `tags//`, where the id keeps the sub
+/// path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
- "recipe" | "loot_table" | "advancement" | "dialog" => kind,
+ "recipe" | "loot_table" | "advancement" | "trade_set" | "villager_trade" | "dialog" => kind,
+ // A tag id is `{ns}:{path-under-tags}` (`tags/worldgen/biome/
+ // test:sub/mytag`), the whole path after the namespace, so nested
+ // registries keep their directory (`TagLoader`'s id shape).
"tags" => {
- let (sub, name) = rest.rsplit_once('/')?;
- return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
+ return Some(format!("tags/{ns}:{}", rest.strip_suffix(".json")?));
}
_ => return None,
};
@@ -504,6 +628,8 @@ impl DataStore {
"predicate",
"recipe",
"tags",
+ "trade_set",
+ "villager_trade",
]) {
if !seen.insert(f.clone()) {
continue;
@@ -617,10 +743,25 @@ fn hash_normalize(s: &str) -> String {
}
}
+/// Whether every flag `pack` asks for (`Pack.getRequestedFeatures`) is among
+/// the world's `enabled_features`.
+fn features_enabled(pack: &DataPack, enabled_features: &[String]) -> bool {
+ pack.features()
+ .iter()
+ .all(|f| enabled_features.iter().any(|e| e == f))
+}
+
/// The ids of `packs` (a world's, from `discover`) that are enabled, lowest
/// priority first: those `/datapacks.json` lists as enabled, in its
/// order, then every pack it lists neither way, in name order.
-pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec {
+///
+/// `enabled_features` are the world's feature flags
+/// (`WorldDataConfiguration.enabledFeatures`): a pack a pack file selected
+/// while asking for missing features comes off
+/// (`MinecraftServer.configurePackRepository`), and a feature pack whose
+/// features the world enables is forced on however the world's selection
+/// feels about it (`MinecraftServer.enableForcedFeaturePacks`).
+pub fn enabled_ids(world: &Path, packs: &[DataPack], enabled_features: &[String]) -> Vec {
let saved: Option = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice(&b).ok());
@@ -639,9 +780,32 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec {
let mut enabled: Vec = list("enabled")
.into_iter()
.filter(|id| packs.iter().any(|p| &p.id == id))
+ .filter(|id| {
+ packs
+ .iter()
+ .find(|p| &p.id == id)
+ .is_none_or(|p| features_enabled(p, enabled_features))
+ })
.collect();
for p in packs {
- if !enabled.contains(&p.id) && !disabled.contains(&p.id) {
+ // A pack nobody mentions comes on (`PackRepository.reload` selects
+ // every new pack), but a feature pack stays off until the world
+ // enabled its flag (`PackSource.FEATURE` starts unselected).
+ if !enabled.contains(&p.id)
+ && !disabled.contains(&p.id)
+ && p.id.starts_with("file/")
+ && features_enabled(p, enabled_features)
+ {
+ enabled.push(p.id.clone());
+ }
+ }
+ for p in packs {
+ let feature_pack = !p.id.starts_with("file/") && p.id != "vanilla";
+ if feature_pack
+ && !p.features().is_empty()
+ && features_enabled(p, enabled_features)
+ && !enabled.contains(&p.id)
+ {
enabled.push(p.id.clone());
}
}
@@ -752,7 +916,7 @@ mod tests {
)
.unwrap();
let packs = discover(&dir);
- let enabled = enabled_ids(&dir, &packs);
+ let enabled = enabled_ids(&dir, &packs, &[]);
assert_eq!(enabled, ["file/b", "file/a"]);
let enabled: Vec<&DataPack> = enabled
.iter()
diff --git a/crates/horizon-client-render/src/dynamic_light.rs b/crates/horizon-client-render/src/dynamic_light.rs
index 4502b33566..17332512ec 100644
--- a/crates/horizon-client-render/src/dynamic_light.rs
+++ b/crates/horizon-client-render/src/dynamic_light.rs
@@ -13,6 +13,37 @@ use std::collections::HashMap;
/// Lights the shaders read: `Globals.dynamic_lights` in gfx.rs, terrain.wgsl and entity.wgsl.
pub const MAX: usize = 16;
+/// Camera-relative position and radius; RGB intensity in a second vec4.
+pub type Colored = [[f32; 4]; 2];
+
+/// Nearest validated native visual lights; never enters gameplay lighting.
+pub fn select_colored(
+ sources: impl Iterator- ,
+ camera: DVec3,
+) -> Vec
{
+ let mut nearest: Vec<_> = sources.collect();
+ nearest.sort_by(|a, b| {
+ (DVec3::from(a.0) - camera)
+ .length_squared()
+ .total_cmp(&(DVec3::from(b.0) - camera).length_squared())
+ });
+ nearest
+ .into_iter()
+ .take(MAX)
+ .map(|(at, rgb, radius, intensity)| {
+ let at = (DVec3::from(at) - camera).as_vec3();
+ [
+ [at.x, at.y, at.z, radius],
+ [
+ ((rgb >> 16) & 255) as f32 / 255.0,
+ ((rgb >> 8) & 255) as f32 / 255.0,
+ (rgb & 255) as f32 / 255.0,
+ intensity,
+ ],
+ ]
+ })
+ .collect()
+}
/// What an entity on fire gives off, as a lit campfire or fire block does.
pub const BURNING: u8 = 15;
/// Sources farther than this from the camera light nothing it can see well.
@@ -87,6 +118,17 @@ pub fn level_at(pos: [f32; 3], lights: &[[f32; 4]]) -> f32 {
mod tests {
use super::*;
+ #[test]
+ fn colored_sources_are_camera_relative_and_bounded() {
+ let lights = select_colored(
+ (0..20).map(|x| ([x as f64, 64.0, 0.0], 0xff8040, 5.0, 0.5)),
+ DVec3::new(2.0, 64.0, 0.0),
+ );
+ assert_eq!(lights.len(), MAX);
+ assert_eq!(lights[0][0][0], 0.0);
+ assert_eq!(lights[0][1], [1.0, 128.0 / 255.0, 64.0 / 255.0, 0.5]);
+ }
+
#[test]
fn nearest_sources_win() {
let camera = DVec3::new(100.0, 64.0, 100.0);
diff --git a/crates/horizon-client-render/src/effects.rs b/crates/horizon-client-render/src/effects.rs
index ce604a3fa0..a97243b349 100644
--- a/crates/horizon-client-render/src/effects.rs
+++ b/crates/horizon-client-render/src/effects.rs
@@ -235,10 +235,12 @@ impl PlayerEffects {
i8::from(e.ambient) | i8::from(e.visible) << 1 | 4
}
/// `LivingEntity.tickEffects` (server part) with the omens' effect ticks:
- /// `omen` answers `BadOmenMobEffect` (every tick) and `RaidOmenMobEffect`
- /// (on its last tick); `absorption_empty` is `AbsorptionMobEffect`'s
- /// answer: the effect ends once the absorption is used up. Returns the
- /// actions to apply, in order, and whether Raid Omen was newly added.
+ /// `omen` answers `BadOmenMobEffect` (every tick), `RaidOmenMobEffect`
+ /// (on its last tick) and a content mod's effect (every tick, with the
+ /// tick count `shouldApplyEffectTickThisTick` takes); `absorption_empty`
+ /// is `AbsorptionMobEffect`'s answer: the effect ends once the
+ /// absorption is used up. Returns the actions to apply, in order, and
+ /// whether Raid Omen was newly added.
///
/// Bad Omen turning into a new Raid Omen adds an entry to the map being
/// iterated: vanilla's iterator then throws a
@@ -247,7 +249,7 @@ impl PlayerEffects {
/// ticked; the next tick refreshes Raid Omen and drops Bad Omen.
pub fn tick_with(
&mut self,
- omen: &mut dyn FnMut(&EffectInstance) -> OmenTick,
+ omen: &mut dyn FnMut(&EffectInstance, i32) -> OmenTick,
absorption_empty: bool,
) -> (Vec, bool) {
self.tick_count = self.tick_count.wrapping_add(1);
@@ -269,8 +271,9 @@ impl PlayerEffects {
actions.push(a);
}
let answer = match e.id {
- "bad_omen" => omen(e),
- "raid_omen" if t == 1 => omen(e),
+ "bad_omen" => omen(e, t),
+ "raid_omen" if t == 1 => omen(e, t),
+ id if potions::is_mod_effect(id) => omen(e, t),
"absorption" if absorption_empty => OmenTick::Remove,
_ => OmenTick::Continue,
};
@@ -508,7 +511,7 @@ mod tests {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("bad_omen", 100, 1));
fx.add(EffectInstance::new("speed", 50, 0));
- let mut convert = |e: &EffectInstance| match e.id {
+ let mut convert = |e: &EffectInstance, _: i32| match e.id {
"bad_omen" => OmenTick::Add(EffectInstance::new("raid_omen", 600, e.amplifier)),
_ => OmenTick::Continue,
};
@@ -550,12 +553,39 @@ mod tests {
.is_empty()
);
}
+ /// A content mod's effect answers each tick before it counts down (an
+ /// infinite one with the tick count), and goes at once on Remove.
+ #[test]
+ fn a_mod_effect_answers_before_it_counts_down() {
+ let mut fx = PlayerEffects::default();
+ fx.add(EffectInstance::new("ruby:glow", 3, 1));
+ fx.add(EffectInstance::new("ruby:bleed", -1, 0));
+ let mut seen = Vec::new();
+ let mut answer = |e: &EffectInstance, t: i32| {
+ seen.push((e.id, t));
+ match (e.id, t) {
+ ("ruby:glow", 2) => OmenTick::Remove,
+ _ => OmenTick::Continue,
+ }
+ };
+ fx.tick_with(&mut answer, false);
+ fx.tick_with(&mut answer, false);
+ let ticks = [
+ ("ruby:glow", 3),
+ ("ruby:bleed", 1),
+ ("ruby:glow", 2),
+ ("ruby:bleed", 2),
+ ];
+ assert_eq!(seen, ticks);
+ assert!(fx.get("ruby:glow").is_none());
+ assert_eq!(fx.get("ruby:bleed").unwrap().duration, -1);
+ }
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("raid_omen", 2, 0));
let mut seen = Vec::new();
- let mut watch = |e: &EffectInstance| {
+ let mut watch = |e: &EffectInstance, _: i32| {
seen.push(e.duration);
OmenTick::Remove
};
diff --git a/crates/horizon-client-render/src/entity.wgsl b/crates/horizon-client-render/src/entity.wgsl
index 7392ac68d4..d612d82a2a 100644
--- a/crates/horizon-client-render/src/entity.wgsl
+++ b/crates/horizon-client-render/src/entity.wgsl
@@ -22,6 +22,10 @@ struct Globals {
outline_params: vec4,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array, 16>,
+ colored_lights: array, 2>, 16>,
+ colored_count: vec4,
+ emissive_layers: array, 32>,
+ emissive_count: vec4,
sunrise_fan: array, 32>,
filtering: vec4,
}
@@ -90,6 +94,15 @@ fn dynamic_block_light(pos: vec3) -> f32 {
}
return level * 16.0;
}
+fn native_colored_light(pos: vec3) -> vec3 {
+ var rgb = vec3(0.0);
+ for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
+ let at = globals.colored_lights[i][0];
+ let color = globals.colored_lights[i][1];
+ rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
+ }
+ return min(rgb, vec3(1.0));
+}
fn entity_vertex(position: vec3, uv: vec2,
overlay: u32, color: vec4, light: vec4, normal: vec4) -> Out {
var out: Out;
@@ -103,6 +116,9 @@ fn entity_vertex(position: vec3, uv: vec2,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
+ if globals.colored_count.x > 0u && !unlit {
+ out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
+ }
// Overlay 6 (beacon beams): the light bytes carry the sprite's v range; 11 and 12
// (cracks): the destroy stage sprite's origin.
out.wrap = vec2(f32(light.x | (light.y << 8u)), f32(light.z | (light.w << 8u))) / 65535.0;
diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs
index a167468655..2f0f3a3b61 100644
--- a/crates/horizon-client-render/src/gfx.rs
+++ b/crates/horizon-client-render/src/gfx.rs
@@ -69,6 +69,15 @@ pub fn outline_vertices(edges: &[[f64; 6]], camera: glam::DVec3) -> Vec<[f32; 6]
.collect()
}
+/// Whether the renderer should initialize its GPU occlusion pass.
+///
+/// Software adapters spend more time running the culling pass than drawing
+/// the sections it removes. Keep the pass available for explicit A/B checks,
+/// while leaving hardware adapters on the existing default.
+fn gpu_culling_requested(info: &wgpu::AdapterInfo, env: Option<&str>) -> bool {
+ env != Some("0") && (env.is_some() || info.device_type != wgpu::DeviceType::Cpu)
+}
+
mod animate;
mod arena;
pub mod compositor;
@@ -91,9 +100,11 @@ mod pack_atlas;
pub mod pack_draw;
mod pack_entities;
mod pack_hand;
+mod pack_images;
mod pack_post;
mod pack_shadow;
mod pack_sky;
+mod pack_textures;
mod pack_timing;
pub mod pack_uniforms;
mod pack_weather;
@@ -225,12 +236,23 @@ struct Globals {
outline_params: [f32; 4],
/// Horizon Extras' dynamic lights: camera-relative position and level (`dynamic_light`).
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
+ colored_lights: [crate::dynamic_light::Colored; crate::dynamic_light::MAX],
+ colored_count: [u32; 4],
+ emissive_layers: [[u32; 4]; crate::native_emissive::MAX],
+ emissive_count: [u32; 4],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
filtering: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
+/// The water mirror's targets: the world as a camera mirrored about the water plane sees
+/// it, at half the world's resolution (the material stage's `reflection`).
+struct MirrorTarget {
+ size: [u32; 2],
+ color: wgpu::TextureView,
+ depth: wgpu::TextureView,
+}
struct SectionGpu {
/// Arena page and first vertex in it: solid quads, then translucent ones.
page: u32,
@@ -293,6 +315,10 @@ pub struct Frame<'a> {
/// `Camera.depthFar`: the far plane (`crate::camera::depth_far`).
pub depth_far: f32,
pub underwater: bool,
+ /// The y of the horizontal water plane around the camera, in world blocks, while one
+ /// of Horizon's own packs reads the water mirror (`Game::water_plane`,
+ /// `Renderer::wants_water_plane`).
+ pub water_plane: Option,
/// Skip sections the camera cannot see through open faces (vanilla's
/// occlusion graph; off in spectator-like views inside blocks).
pub cave_culling: bool,
@@ -347,6 +373,8 @@ pub struct Frame<'a> {
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
pub dynamic_lights: &'a [[f32; 4]],
+ pub colored_lights: &'a [crate::dynamic_light::Colored],
+ pub emissive_layers: &'a [[u32; 4]],
/// Cloud mesh inputs; geometry and GPU storage are retained by the renderer.
pub clouds: Option>,
/// Retained lightning bolts, drawn before the other additive entities.
@@ -487,6 +515,12 @@ pub struct Renderer {
/// (`crosshair_globals`).
crosshair_group: wgpu::BindGroup,
crosshair_globals: wgpu::Buffer,
+ /// The water mirror: its solid terrain pipeline, its camera's globals and their group,
+ /// and its targets once drawn.
+ solid_mirror: wgpu::RenderPipeline,
+ mirror_globals: wgpu::Buffer,
+ mirror_group: wgpu::BindGroup,
+ mirror: Option,
entity_pipeline: wgpu::RenderPipeline,
hdr_mark_pipeline: wgpu::RenderPipeline,
hdr_mark_lightning_pipeline: wgpu::RenderPipeline,
@@ -1191,7 +1225,9 @@ async fn open_backend(
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
- // stand-in neither clamps nor drops NaN.
+ // stand-in neither clamps nor drops NaN. Subgroups: shader packs' compute programs
+ // (`MC_GL_KHR_shader_subgroup_*`); storage buffers in vertex shaders, clearing and
+ // atomics on images: their buffers and images.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
@@ -1200,7 +1236,11 @@ async fn open_backend(
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
- | wgpu::Features::RG11B10UFLOAT_RENDERABLE));
+ | wgpu::Features::RG11B10UFLOAT_RENDERABLE
+ | wgpu::Features::SUBGROUP
+ | wgpu::Features::VERTEX_WRITABLE_STORAGE
+ | wgpu::Features::CLEAR_TEXTURE
+ | wgpu::Features::TEXTURE_ATOMIC));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
@@ -1624,6 +1664,19 @@ impl Renderer {
true,
None,
);
+ // The water mirror's camera sees the world from the other side of the plane, which
+ // flips the winding: its solid terrain culls the front.
+ let solid_mirror = pipeline(
+ "Terrain solid (mirrored)",
+ "vs_terrain",
+ "fs_solid_mirror",
+ &terrain_buffers,
+ tri,
+ Some(wgpu::Face::Front),
+ true,
+ true,
+ None,
+ );
let translucent = pipeline(
"Terrain translucent",
"vs_terrain",
@@ -1720,6 +1773,12 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
+ let mirror_globals = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Water mirror globals"),
+ size: std::mem::size_of::() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
let prepared = prepared.unwrap_or_else(|| {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
@@ -1832,6 +1891,16 @@ impl Renderer {
&lightmap_view,
&lightmap_sampler,
);
+ let mirror_group = terrain_group(
+ &device,
+ "Water mirror resources",
+ &bindings,
+ &mirror_globals,
+ &sprite_views,
+ &sprite_sampler,
+ &lightmap_view,
+ &lightmap_sampler,
+ );
let index_quads = 1 << 16;
let indices = Self::quad_indices(&device, index_quads);
let instance_capacity = 4096;
@@ -2780,7 +2849,7 @@ impl Renderer {
&bindings,
arena.page_vertices(),
min_sections,
- |layout, label, fragment, blend| {
+ |layout, label, fragment, blend, cull| {
pipeline_in(
layout,
label,
@@ -2788,7 +2857,7 @@ impl Renderer {
fragment,
&buffers,
tri,
- Some(wgpu::Face::Back),
+ cull,
true,
true,
blend,
@@ -2822,8 +2891,9 @@ impl Renderer {
// frame however few its draws and little it hides, `shader` tests in
// the shader as on Metal (A/B checks).
let cull_env = std::env::var("HORIZON_GPU_CULL");
+ let adapter_info = adapter.get_info();
let cull = cull::Cull::supported(&adapter, &device)
- .filter(|_| cull_env.as_deref() != Ok("0"))
+ .filter(|_| gpu_culling_requested(&adapter_info, cull_env.as_deref().ok()))
.map(|depth_test| match cull_env.as_deref() {
Ok("shader") => cull::DepthTest::InShader,
_ => depth_test,
@@ -2832,7 +2902,7 @@ impl Renderer {
let mut cull =
cull::Cull::new(&device, config.format.remove_srgb_suffix(), depth_test);
cull.always = cull_env.as_deref() == Ok("all");
- if !cull.always && adapter.get_info().backend == wgpu::Backend::Metal {
+ if !cull.always && adapter_info.backend == wgpu::Backend::Metal {
cull.min_sections = cull::TILED_MIN_SECTIONS;
}
cull
@@ -2901,6 +2971,10 @@ impl Renderer {
debug_line_capacity: 4096,
crosshair_group,
crosshair_globals,
+ solid_mirror,
+ mirror_globals,
+ mirror_group,
+ mirror: None,
entity_pipeline,
hdr_mark_pipeline,
hdr_mark_lightning_pipeline,
@@ -3209,6 +3283,11 @@ impl Renderer {
self.flat_on = on;
Ok(())
}
+ /// Whether one of Horizon's own packs reads the water mirror, which needs the frame's
+ /// `water_plane`.
+ pub fn wants_water_plane(&self) -> bool {
+ self.native.as_ref().is_some_and(|n| n.frame.reflects)
+ }
/// Whether solid quads facing away from the eye are left out.
pub fn section_sides(&self) -> bool {
self.sides_on
@@ -3944,6 +4023,23 @@ impl Renderer {
.map(pack_timing::PassTiming::medians)
.unwrap_or_default()
}
+ /// The macros of the GL extensions this device gives shader packs: subgroups, when it
+ /// has them.
+ pub fn define_pack_macros(&self, macros: &mut horizon_shaders::Macros) {
+ if self.device.features().contains(wgpu::Features::SUBGROUP) {
+ for ext in [
+ "basic",
+ "vote",
+ "arithmetic",
+ "ballot",
+ "shuffle",
+ "shuffle_relative",
+ ] {
+ macros.define(format!("MC_GL_KHR_shader_subgroup_{ext}"), "");
+ }
+ }
+ }
+
/// 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)
@@ -4106,8 +4202,26 @@ impl Renderer {
}
pub fn render(&mut self, frame: &Frame) -> Result {
let rendered = self.draw_frame(frame);
- // A frame that was not drawn still submits the section copies.
- if let Some(copies) = self.take_section_copies() {
+ // A frame that was not drawn still submits the section copies and
+ // atlas patches (glyphs, skins, maps), which the caller already took.
+ if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
+ let section_copies = self.section_copies.take();
+ let has_section_copies = section_copies.is_some();
+ let mut encoder = section_copies.unwrap_or_else(|| {
+ self.device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor {
+ label: Some("Horizon atlas patches"),
+ })
+ });
+ self.hud
+ .upload_patches(&mut self.uploads, &mut encoder, &frame.hud.patches);
+ self.uploads.finish(&encoder);
+ if has_section_copies {
+ self.section_copy_bytes = 0;
+ self.section_uploads.finish(&encoder);
+ }
+ self.queue.submit([encoder.finish()]);
+ } else if let Some(copies) = self.take_section_copies() {
self.queue.submit([copies]);
}
rendered
@@ -4318,6 +4432,32 @@ impl Renderer {
}
lights
},
+ colored_lights: {
+ let mut lights = [[[0.0; 4]; 2]; crate::dynamic_light::MAX];
+ for (to, from) in lights.iter_mut().zip(frame.colored_lights) {
+ *to = *from;
+ }
+ lights
+ },
+ colored_count: [
+ frame.colored_lights.len().min(crate::dynamic_light::MAX) as u32,
+ 0,
+ 0,
+ 0,
+ ],
+ emissive_layers: {
+ let mut layers = [[0; 4]; crate::native_emissive::MAX];
+ for (to, from) in layers.iter_mut().zip(frame.emissive_layers) {
+ *to = *from;
+ }
+ layers
+ },
+ emissive_count: [
+ frame.emissive_layers.len().min(crate::native_emissive::MAX) as u32,
+ 0,
+ 0,
+ 0,
+ ],
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
filtering: [
self.texture_filtering as f32,
@@ -4366,9 +4506,24 @@ impl Renderer {
};
let camera = frame.camera;
let camera_section = camera_section(camera, self.layout.min_y());
+ // The water mirror draws the same lists: they take in what its camera sees too
+ // (above the screen's top, the far shore, the trees against the sky).
+ let mirror_plane = frame
+ .water_plane
+ .filter(|_| !frame.title && self.wants_water_plane());
+ let mirror = mirror_plane.map(|plane| Frustum {
+ planes: frustum(mirror_view_projection(
+ cull_proj,
+ plane - frame.camera.y as f32,
+ )),
+ });
// Solid quads facing away from the eye are left out: the bob moves
- // the eye off `camera`, so it is found from the projection.
+ // the eye off `camera`, so it is found from the projection. With the mirror, those
+ // facing its eye are kept too: it sits straight below, at this camera-relative y.
let eye = projection_center(cull_proj).filter(|_| self.sides_on);
+ let mirror_low = mirror_plane
+ .zip(eye)
+ .map(|(plane, eye)| 2.0 * (plane - frame.camera.y as f32) - eye.y);
let culling = !frame.title && frame.cave_culling;
if culling {
crate::spans::time("occlusion", || {
@@ -4390,7 +4545,7 @@ impl Renderer {
self.sections.visible(
camera,
(frame.detail_distance / 16.0) as i32,
- &view,
+ &Frusta(view, mirror),
|k| !culling || reachable.contains(k),
|x, z| {
(!cutout.is_empty() && cutout.contains(&(x, z)))
@@ -4521,6 +4676,8 @@ impl Renderer {
&& !frame.title
&& !pack_terrain
&& self.multi_draw
+ // Occlusion is found from the camera, not the mirror's eye.
+ && mirror.is_none()
&& visible.len() >= c.min_sections
})
.and_then(|cull| cull.begin(&self.device).then_some(cull));
@@ -4539,7 +4696,7 @@ impl Renderer {
let start = draws.len();
let instances = u32::from(first_phase);
let mut indices = 0;
- facing_runs(&s.sides, eye, v.origin, |first, quads| {
+ facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
indices += quads * 6;
let base = s.start as u32 + first * 4;
draws.push([quads * 6, instances, 0, base, i as u32]);
@@ -4582,7 +4739,7 @@ impl Renderer {
let s = self.lods[&v.key].drawn();
let start = self.draws.len();
let instance = (visible.len() + i) as u32;
- facing_runs(&s.sides, eye, v.origin, |first, quads| {
+ facing_runs_from(&s.sides, eye, mirror_low, v.origin, |first, quads| {
let base = s.start as u32 + first * 4;
self.draws.push([quads * 6, 1, 0, base, instance]);
self.draw_pages.push((s.page, None));
@@ -5259,7 +5416,18 @@ impl Renderer {
}
let oit = self.oit.as_ref().filter(|_| oit_on);
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
- let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
+ let pack_sky_clear = pack_draws_sky
+ .then(|| self.pack_post.as_ref().and_then(|p| p.1.as_ref()))
+ .flatten()
+ .and_then(|p| p.sky_clear());
+ let clear = if let Some([r, g, b, a]) = pack_sky_clear {
+ wgpu::Color {
+ r: f64::from(r),
+ g: f64::from(g),
+ b: f64::from(b),
+ a: f64::from(a),
+ }
+ } else if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
} else {
wgpu::Color {
@@ -5321,6 +5489,118 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
sky.draw(&mut pass, &native.frame);
}
+ // The water mirror, for one of Horizon's own packs that reads it: the world as a
+ // camera mirrored about the water plane sees it, into a half-resolution target the
+ // material stage samples as `reflection`.
+ let mut reflection = None;
+ if let Some(plane) = mirror_plane {
+ let world = self
+ .world_post
+ .size()
+ .filter(|_| scaled.is_some())
+ .unwrap_or([self.config.width, self.config.height]);
+ let size = native_post::reflection_size(world);
+ if self.mirror.as_ref().is_none_or(|m| m.size != size) {
+ let target = |label: &'static str, format, usage| {
+ self.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,
+ view_formats: &[],
+ })
+ .create_view(&Default::default())
+ };
+ self.mirror = Some(MirrorTarget {
+ size,
+ color: target(
+ "Water mirror",
+ self.config.format.remove_srgb_suffix(),
+ wgpu::TextureUsages::RENDER_ATTACHMENT
+ | wgpu::TextureUsages::TEXTURE_BINDING,
+ ),
+ depth: target(
+ "Water mirror depth",
+ wgpu::TextureFormat::Depth32Float,
+ wgpu::TextureUsages::RENDER_ATTACHMENT,
+ ),
+ });
+ }
+ if let Some(mirror) = &self.mirror {
+ // Mirrored about y = `plane` (camera-relative).
+ let plane = plane - frame.camera.y as f32;
+ let inverse_view = mirror_inverse_view(globals.inverse_view, plane);
+ // fog_ends.w: the plane, below which fs_solid_mirror cuts the world away
+ // (only entity.wgsl reads the glint strength there, and no entity draws here).
+ let mut fog_ends = globals.fog_ends;
+ fog_ends[3] = plane;
+ let mirror_globals = Globals {
+ view_proj: mirror_view_projection(view_proj, plane).to_cols_array_2d(),
+ inverse_view,
+ fog_ends,
+ ..globals
+ };
+ self.uploads.buffer(
+ &mut encoder,
+ &self.mirror_globals,
+ 0,
+ bytemuck::bytes_of(&mirror_globals),
+ );
+ let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("Water mirror"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &mirror.color,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(clear),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &mirror.depth,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(0.0),
+ store: wgpu::StoreOp::Discard,
+ }),
+ stencil_ops: None,
+ }),
+ ..Default::default()
+ });
+ pass.set_bind_group(0, &self.mirror_group, &[]);
+ if sky_visible && frame.skybox {
+ pass.set_pipeline(&self.sky);
+ pass.draw(0..3, 0..1);
+ }
+ // The solid terrain: occlusion culling is off with a mirror, so every draw
+ // is phase 1's.
+ if flat_frame && let Some(flat) = &self.flat {
+ pass.set_pipeline(&flat.solid_mirror);
+ let vertices = |p| self.arena.buffer(p);
+ flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices);
+ } else {
+ pass.set_pipeline(&self.solid_mirror);
+ pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
+ pass.set_vertex_buffer(1, self.instances.slice(..));
+ self.draw_sections(&mut pass, &self.indirect, 0, 0, solid_draws);
+ }
+ drop(pass);
+ reflection = Some(mirror.color.clone());
+ }
+ }
+ if let Some(native) = &mut self.native {
+ native
+ .frame
+ .set_reflection(&self.device, reflection.as_ref());
+ }
// The material stage's copies of the world before translucent terrain, the size of
// this frame's targets.
if let Some(native) = &mut self.native
@@ -6018,12 +6298,14 @@ impl Renderer {
&& let Some(post) = &mut native.post
{
let size = self.world_post.size().unwrap_or([1, 1]);
+ let history_reset = native.frame.take_history_reset();
post.run(
&self.device,
&mut encoder,
(color, depth),
&view_target,
size,
+ history_reset,
native.timing.as_mut(),
);
} else if scaled.is_some() {
@@ -6515,6 +6797,31 @@ pub(crate) fn square_downscale(pixels: &[u8], width: u32, height: u32, size: [u3
}
out
}
+/// The union of two frusta (the camera's and its water mirror's), for `Resident::visible`.
+#[derive(Clone, Copy, Debug)]
+pub struct Frusta(pub Frustum, pub Option);
+/// What `Resident::visible` culls with: a frustum or a union of them.
+pub trait Cull {
+ fn intersects(&self, min: Vec3, max: Vec3) -> bool;
+ fn contains(&self, min: Vec3, max: Vec3) -> bool;
+}
+impl Cull for Frustum {
+ fn intersects(&self, min: Vec3, max: Vec3) -> bool {
+ Frustum::intersects(self, min, max)
+ }
+ fn contains(&self, min: Vec3, max: Vec3) -> bool {
+ Frustum::contains(self, min, max)
+ }
+}
+impl Cull for Frusta {
+ fn intersects(&self, min: Vec3, max: Vec3) -> bool {
+ self.0.intersects(min, max) || self.1.is_some_and(|f| f.intersects(min, max))
+ }
+ /// Entirely inside one of them is entirely inside the union.
+ fn contains(&self, min: Vec3, max: Vec3) -> bool {
+ self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
+ }
+}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -6563,6 +6870,24 @@ fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
}
+/// `view_proj` mirrored about the horizontal plane at the camera-relative height `plane`:
+/// with F the mirror (y flips and moves to 2 * plane), `view_proj * F` negates column 1
+/// and moves column 3 by 2 * plane times column 1.
+///
+/// `mirror_inverse_view` is the inverse's other side: F * `inverse_view`.
+fn mirror_view_projection(view_proj: Mat4, plane: f32) -> Mat4 {
+ let mut m = view_proj.to_cols_array_2d();
+ let col1 = m[1];
+ m[1] = col1.map(|v| -v);
+ m[3] = std::array::from_fn(|i| m[3][i] + 2.0 * plane * col1[i]);
+ Mat4::from_cols_array_2d(&m)
+}
+
+/// The mirror's `inverse_view` (the sky's rays): what the view sees, mirrored about the
+/// horizontal plane at the camera-relative height `plane`, so F * `inverse_view`.
+fn mirror_inverse_view(inverse_view: [[f32; 4]; 4], plane: f32) -> [[f32; 4]; 4] {
+ inverse_view.map(|[x, y, z, w]| [x, 2.0 * plane * w - y, z, w])
+}
/// Where a perspective `view_proj` sees from, in its own (camera-relative)
/// space: the point whose clip x, y and w are all 0. `None` for a
/// projection without one (orthographic).
@@ -6584,9 +6909,24 @@ fn projection_center(view_proj: Mat4) -> Option {
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
/// `origin`; all of them when there is no eye.
-fn facing_runs(sides: &Sides, eye: Option, origin: [f32; 3], mut draw: impl FnMut(u32, u32)) {
+fn facing_runs(sides: &Sides, eye: Option, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
+ facing_runs_from(sides, eye, None, origin, draw)
+}
+/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
+/// camera-relative height (the water mirror's eye).
+fn facing_runs_from(
+ sides: &Sides,
+ eye: Option,
+ low: Option,
+ origin: [f32; 3],
+ mut draw: impl FnMut(u32, u32),
+) {
match eye {
- Some(eye) => sides.visible((eye - Vec3::from(origin)).to_array(), draw),
+ Some(eye) => {
+ let eye = eye - Vec3::from(origin);
+ let low = low.map_or(eye.y, |y| (y - origin[1]).min(eye.y));
+ sides.visible_from(eye.to_array(), low, draw)
+ }
None if sides.quads() > 0 => draw(0, sides.quads()),
None => {}
}
@@ -6606,6 +6946,64 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
+ fn adapter(device_type: wgpu::DeviceType) -> wgpu::AdapterInfo {
+ wgpu::AdapterInfo {
+ name: "test adapter".into(),
+ vendor: 0,
+ device: 0,
+ device_type,
+ device_pci_bus_id: String::new(),
+ driver: String::new(),
+ driver_info: String::new(),
+ backend: wgpu::Backend::Vulkan,
+ subgroup_min_size: 0,
+ subgroup_max_size: 0,
+ transient_saves_memory: None,
+ limit_bucket: None,
+ }
+ }
+
+ #[test]
+ fn software_occlusion_culling_is_opt_in() {
+ let cpu = adapter(wgpu::DeviceType::Cpu);
+ let discrete = adapter(wgpu::DeviceType::DiscreteGpu);
+ assert!(!super::gpu_culling_requested(&cpu, None));
+ assert!(super::gpu_culling_requested(&discrete, None));
+ assert!(super::gpu_culling_requested(&cpu, Some("all")));
+ assert!(!super::gpu_culling_requested(&discrete, Some("0")));
+ }
+
+ #[test]
+ fn the_mirror_sees_each_point_where_its_reflection_is() {
+ use glam::{Mat4, Vec3, Vec4};
+ let view_proj = super::world_projection(70.0, 1.5, 512.0)
+ * Mat4::look_to_rh(Vec3::ZERO, Vec3::new(0.3, -0.4, -1.0), Vec3::Y);
+ let plane = -3.25;
+ let mirror = super::mirror_view_projection(view_proj, plane);
+ for p in [Vec3::new(1.0, 2.0, -9.0), Vec3::new(-4.0, -1.5, -20.0)] {
+ let reflected = Vec3::new(p.x, 2.0 * plane - p.y, p.z);
+ let a = mirror * Vec4::from((p, 1.0));
+ let b = view_proj * Vec4::from((reflected, 1.0));
+ assert!((a - b).abs().max_element() < 1e-4, "{a} {b}");
+ }
+ }
+ #[test]
+ fn the_mirrors_sky_rays_are_the_cameras_reflected() {
+ use glam::{Mat4, Vec3, Vec4};
+ // A camera looking down: its mirror looks up, by as much.
+ let forward = Vec3::new(0.3, -0.64, -1.0).normalize();
+ let view = Mat4::look_to_rh(Vec3::ZERO, forward, Vec3::Y);
+ let mirrored = super::mirror_inverse_view(view.inverse().to_cols_array_2d(), -3.25);
+ let ray = Mat4::from_cols_array_2d(&mirrored) * Vec4::new(0.0, 0.0, -1.0, 0.0);
+ let reflected = Vec3::new(forward.x, -forward.y, forward.z);
+ assert!((ray.truncate() - reflected).length() < 1e-5, "{ray}");
+ // The view's origin, the camera, goes to the mirror's eye.
+ let eye = Mat4::from_cols_array_2d(&mirrored) * Vec4::W;
+ assert!(
+ (eye.truncate() - Vec3::new(0.0, -6.5, 0.0)).length() < 1e-5,
+ "{eye}"
+ );
+ }
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
diff --git a/crates/horizon-client-render/src/gfx/flat.rs b/crates/horizon-client-render/src/gfx/flat.rs
index ef905abd55..fbe7b14efa 100644
--- a/crates/horizon-client-render/src/gfx/flat.rs
+++ b/crates/horizon-client-render/src/gfx/flat.rs
@@ -63,6 +63,8 @@ pub struct Call {
pub struct Flat {
pub solid: wgpu::RenderPipeline,
+ /// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
+ pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
@@ -127,7 +129,7 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
}
/// Whether frames without a shader pack's terrain draw flat: where the backend
-/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11's Vulkan
+/// emulates multi-draw (Metal, past `MIN_SECTIONS`), on Intel Gen11/Gen12's Vulkan
/// driver, or with `HORIZON_FLAT_DRAWS=1`. Elsewhere per-section draws stay.
pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
@@ -135,15 +137,34 @@ pub fn for_vanilla(info: &wgpu::AdapterInfo) -> bool {
|| is_broken_intel_vulkan(info)
}
-/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
-/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
-/// and flicker.
+/// Intel Gen11 (Ice Lake) and Gen12 (Tiger Lake, Rocket Lake, Alder Lake,
+/// Raptor Lake) ANV Vulkan drivers have broken `first_instance` for
+/// instance-step vertex attributes (#3529, #4599), causing terrain sections to
+/// be drawn with another section's origin and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
- && (info.name.contains("ICL")
- || info.name.contains("Ice Lake")
- || (info.device >= 0x8a50 && info.device <= 0x8a71))
+ && (matches!(
+ info.device,
+ 0x8a50..=0x8a71 // Gen11: Ice Lake
+ | 0x9a40..=0x9a7f // Gen12: Tiger Lake
+ | 0x4c80..=0x4c9a // Gen12: Rocket Lake
+ | 0x4600..=0x46ff // Gen12: Alder Lake (incl. 0x46a8 in #4599)
+ | 0xa700..=0xa7ff // Gen12: Raptor Lake
+ ) || [
+ "ICL",
+ "Ice Lake",
+ "TGL",
+ "Tiger Lake",
+ "RKL",
+ "Rocket Lake",
+ "ADL",
+ "Alder Lake",
+ "RPL",
+ "Raptor Lake",
+ ]
+ .iter()
+ .any(|tag| info.name.contains(tag)))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -265,7 +286,7 @@ fn plan(
}
impl Flat {
- /// `pipeline(layout, label, vertex entry, fragment entry, blend)` builds a
+ /// `pipeline(layout, label, fragment entry, blend, cull)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer.
pub fn new(
device: &wgpu::Device,
@@ -277,6 +298,7 @@ impl Flat {
&str,
&str,
Option,
+ Option,
) -> wgpu::RenderPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
@@ -330,13 +352,23 @@ impl Flat {
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
+ let back = Some(wgpu::Face::Back);
Self {
- solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
+ solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None, back),
+ // The mirror flips the winding: its solid terrain culls the front.
+ solid_mirror: pipeline(
+ &layout,
+ "Terrain solid mirrored (flat)",
+ "fs_solid_mirror",
+ None,
+ Some(wgpu::Face::Front),
+ ),
translucent: pipeline(
&layout,
"Terrain translucent (flat)",
"fs_translucent",
Some(wgpu::BlendState::ALPHA_BLENDING),
+ back,
),
flatten,
compute_layout,
diff --git a/crates/horizon-client-render/src/gfx/native_material.rs b/crates/horizon-client-render/src/gfx/native_material.rs
index 75a3f6da53..fa6b7da5bd 100644
--- a/crates/horizon-client-render/src/gfx/native_material.rs
+++ b/crates/horizon-client-render/src/gfx/native_material.rs
@@ -28,6 +28,9 @@ struct HorizonMaterialOut {
@location(5) position: vec3,
@location(6) spherical: f32,
@location(7) cylindrical: f32,
+ @location(8) @interpolate(flat) face: u32,
+ @location(9) @interpolate(flat) fluid_quad: u32,
+ @location(10) @interpolate(flat) emission: f32,
}
fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color: vec4,
light: vec2, origin: vec3) -> HorizonMaterialOut {
@@ -45,6 +48,19 @@ fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color
out.position = pos;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
+ // The colour's alpha byte (mesher.rs's `fluid_face` and `emitting`): a fluid quad's
+ // high nibble is 8 + its face (0..5), every other quad keeps 0xF there; the low nibble
+ // is 15 minus the face's HDR tier. Every corner of a quad carries the same byte, so the
+ // interpolated value rounds back exactly.
+ let flags = u32(round(color.a * 255.0));
+ let high = flags >> 4u;
+ let fluid = high >= 8u && (high & 7u) < 6u;
+ out.face = select(0u, high & 7u, fluid);
+ out.fluid_quad = u32(fluid);
+ // The tier (0 none, 1 low, 2 incandescent, 3 bright, 4 very bright) as how strongly the
+ // face shines: a redstone torch 7/15, a torch 14/15, glowstone 1.
+ let tier = 15u - (flags & 15u);
+ out.emission = select(min(f32(tier) * 7.0 / 15.0, 1.0), 0.0, tier == 0u);
return out;
}
@vertex fn horizon_material_vs(
@@ -65,9 +81,13 @@ fn horizon_material_vertex(position: vec3, uv: vec2, layer: u32, color
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4 {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
+ // Water draws translucent and lava solid, so the pass resolves the fluid kind.
+ let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
+ let normal = horizon_material_normal(in.position);
+ let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
- translucent, horizon_sprite_tags(in.layer & 0x7fffu),
- in.clip.xy / frame.resolution)).color;
+ translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
+ in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4 {
let color = horizon_material_color(in, false);
@@ -320,7 +340,10 @@ mod tests {
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
let t = vec2(input.screen * frame.resolution);\n \
let behind = textureLoad(opaque_color, t, 0) * textureLoad(opaque_depth, t, 0);\n \
- return Surface(apply_fog(sprite(lit)) + behind * 0.0);\n}\n";
+ let mirrored = textureSampleLevel(reflection, reflection_sampler, input.screen, 0.0);\n \
+ let kind = f32(input.face + input.fluid) * input.emission * mirrored.a;\n \
+ let frame_of = vec4(cross(input.normal, input.tangent.xyz) * input.tangent.w, kind);\n \
+ return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
diff --git a/crates/horizon-client-render/src/gfx/native_pack.rs b/crates/horizon-client-render/src/gfx/native_pack.rs
index 4fb5cdc59c..33a86f16a9 100644
--- a/crates/horizon-client-render/src/gfx/native_pack.rs
+++ b/crates/horizon-client-render/src/gfx/native_pack.rs
@@ -29,7 +29,20 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec), String> {
let (globals, format) = (terrain.globals, terrain.format);
- let frame = NativeFrame::new(device);
+ let mut frame = NativeFrame::new(device);
+ // The mirror is drawn for a material that reads it, while `[reflection] option` is on.
+ let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
+ frame.reflects = report
+ .stages
+ .iter()
+ .find(|s| s.kind == StageKind::Material)
+ .is_some_and(|s| s.reads("reflection"))
+ && option.is_none_or(|id| {
+ constants
+ .iter()
+ .find(|(k, _)| k == id)
+ .is_none_or(|(_, v)| *v != 0.0)
+ });
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
diff --git a/crates/horizon-client-render/src/gfx/native_post.rs b/crates/horizon-client-render/src/gfx/native_post.rs
index 5f7f798dc3..9bfb71658c 100644
--- a/crates/horizon-client-render/src/gfx/native_post.rs
+++ b/crates/horizon-client-render/src/gfx/native_post.rs
@@ -24,8 +24,14 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
- /// One bind group per pass, for these world views.
- groups: Option<(WorldViews, Vec)>,
+ /// Last frame's input to the last pass (`history`, binding 8), made with the targets,
+ /// and a black 1×1 in its place before then.
+ history: Option,
+ history_blank: wgpu::Texture,
+ /// One bind group per pass, for these world views and either the valid history or black.
+ groups: Option<(WorldViews, bool, Vec)>,
+ /// Whether `history` contains the last frame's input to the last pass.
+ history_valid: bool,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -43,11 +49,21 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
+ /// The material stage's `reflection` (binding 1): this frame's water mirror, else
+ /// `blank`; and its sampler (binding 2).
+ reflection: wgpu::TextureView,
+ blank: wgpu::TextureView,
+ reflection_sampler: wgpu::Sampler,
+ /// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
+ /// is drawn and `has_reflection` stays 0.
+ pub reflects: bool,
/// Last frame's view-projection, camera and time (`write`).
last: Option<([[f32; 4]; 4], glam::DVec3, std::time::Instant)>,
/// The eased eye light (block, sky) and being in water and lava (`write`); NaN until the
/// first frame sets them.
eye: [f32; 4],
+ /// A post stage must use black history after a long gap until it has rendered a frame.
+ history_reset: bool,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
@@ -105,6 +121,11 @@ fn frame_entry(visibility: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
}
}
+/// The size of the water mirror for a world of `size`: half of it.
+pub fn reflection_size(size: [u32; 2]) -> [u32; 2] {
+ size.map(|s| s.div_ceil(2).max(1))
+}
+
/// Seconds between frames past which the last one is forgotten (`NativeFrame::write`).
const MAX_FRAME_GAP: f32 = 0.5;
@@ -130,21 +151,48 @@ impl NativeFrame {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
+ texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
+ sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
- let group = Self::group(device, &layout, &buffer, &opaque_depth, &opaque_color);
+ let blank = copy_target(
+ device,
+ "Native shader pack reflection blank",
+ [1, 1],
+ wgpu::TextureFormat::Rgba8Unorm,
+ )
+ .create_view(&Default::default());
+ let reflection_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("Native shader pack reflection sampler"),
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+ let group = Self::group(
+ device,
+ &layout,
+ &buffer,
+ (&blank, &reflection_sampler),
+ &opaque_depth,
+ &opaque_color,
+ );
Self {
buffer,
layout,
group,
opaque_depth,
opaque_color,
+ reflection: blank.clone(),
+ blank,
+ reflection_sampler,
+ reflects: false,
last: None,
eye: [f32::NAN; 4],
+ history_reset: true,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
@@ -155,6 +203,7 @@ impl NativeFrame {
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
+ (reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
@@ -166,6 +215,14 @@ impl NativeFrame {
binding: 0,
resource: buffer.as_entire_binding(),
},
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(reflection),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::Sampler(sampler),
+ },
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
@@ -208,10 +265,25 @@ impl NativeFrame {
if !depth_fits {
self.opaque_depth = depth_copy(device, size);
}
+ self.regroup(device);
+ }
+
+ /// Binds `view` as this frame's `reflection` (`None`: the blank). The mirror is drawn
+ /// before the stages read it.
+ pub fn set_reflection(&mut self, device: &wgpu::Device, view: Option<&wgpu::TextureView>) {
+ let view = view.unwrap_or(&self.blank);
+ if *view != self.reflection {
+ self.reflection = view.clone();
+ self.regroup(device);
+ }
+ }
+
+ fn regroup(&mut self, device: &wgpu::Device) {
self.group = Self::group(
device,
&self.layout,
&self.buffer,
+ (&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
);
@@ -225,6 +297,9 @@ impl NativeFrame {
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
inputs.wetness = self.wetness.0;
+ if !self.reflects {
+ inputs.has_reflection = 0;
+ }
let view_proj = (Mat4::from_cols_array_2d(&inputs.projection)
* Mat4::from_cols_array_2d(&inputs.view))
.to_cols_array_2d();
@@ -237,7 +312,10 @@ impl NativeFrame {
inputs.camera_motion = (frame.camera - camera).as_vec3().to_array();
inputs.delta_time = (now - time).as_secs_f32();
}
- _ => self.eye = [f32::NAN; 4],
+ _ => {
+ self.eye = [f32::NAN; 4];
+ self.history_reset = true;
+ }
}
self.last = Some((view_proj, frame.camera, now));
// The eye's light and medium eased by half-lives in seconds, from where they are at
@@ -259,6 +337,14 @@ impl NativeFrame {
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
+
+ /// Takes the post stage's pending history reset. It stays pending if post rendering is
+ /// skipped for a frame, so the first frame it does render still sees black history.
+ pub(super) fn take_history_reset(&mut self) -> bool {
+ let reset = self.history_reset;
+ self.history_reset = false;
+ reset
+ }
}
/// The option values of `constants` that `module` declares as overrides (wgpu refuses others).
@@ -320,6 +406,7 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
+ texture_entry(8, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -387,7 +474,10 @@ impl NativePost {
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
+ history: None,
+ history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
+ history_valid: false,
format,
})
}
@@ -402,6 +492,7 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
+ history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
@@ -417,8 +508,10 @@ impl NativePost {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
+ // COPY_SRC: the last pass's input is kept as next frame's `history`.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
- | wgpu::TextureUsages::TEXTURE_BINDING,
+ | wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
@@ -430,15 +523,28 @@ impl NativePost {
target("Native shader pack B"),
],
));
+ self.history = Some(history_target(device, "Native shader pack history", size));
+ self.history_valid = false;
self.groups = None;
}
+ let blank_history = history_reset || !self.history_valid;
if self
.groups
.as_ref()
- .is_some_and(|((color, depth), _)| color == world.0 && depth == world.1)
+ .is_some_and(|((color, depth), blank, _)| {
+ color == world.0 && depth == world.1 && *blank == blank_history
+ })
{
return;
}
+ let history = if blank_history {
+ &self.history_blank
+ } else {
+ self.history
+ .as_ref()
+ .expect("history exists after a post stage has more than one pass")
+ }
+ .create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
@@ -474,11 +580,15 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
+ wgpu::BindGroupEntry {
+ binding: 8,
+ resource: wgpu::BindingResource::TextureView(&history),
+ },
],
})
})
.collect();
- self.groups = Some(((world.0.clone(), world.1.clone()), groups));
+ self.groups = Some(((world.0.clone(), world.1.clone()), blank_history, groups));
}
/// Runs the passes over the world target `world` (colour, depth) of `size`, the last onto
@@ -490,13 +600,14 @@ impl NativePost {
world: (&wgpu::TextureView, &wgpu::TextureView),
screen: &wgpu::TextureView,
size: [u32; 2],
+ history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
- self.prepare(device, world, size);
+ self.prepare(device, world, size, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
- let Some((_, groups)) = &self.groups else {
+ let Some((_, _, groups)) = &self.groups else {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
@@ -523,9 +634,40 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
+ // The last pass's input becomes next frame's `history`, once that pass has read this
+ // frame's.
+ let count = self.passes.len();
+ if count > 1
+ && let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
+ {
+ encoder.copy_texture_to_texture(
+ targets[(count - 2) % 2].texture().as_image_copy(),
+ history.as_image_copy(),
+ history.size(),
+ );
+ self.history_valid = true;
+ }
}
}
+/// A target of `size` the post stage's `history` is copied into.
+fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::Texture {
+ device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: wgpu::Extent3d {
+ width: size[0].max(1),
+ height: size[1].max(1),
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: INTERMEDIATE,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ })
+}
+
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -603,6 +745,9 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
eye_light_smooth: [level(pack.eye_light[0]), level(pack.eye_light[1])],
in_water: f32::from(u8::from(pack.eye_in as u32 == 1)),
in_lava: f32::from(u8::from(pack.eye_in as u32 == 2)),
+ water_plane: frame.water_plane.unwrap_or(0.0),
+ has_reflection: u32::from(frame.water_plane.is_some()),
+ reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
}
}
@@ -723,7 +868,15 @@ mod tests {
..Default::default()
});
let view = screen.create_view(&Default::default());
- post.run(&device, &mut encoder, (&color, &depth), &view, [4, 4], None);
+ post.run(
+ &device,
+ &mut encoder,
+ (&color, &depth),
+ &view,
+ [4, 4],
+ true,
+ None,
+ );
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
@@ -754,4 +907,111 @@ mod tests {
"{pixel:?}"
);
}
+
+ /// `history` is black on the first frame, then what the pass before the last wrote the
+ /// frame before.
+ #[test]
+ fn the_last_pass_reads_last_frames_input_as_history() {
+ let Some((device, queue)) = device() else {
+ return;
+ };
+ let manifest = "schema = 1
+id = \"t\"
+name = \"T\"
+version = \"1\"
+api = \"0.1\"
+ [[post]]
+file = \"a.wgsl\"
+[[post]]
+file = \"b.wgsl\"
+";
+ let a = "fn post(input: PostInput) -> vec4 { return vec4(input.color.rgb * 0.5, 1.0); }
+";
+ let b = "fn post(input: PostInput) -> vec4 { return textureLoad(history, vec2(input.pixel), 0); }
+";
+ let files = [(MANIFEST, manifest), ("a.wgsl", a), ("b.wgsl", b)]
+ .map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
+ .into();
+ let report = check(&Pack::from_files(files).unwrap()).unwrap();
+ let format = wgpu::TextureFormat::Rgba8Unorm;
+ let frame = NativeFrame::new(&device);
+ let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
+ let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
+ let color = texture(&device, format, usage).create_view(&Default::default());
+ let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
+ .create_view(&Default::default());
+ let screen = texture(
+ &device,
+ format,
+ wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
+ );
+ let view = screen.create_view(&Default::default());
+ let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+ label: None,
+ size: u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT * 4),
+ usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+ let mut frame_pixel = |history_reset| {
+ let mut encoder = device.create_command_encoder(&Default::default());
+ encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: None,
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: &color,
+ resolve_target: None,
+ depth_slice: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ ..Default::default()
+ });
+ post.run(
+ &device,
+ &mut encoder,
+ (&color, &depth),
+ &view,
+ [4, 4],
+ history_reset,
+ None,
+ );
+ encoder.copy_texture_to_buffer(
+ screen.as_image_copy(),
+ wgpu::TexelCopyBufferInfo {
+ buffer: &buffer,
+ layout: wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT),
+ rows_per_image: Some(4),
+ },
+ },
+ screen.size(),
+ );
+ queue.submit([encoder.finish()]);
+ buffer
+ .slice(..)
+ .map_async(wgpu::MapMode::Read, |r| r.unwrap());
+ device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
+ let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
+ buffer.unmap();
+ pixel
+ };
+ assert_eq!(
+ frame_pixel(true)[..3],
+ [0, 0, 0],
+ "black on the first frame"
+ );
+ let second = frame_pixel(false);
+ assert!(
+ second[..3].iter().all(|&v| v.abs_diff(128) <= 1),
+ "{second:?}"
+ );
+ assert_eq!(frame_pixel(true)[..3], [0, 0, 0], "black after a frame gap");
+ let fourth = frame_pixel(false);
+ assert!(
+ fourth[..3].iter().all(|&v| v.abs_diff(128) <= 1),
+ "{fourth:?}"
+ );
+ }
}
diff --git a/crates/horizon-client-render/src/gfx/pack_hand.rs b/crates/horizon-client-render/src/gfx/pack_hand.rs
index 096cc5bcfe..1ecb458101 100644
--- a/crates/horizon-client-render/src/gfx/pack_hand.rs
+++ b/crates/horizon-client-render/src/gfx/pack_hand.rs
@@ -27,12 +27,11 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
inputs
}
-/// The hand's projection in GL's conventions: `hud_fov` degrees, the view bob, depth scaled by
-/// `MC_HAND_DEPTH`.
-pub fn projection(hud_fov: f32, aspect: f32, far: f32, bob: Mat4) -> Mat4 {
+/// The hand's projection in GL's conventions: `hud_fov` degrees, depth scaled by
+/// `MC_HAND_DEPTH` (the view bob is in `gbufferModelView`).
+pub fn projection(hud_fov: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::from_scale(glam::Vec3::new(1.0, 1.0, HAND_DEPTH))
* Mat4::perspective_rh_gl(hud_fov.to_radians(), aspect, 0.05, far)
- * bob
}
/// The hand's vertices (also read at set 1 for normals and tangents), its uniform and quad
@@ -153,7 +152,7 @@ mod tests {
/// apart by it), and x, y match the plain projection.
#[test]
fn hand_depth_is_scaled_around_the_middle() {
- let p = projection(70.0, 16.0 / 9.0, 512.0, Mat4::IDENTITY);
+ let p = projection(70.0, 16.0 / 9.0, 512.0);
let plain = Mat4::perspective_rh_gl(70f32.to_radians(), 16.0 / 9.0, 0.05, 512.0);
for z in [-0.06f32, -0.5, -3.0, -400.0] {
let v = glam::Vec4::new(0.1, -0.2, z, 1.0);
diff --git a/crates/horizon-client-render/src/gfx/pack_images.rs b/crates/horizon-client-render/src/gfx/pack_images.rs
new file mode 100644
index 0000000000..0c0838bdf9
--- /dev/null
+++ b/crates/horizon-client-render/src/gfx/pack_images.rs
@@ -0,0 +1,238 @@
+//! A shader pack's own images (`image.`) and storage buffers (`bufferObject.`) on the
+//! GPU: created at the render size they depend on, kept across frames, images cleared before a
+//! frame when the pack asks.
+
+use std::collections::BTreeMap;
+
+use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
+
+pub(super) struct PackImage {
+ pub(super) def: CustomImage,
+ pub(super) format: wgpu::TextureFormat,
+ pub(super) dimension: wgpu::TextureViewDimension,
+ pub(super) view: Option,
+ /// The render size the image was made for (relative images follow it).
+ made_for: [u32; 2],
+}
+
+impl PackImage {
+ pub(super) fn sample_type(&self, features: wgpu::Features) -> wgpu::TextureSampleType {
+ self.format
+ .sample_type(None, Some(features))
+ .unwrap_or(wgpu::TextureSampleType::Float { filterable: false })
+ }
+}
+
+/// The images `image.*` declares.
+pub(super) fn images(defs: Vec) -> Vec {
+ defs.into_iter()
+ .map(|def| PackImage {
+ format: format(def.format),
+ dimension: if def.is_3d() {
+ wgpu::TextureViewDimension::D3
+ } else {
+ wgpu::TextureViewDimension::D2
+ },
+ def,
+ view: None,
+ made_for: [0; 2],
+ })
+ .collect()
+}
+
+/// The wgpu format of an image's internal format: a storage format of the same channels.
+fn format(f: Format) -> wgpu::TextureFormat {
+ use wgpu::TextureFormat as T;
+ let pick = |r, rg, rgba| match f.channels {
+ 1 => r,
+ 2 => rg,
+ _ => rgba,
+ };
+ match (f.kind, f.bits) {
+ (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, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
+ (Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
+ (Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
+ (Kind::Float, _) => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
+ (Kind::Snorm, _) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
+ (Kind::Unorm, 16) => pick(T::R16Unorm, T::Rg16Unorm, T::Rgba16Unorm),
+ (Kind::Unorm, _) => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
+ }
+}
+
+/// Whether this device can make `def`: its format's features, and a size in pixels within the
+/// device's limits.
+pub(super) fn makeable(device: &wgpu::Device, def: &CustomImage) -> Result<(), String> {
+ let format = format(def.format);
+ let missing = format.required_features() - device.features();
+ if !missing.is_empty() {
+ return Err(format!(
+ "{format:?} needs {missing:?}, which this GPU lacks"
+ ));
+ }
+ let limits = device.limits();
+ let largest = if def.is_3d() {
+ limits.max_texture_dimension_3d
+ } else {
+ limits.max_texture_dimension_2d
+ };
+ let [width, height, depth] = def.extent([1, 1]);
+ let relative = matches!(def.size, horizon_shaders::ImageSize::Relative(_));
+ if !relative && width.max(height).max(depth) > largest {
+ return Err(format!(
+ "{width}x{height}x{depth} is larger than this GPU's {largest}"
+ ));
+ }
+ Ok(())
+}
+
+/// Makes the images that are missing or sized for another render size; whether it made any.
+pub(super) fn resize_images(
+ device: &wgpu::Device,
+ images: &mut [PackImage],
+ size: [u32; 2],
+) -> bool {
+ let largest = device.limits().max_texture_dimension_2d;
+ let mut made = false;
+ for image in images {
+ let relative = matches!(image.def.size, horizon_shaders::ImageSize::Relative(_));
+ if image.view.is_some() && (!relative || image.made_for == size) {
+ continue;
+ }
+ made = true;
+ // A relative image larger than the render size stops at the device's limit.
+ let [width, height, depth] = image.def.extent(size);
+ let [width, height] = [width.min(largest), height.min(largest)];
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(&image.def.name),
+ size: wgpu::Extent3d {
+ width,
+ height,
+ depth_or_array_layers: depth,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: if image.dimension == wgpu::TextureViewDimension::D3 {
+ wgpu::TextureDimension::D3
+ } else {
+ wgpu::TextureDimension::D2
+ },
+ format: image.format,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING
+ | wgpu::TextureUsages::STORAGE_BINDING
+ | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+ image.view = Some(texture.create_view(&Default::default()));
+ image.made_for = size;
+ }
+ made
+}
+
+/// Clears the images the pack clears before each frame.
+pub(super) fn clear(encoder: &mut wgpu::CommandEncoder, images: &[PackImage]) {
+ for view in images
+ .iter()
+ .filter(|i| i.def.clear)
+ .filter_map(|i| i.view.as_ref())
+ {
+ encoder.clear_texture(view.texture(), &Default::default());
+ }
+}
+
+/// The pack's storage buffers, bound in set 4.
+pub(super) struct Buffers {
+ pub(super) layout: wgpu::BindGroupLayout,
+ objects: BTreeMap,
+ pub(super) group: Option,
+ buffers: Vec,
+ made_for: [u32; 2],
+}
+
+impl Buffers {
+ pub(super) fn new(device: &wgpu::Device, objects: BTreeMap) -> Self {
+ let entries: Vec<_> = objects
+ .keys()
+ .map(|&binding| wgpu::BindGroupLayoutEntry {
+ binding,
+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Storage { read_only: false },
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ })
+ .collect();
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("Shader pack buffers"),
+ entries: &entries,
+ });
+ Self {
+ layout,
+ objects,
+ group: None,
+ buffers: Vec::new(),
+ made_for: [0; 2],
+ }
+ }
+
+ /// Makes the buffers (again, when one depends on the render size and it changed): their
+ /// contents start zeroed and persist across frames.
+ pub(super) fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
+ let relative = self
+ .objects
+ .values()
+ .any(|o| matches!(o, BufferObject::PerPixel { .. }));
+ if self.group.is_some() && (!relative || self.made_for == size) {
+ return;
+ }
+ let limits = device.limits();
+ let largest = limits
+ .max_buffer_size
+ .min(limits.max_storage_buffer_binding_size)
+ & !3;
+ self.buffers = self
+ .objects
+ .iter()
+ .map(|(binding, o)| {
+ let wanted = o.size(size).next_multiple_of(4);
+ if wanted > largest {
+ log::warn!(
+ "shader pack: bufferObject.{binding} wants {wanted} bytes, this device \
+ binds at most {largest}"
+ );
+ }
+ device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("Shader pack buffer"),
+ size: wanted.min(largest),
+ usage: wgpu::BufferUsages::STORAGE,
+ mapped_at_creation: false,
+ })
+ })
+ .collect();
+ let entries: Vec<_> = self
+ .objects
+ .keys()
+ .zip(&self.buffers)
+ .map(|(&binding, b)| wgpu::BindGroupEntry {
+ binding,
+ resource: b.as_entire_binding(),
+ })
+ .collect();
+ self.group = Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("Shader pack buffers"),
+ layout: &self.layout,
+ entries: &entries,
+ }));
+ self.made_for = size;
+ }
+
+ /// Whether the pack declares buffer `binding`.
+ pub(super) fn has(&self, binding: u32) -> bool {
+ self.objects.contains_key(&binding)
+ }
+}
diff --git a/crates/horizon-client-render/src/gfx/pack_post.rs b/crates/horizon-client-render/src/gfx/pack_post.rs
index 7eea498ef1..0d159f77b7 100644
--- a/crates/horizon-client-render/src/gfx/pack_post.rs
+++ b/crates/horizon-client-render/src/gfx/pack_post.rs
@@ -17,7 +17,9 @@ use horizon_shaders::{Format, Group, Kind, Post, Val};
use super::pack_draw::DrawUniforms;
use super::pack_entities::{PROGRAMS, PackEntities};
+use super::pack_images;
use super::pack_sky::PackSky;
+use super::pack_textures;
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
@@ -30,8 +32,17 @@ enum Source {
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
Depth1,
Noise,
- /// A 1×1 stand-in for names this slice does not provide yet (custom textures...).
+ /// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
+ /// `texture..`: the pack's own texture, filtered and wrapped as its
+ /// `.mcmeta` says.
+ Custom {
+ index: usize,
+ blur: bool,
+ clamp: bool,
+ },
+ /// `image.` through its sampler name.
+ Image(usize),
/// A 1×1 depth of 1.0 behind a comparison sampler, until shadow maps exist: no shadow.
Shadow,
/// The block atlas (pack_atlas.rs), in gbuffers programs.
@@ -49,6 +60,8 @@ struct Binding {
binding: u32,
/// A filtering sampler (the texture is filterable).
filtering: bool,
+ /// The view's dimension: 2D, or 1D and 3D for custom textures and their stand-ins.
+ dimension: wgpu::TextureViewDimension,
/// Where this texture's sampler object is bound: the first texture of each kind binds it
/// (`binding + 1`), the others share it (devices have as few as 16 sampler slots).
sampler: u32,
@@ -75,6 +88,14 @@ impl SamplerKind {
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
Source::Atlas => Self::NearestRepeat,
+ Source::Image(_) if filtering => Self::Linear,
+ Source::Image(_) => Self::Nearest,
+ Source::Custom { blur, clamp, .. } => match (blur && filtering, clamp) {
+ (true, true) => Self::Linear,
+ (false, true) => Self::Nearest,
+ (true, false) => Self::Repeat,
+ (false, false) => Self::NearestRepeat,
+ },
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
@@ -160,11 +181,48 @@ impl Entities {
}
}
+/// What an image binds: a buffer (`colorimgN`, the side its sampler reads) or one of the pack's.
+#[derive(Clone, Copy, PartialEq)]
+enum Image {
+ Color(usize),
+ Custom(usize),
+}
+
+impl Image {
+ fn buffer(self) -> Option {
+ match self {
+ Image::Color(b) => Some(b),
+ Image::Custom(_) => None,
+ }
+ }
+}
+
+/// The color buffers of `images`, as a mask.
+fn image_buffers(images: &[(u32, Image)]) -> u16 {
+ images
+ .iter()
+ .filter_map(|i| i.1.buffer())
+ .fold(0, |m, b| m | 1 << b)
+}
+
+/// A pass's pipeline: a draw, or a compute dispatch.
+#[derive(Clone)]
+enum Pipeline {
+ Render(wgpu::RenderPipeline),
+ Compute {
+ pipeline: wgpu::ComputePipeline,
+ /// `workGroups`, else `workGroupsRender` and the local size.
+ groups: Option<[u32; 3]>,
+ render: [f32; 2],
+ local: [u32; 3],
+ },
+}
+
#[derive(Clone)]
struct Pass {
name: String,
group: Group,
- pipeline: wgpu::RenderPipeline,
+ pipeline: Pipeline,
layout: wgpu::BindGroupLayout,
ubo: wgpu::Buffer,
block: Vec,
@@ -192,6 +250,24 @@ struct Pass {
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
/// 16–31) and the shadowcolor sides read (bits 32–33).
groups: HashMap,
+ /// (binding, target) of each image: for a buffer, the side its sampler reads.
+ images: Vec<(u32, Image)>,
+ /// Binds the pack's storage buffers in set 4.
+ buffers: bool,
+ /// Buffers both sampled and bound as images: sampled from a copy taken first, as wgpu binds
+ /// a texture one way per pass.
+ copies: u16,
+ /// Compute programs dispatched before this pass.
+ compute: Vec,
+}
+
+impl Pass {
+ fn render(&self) -> &wgpu::RenderPipeline {
+ match &self.pipeline {
+ Pipeline::Render(p) => p,
+ Pipeline::Compute { .. } => unreachable!("a compute pass draws nothing"),
+ }
+ }
}
/// One side of a buffer with mipmaps.
@@ -212,6 +288,10 @@ struct Targets {
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
+ /// Where a pass that both samples and stores a buffer samples it from.
+ scratch: [Option; BUFFERS],
+ /// Each buffer's size: the render size, or its `size.buffer`.
+ sizes: [[u32; 2]; BUFFERS],
}
/// What a pack's gbuffers programs read and draw from the game's world.
@@ -269,6 +349,12 @@ pub struct PackPost {
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
/// clouds discards vanilla's): the game's clouds are not drawn.
hides_clouds: bool,
+ /// The pack has a sky (`gbuffers_skybasic`) or sun and moon (`gbuffers_skytextured`)
+ /// program: vanilla's stays hidden even when the program writes nothing.
+ hides_sky: bool,
+ hides_celestial: bool,
+ /// `colortex0ClearColor`, when the pack sets it.
+ sky_clear: Option<[f32; 4]>,
/// What draws rain and snow (the same program, each with its kind's sprite as `texture`),
/// after translucent terrain, this frame's weather and the sprites' generation bound.
weather: Option<[Pass; 2]>,
@@ -308,11 +394,22 @@ pub struct PackPost {
formats: [wgpu::TextureFormat; BUFFERS],
clears: [Option<[f32; 4]>; BUFFERS],
used: u16,
+ /// `size.buffer.*`.
+ sizes: [Option; BUFFERS],
+ /// Buffers bound as images, and those also copied for a pass that samples them.
+ storage: u16,
+ copied: u16,
/// Bit n set: B is buffer n's "main".
main: u16,
targets: Option,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
+ dummy_3d: wgpu::TextureView,
+ dummy_1d: wgpu::TextureView,
+ /// `image.*` and `bufferObject.*`.
+ images: Vec,
+ buffers: pack_images::Buffers,
+ textures: Vec,
/// The stand-in shadow map and its comparison sampler; cleared on the first frame.
shadow: (wgpu::TextureView, wgpu::Sampler),
shadow_cleared: bool,
@@ -372,6 +469,18 @@ fn texture_format(f: Format, features: wgpu::Features) -> wgpu::TextureFormat {
/// The texture behind sampler `name` in a program of the shadow buffers (`shadow_space`: buffer
/// n is shadowcolor n) or of the others, when the shadow pass runs (`shadow_pass`). `shadow` is
/// `shadowtex1` when the program also declares `watershadow` (6.3).
+/// Whether a `ty` sampler can read a custom texture of `sample_type`: custom textures hold
+/// colours, never depth to compare against.
+fn fits(ty: &str, sample_type: wgpu::TextureSampleType) -> bool {
+ use wgpu::TextureSampleType as T;
+ !ty.contains("Shadow")
+ && match sample_type {
+ T::Sint => ty.starts_with('i'),
+ T::Uint => ty.starts_with('u'),
+ _ => ty.starts_with('s'),
+ }
+}
+
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = match name {
"shadowcolor" | "shadowcolor0" => Some(0),
@@ -476,13 +585,16 @@ fn sampler(
})
}
-/// Runs `create` and returns its validation error, if any. A panic while it runs is an error
-/// too: naga's backends turn a module they cannot write into a panic as the driver gets the
-/// pipeline (#3525), which a pack's program must not turn into a crash.
+/// Runs `create` and returns its validation error, if any, or the backend's (a shader the
+/// driver's compiler refuses is an internal error). A panic while it runs is an error too:
+/// naga's backends turn a module they cannot write into a panic as the driver gets the pipeline
+/// (#3525), which a pack's program must not turn into a crash.
fn checked(device: &wgpu::Device, create: impl FnOnce() -> T) -> Result {
- let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
+ let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
+ let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let value = crate::crash::contain(create);
- match pollster::block_on(scope.pop()) {
+ let internal = pollster::block_on(internal.pop());
+ match pollster::block_on(validation.pop()).or(internal) {
Some(e) => {
// The details are in the error's description or its sources.
let mut message = match &e {
@@ -549,18 +661,26 @@ fn helper(
fn entry(binding: u32, ty: wgpu::BindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
- visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT | wgpu::ShaderStages::COMPUTE,
ty,
count: None,
}
}
fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::BindGroupLayoutEntry {
+ texture_entry_of(binding, sample_type, wgpu::TextureViewDimension::D2)
+}
+
+fn texture_entry_of(
+ binding: u32,
+ sample_type: wgpu::TextureSampleType,
+ view_dimension: wgpu::TextureViewDimension,
+) -> wgpu::BindGroupLayoutEntry {
entry(
binding,
wgpu::BindingType::Texture {
sample_type,
- view_dimension: wgpu::TextureViewDimension::D2,
+ view_dimension,
multisampled: false,
},
)
@@ -689,33 +809,42 @@ pub(super) struct Geometry<'a> {
pub cull: bool,
}
-/// The pass of program `p`: a quad, or `geometry` drawn by the game. `None` (with a note) when
-/// wgpu refuses it or it writes nothing. `formats` are its buffers' (for a program of the shadow
-/// buffers, buffer n is shadowcolor n); `shadow` the shadowcolor formats when the shadow pass
-/// runs.
-#[allow(clippy::too_many_arguments)]
-fn build_pass(
- device: &wgpu::Device,
- p: horizon_shaders::PostProgram,
+/// What a pack brings besides its programs: custom textures, images and storage buffers.
+struct Resources<'a> {
+ textures: &'a [pack_textures::PackTexture],
+ images: &'a [pack_images::PackImage],
+ /// The images this device can't make.
+ left_out: &'a [horizon_shaders::CustomImage],
+ buffers: &'a pack_images::Buffers,
+}
+
+/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
+/// and image binds. `None` (with a note) for an image the game can't give it.
+#[allow(clippy::too_many_arguments, clippy::type_complexity)]
+fn bindings(
+ features: wgpu::Features,
+ interface: &horizon_shaders::Interface,
+ name: &str,
+ group: Group,
+ buffers: &[u32],
+ geometry: Option<&Geometry<'_>>,
formats: &[wgpu::TextureFormat; BUFFERS],
shadow: Option<&[wgpu::TextureFormat; 2]>,
- screen: wgpu::TextureFormat,
notes: &mut Vec,
- geometry: Option<&Geometry<'_>>,
- custom: &[String],
- draws: &wgpu::BindGroupLayout,
-) -> Option {
- let features = device.features();
- // The frame's uniforms, then the pack's custom ones.
- let index = |name: &str| {
- pack_uniforms::index(name).or_else(|| {
- let k = custom.iter().position(|c| c == name)?;
- Some(pack_uniforms::NAMES.len() + k)
- })
- };
- let mask = |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
- let interface = &p.program.interface;
- let draw = interface.draw;
+ pack: &Resources<'_>,
+) -> Option<(
+ Vec,
+ Vec,
+ HashMap,
+ u16,
+ u16,
+ Vec<(u32, Image)>,
+)> {
+ let textures = pack.textures;
+ if let Some(b) = interface.buffers.iter().find(|&&b| !pack.buffers.has(b)) {
+ notes.push(format!("{name}: buffer {b} has no `bufferObject.{b}`"));
+ return None;
+ }
let mut entries = vec![entry(
0,
wgpu::BindingType::Buffer {
@@ -732,74 +861,131 @@ fn build_pass(
// A gbuffers pass draws into its buffers and the depth buffer: reading them as well would
// be a feedback loop (undefined in GL, refused by wgpu), so those read a blank texture.
let in_place = match geometry {
- Some(_) => p.buffers.iter().fold(0u16, |m, &b| m | 1 << b),
+ Some(_) => buffers.iter().fold(0u16, |m, &b| m | 1 << b),
None => 0,
};
for s in &interface.samplers {
- let mut src = source(&s.name, p.group.shadow(), shadow.is_some(), watershadow);
- let blank = match src {
- // The shadow pass writes the shadow map.
- Source::ShadowDepth(..) => p.group == Group::Shadow,
- Source::Atlas => geometry.is_none(),
- Source::Depth => geometry.is_some(),
- // Translucent terrain reads the copy; opaque terrain would read itself.
- Source::Depth1 => p.group == Group::Gbuffers,
- Source::Color(n) => in_place & 1 << n != 0,
- _ => false,
- };
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::ShadowColor(n) => shadow
- .and_then(|f| f[n].sample_type(None, Some(features)))
- .unwrap_or(float),
- Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
- wgpu::TextureSampleType::Float { filterable: false }
- }
- // Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
- Source::Lightmap => float,
- _ => float,
+ // A stand-in of the sampler's own dimension.
+ let stand_in = if s.ty.contains("1D") {
+ wgpu::TextureViewDimension::D1
+ } else if s.ty.contains("3D") {
+ wgpu::TextureViewDimension::D3
+ } else {
+ wgpu::TextureViewDimension::D2
};
- let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. });
- if s.ty == "sampler2DShadow" {
- src = match src {
- Source::ShadowDepth(n, _) => Source::ShadowDepth(n, true),
- _ => Source::Shadow,
- };
- sample_type = 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 {
+ if let Some(def) = pack
+ .left_out
+ .iter()
+ .find(|d| d.sampler.as_deref() == Some(s.name.as_str()))
+ {
notes.push(format!(
- "{}: `{} {}` is not provided yet",
- p.name, s.ty, s.name
+ "{name}: samples image `{}`, which is left out",
+ def.name
));
- (src, sample_type) = (Source::Dummy, float);
+ return None;
}
- if blank && sample_type == wgpu::TextureSampleType::Depth {
- src = Source::Shadow;
- } else if blank {
- // The blank texture is float: an integer sampler then fails the pipeline (a note).
- src = Source::Dummy;
- if int {
- sample_type = float;
+ let custom = pack_textures::find(textures, group, &s.ty, &s.name);
+ let image = pack
+ .images
+ .iter()
+ .position(|i| i.def.sampler.as_deref() == Some(s.name.as_str()));
+ let (src, sample_type, dimension) = match custom {
+ _ if let Some(k) = image => {
+ let i = &pack.images[k];
+ (Source::Image(k), i.sample_type(features), i.dimension)
}
- }
+ Some(i) if !fits(&s.ty, textures[i].sample_type) => {
+ notes.push(format!(
+ "{name}: `{} {}` can't read texture.{}.{}",
+ s.ty, s.name, textures[i].stage, textures[i].name
+ ));
+ (Source::Dummy, float, stand_in)
+ }
+ Some(i) => {
+ let t = &textures[i];
+ let src = match t.image {
+ pack_textures::Image::Lightmap => Source::Lightmap,
+ pack_textures::Image::Atlas => Source::Atlas,
+ pack_textures::Image::View(_) => Source::Custom {
+ index: i,
+ blur: t.blur,
+ clamp: t.clamp,
+ },
+ };
+ (src, t.sample_type, t.dimension)
+ }
+ None => {
+ let mut src = source(&s.name, group.shadow(), shadow.is_some(), watershadow);
+ let blank = match src {
+ // The shadow pass writes the shadow map.
+ Source::ShadowDepth(..) => group == Group::Shadow,
+ Source::Atlas => geometry.is_none(),
+ Source::Depth => geometry.is_some(),
+ // Translucent terrain reads the copy; opaque terrain would read itself.
+ Source::Depth1 => group == Group::Gbuffers,
+ Source::Color(n) => in_place & 1 << n != 0,
+ _ => false,
+ };
+ let mut sample_type = match src {
+ Source::Color(n) => formats[n]
+ .sample_type(None, Some(features))
+ .unwrap_or(float),
+ Source::ShadowColor(n) => shadow
+ .and_then(|f| f[n].sample_type(None, Some(features)))
+ .unwrap_or(float),
+ Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
+ wgpu::TextureSampleType::Float { filterable: false }
+ }
+ // Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
+ Source::Lightmap => float,
+ _ => float,
+ };
+ let int = !matches!(sample_type, wgpu::TextureSampleType::Float { .. });
+ if s.ty == "sampler2DShadow" {
+ src = match src {
+ Source::ShadowDepth(n, _) => Source::ShadowDepth(n, true),
+ _ => Source::Shadow,
+ };
+ sample_type = 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",
+ name, s.ty, s.name
+ ));
+ (src, sample_type) = (Source::Dummy, float);
+ }
+ if blank && sample_type == wgpu::TextureSampleType::Depth {
+ src = Source::Shadow;
+ } else if blank {
+ // The blank texture is float: an integer sampler then fails the pipeline (a note).
+ src = Source::Dummy;
+ if int {
+ sample_type = float;
+ }
+ }
+ let dimension = match src {
+ Source::Dummy => stand_in,
+ _ => wgpu::TextureViewDimension::D2,
+ };
+ (src, sample_type, dimension)
+ }
+ };
let filtering = sample_type == float;
match src {
Source::Color(n) => reads |= 1 << n,
Source::ShadowColor(n) => shadow_reads |= 1 << n,
_ => {}
}
- entries.push(texture_entry(s.binding, sample_type));
+ entries.push(texture_entry_of(s.binding, sample_type, dimension));
let kind = SamplerKind::of(src, filtering);
let sampler = *kinds.entry(kind).or_insert_with(|| {
entries.push(entry(
@@ -814,8 +1000,142 @@ fn build_pass(
binding: s.binding,
filtering,
sampler,
+ dimension,
});
}
+ let mut images = Vec::new();
+ for i in &interface.images {
+ let buffer = i
+ .name
+ .strip_prefix("colorimg")
+ .and_then(|n| n.parse::().ok())
+ .filter(|&n| n < BUFFERS && !group.shadow());
+ let own = pack.images.iter().position(|c| c.def.name == i.name);
+ let (target, actual) = match (buffer, own) {
+ (_, Some(k)) => (Image::Custom(k), pack.images[k].format),
+ (Some(b), None) => (Image::Color(b), formats[b]),
+ (None, None) => {
+ notes.push(format!("{name}: image `{}` is not provided yet", i.name));
+ return None;
+ }
+ };
+ let Some(format) = image_format(&i.format) else {
+ notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
+ return None;
+ };
+ if actual != format {
+ notes.push(format!(
+ "{name}: `{}` is {} but its texture is {actual:?}",
+ i.name, i.format
+ ));
+ return None;
+ }
+ let access = match i.access.as_str() {
+ "writeonly" => wgpu::StorageTextureAccess::WriteOnly,
+ "readonly" => wgpu::StorageTextureAccess::ReadOnly,
+ "atomic" => wgpu::StorageTextureAccess::Atomic,
+ _ => wgpu::StorageTextureAccess::ReadWrite,
+ };
+ let dimension = if i.ty.ends_with("3D") {
+ wgpu::TextureViewDimension::D3
+ } else {
+ wgpu::TextureViewDimension::D2
+ };
+ entries.push(wgpu::BindGroupLayoutEntry {
+ binding: i.binding,
+ visibility: wgpu::ShaderStages::FRAGMENT | wgpu::ShaderStages::COMPUTE,
+ ty: wgpu::BindingType::StorageTexture {
+ access,
+ format,
+ view_dimension: dimension,
+ },
+ count: None,
+ });
+ images.push((i.binding, target));
+ }
+ Some((entries, samplers, slots, reads, shadow_reads, images))
+}
+
+/// The wgpu format of an image's format qualifier.
+fn image_format(qualifier: &str) -> Option {
+ use wgpu::TextureFormat as T;
+ Some(match qualifier {
+ "rgba32f" => T::Rgba32Float,
+ "rgba16f" => T::Rgba16Float,
+ "rg32f" => T::Rg32Float,
+ "rg16f" => T::Rg16Float,
+ "r11f_g11f_b10f" => T::Rg11b10Ufloat,
+ "r32f" => T::R32Float,
+ "r16f" => T::R16Float,
+ "rgba16" => T::Rgba16Unorm,
+ "rgb10_a2" => T::Rgb10a2Unorm,
+ "rgba8" => T::Rgba8Unorm,
+ "rg16" => T::Rg16Unorm,
+ "rg8" => T::Rg8Unorm,
+ "r16" => T::R16Unorm,
+ "r8" => T::R8Unorm,
+ "rgba16_snorm" => T::Rgba16Snorm,
+ "rgba8_snorm" => T::Rgba8Snorm,
+ "rg16_snorm" => T::Rg16Snorm,
+ "rg8_snorm" => T::Rg8Snorm,
+ "r16_snorm" => T::R16Snorm,
+ "r8_snorm" => T::R8Snorm,
+ "rgba32i" => T::Rgba32Sint,
+ "rgba16i" => T::Rgba16Sint,
+ "rgba8i" => T::Rgba8Sint,
+ "rg32i" => T::Rg32Sint,
+ "rg16i" => T::Rg16Sint,
+ "rg8i" => T::Rg8Sint,
+ "r32i" => T::R32Sint,
+ "r16i" => T::R16Sint,
+ "r8i" => T::R8Sint,
+ "rgba32ui" => T::Rgba32Uint,
+ "rgba16ui" => T::Rgba16Uint,
+ "rgb10_a2ui" => T::Rgb10a2Uint,
+ "rgba8ui" => T::Rgba8Uint,
+ "rg32ui" => T::Rg32Uint,
+ "rg16ui" => T::Rg16Uint,
+ "rg8ui" => T::Rg8Uint,
+ "r32ui" => T::R32Uint,
+ "r16ui" => T::R16Uint,
+ "r8ui" => T::R8Uint,
+ _ => return None,
+ })
+}
+
+/// The pass of program `p`: a quad, or `geometry` drawn by the game. `None` (with a note) when
+/// wgpu refuses it or it writes nothing. `formats` are its buffers' (for a program of the shadow
+/// buffers, buffer n is shadowcolor n); `shadow` the shadowcolor formats when the shadow pass
+/// runs.
+#[allow(clippy::too_many_arguments)]
+fn build_pass(
+ device: &wgpu::Device,
+ p: horizon_shaders::PostProgram,
+ formats: &[wgpu::TextureFormat; BUFFERS],
+ shadow: Option<&[wgpu::TextureFormat; 2]>,
+ screen: wgpu::TextureFormat,
+ notes: &mut Vec,
+ geometry: Option<&Geometry<'_>>,
+ custom: &[String],
+ draws: &wgpu::BindGroupLayout,
+ pack: &Resources<'_>,
+) -> Option {
+ let features = device.features();
+ // The frame's uniforms, then the pack's custom ones.
+ let index = |name: &str| {
+ pack_uniforms::index(name).or_else(|| {
+ let k = custom.iter().position(|c| c == name)?;
+ Some(pack_uniforms::NAMES.len() + k)
+ })
+ };
+ let mask = |flips: &[(u32, bool)]| flips.iter().filter(|f| f.1).fold(0u16, |m, f| m | 1 << f.0);
+ let mut p = p;
+ let program = p.program.take()?;
+ let interface = &program.interface;
+ let draw = interface.draw;
+ let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
+ features, interface, &p.name, p.group, &p.buffers, geometry, formats, shadow, notes, pack,
+ )?;
let final_pass = p.group == Group::Final;
let mut targets = Vec::new();
let mut writes = Vec::new();
@@ -843,6 +1163,11 @@ fn build_pass(
writes.push((location, buffer as usize));
}
}
+ // Outputs past the last buffer the program writes go nowhere, as in GL; a pass has no
+ // attachment for them.
+ while targets.last().is_some_and(Option::is_none) {
+ targets.pop();
+ }
if !final_pass && writes.is_empty() {
notes.push(format!("{}: writes no buffer; skipped", p.name));
return None;
@@ -871,6 +1196,7 @@ fn build_pass(
geometry.and_then(|g| g.table),
draw.then_some(draws),
geometry.and_then(|g| g.ids),
+ (!interface.buffers.is_empty()).then_some(&pack.buffers.layout),
],
..Default::default()
});
@@ -881,7 +1207,7 @@ fn build_pass(
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(m)),
})
};
- let (vs, fs) = (module(p.program.vertex), module(p.program.fragment));
+ let (vs, fs) = (module(program.vertex), module(program.fragment));
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&p.name),
layout: Some(&pipeline_layout),
@@ -923,7 +1249,7 @@ fn build_pass(
return None;
}
};
- let interface = &p.program.interface;
+ let interface = &program.interface;
let missing: Vec<&str> = interface
.uniforms
.iter()
@@ -948,12 +1274,11 @@ fn build_pass(
.collect(),
name: p.name,
group: p.group,
- pipeline,
+ pipeline: Pipeline::Render(pipeline),
layout,
samplers,
writes,
flip,
- reads,
shadow_reads,
written,
mipmaps: p.mipmaps,
@@ -961,6 +1286,125 @@ fn build_pass(
atlas: geometry.and_then(|g| g.atlas.clone()),
ids: geometry.is_some_and(|g| g.ids.is_some()),
groups: HashMap::new(),
+ copies: image_buffers(&images) & reads,
+ reads: reads | image_buffers(&images),
+ buffers: !interface.buffers.is_empty(),
+ images,
+ compute: Vec::new(),
+ })
+}
+
+/// The pass of compute program `c` of a `group` program: `None` (with a note) when wgpu refuses
+/// it or it needs what the game can't give.
+#[allow(clippy::too_many_arguments)]
+fn build_compute(
+ device: &wgpu::Device,
+ name: String,
+ group: Group,
+ c: horizon_shaders::ComputeProgram,
+ formats: &[wgpu::TextureFormat; BUFFERS],
+ shadow: Option<&[wgpu::TextureFormat; 2]>,
+ notes: &mut Vec,
+ custom: &[String],
+ pack: &Resources<'_>,
+) -> Option {
+ let index = |name: &str| {
+ pack_uniforms::index(name).or_else(|| {
+ let k = custom.iter().position(|c| c == name)?;
+ Some(pack_uniforms::NAMES.len() + k)
+ })
+ };
+ let interface = &c.interface;
+ let (entries, samplers, slots, reads, shadow_reads, images) = bindings(
+ device.features(),
+ interface,
+ &name,
+ group,
+ &[],
+ None,
+ formats,
+ shadow,
+ notes,
+ pack,
+ )?;
+ let local = c.module.entry_points[0].workgroup_size;
+ let created = checked(device, || {
+ let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some(&name),
+ entries: &entries,
+ });
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some(&name),
+ bind_group_layouts: &[
+ Some(&layout),
+ None,
+ None,
+ None,
+ (!interface.buffers.is_empty()).then_some(&pack.buffers.layout),
+ ],
+ ..Default::default()
+ });
+ let mut module = c.module;
+ share_samplers(&mut module, &slots);
+ let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some(&name),
+ source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
+ });
+ let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
+ label: Some(&name),
+ layout: Some(&pipeline_layout),
+ module: &module,
+ entry_point: Some("main"),
+ compilation_options: Default::default(),
+ cache: None,
+ });
+ (layout, pipeline)
+ });
+ let (layout, pipeline) = match created {
+ Ok(created) => created,
+ Err(e) => {
+ notes.push(format!("{name}: skipped: {e}"));
+ return None;
+ }
+ };
+ let written = image_buffers(&images);
+ Some(Pass {
+ ubo: device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some(&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| (index(&u.name), u.ty.clone(), u.offset))
+ .collect(),
+ name,
+ group,
+ pipeline: Pipeline::Compute {
+ pipeline,
+ groups: c.work_groups,
+ render: c.work_groups_render,
+ local,
+ },
+ layout,
+ samplers,
+ writes: Vec::new(),
+ flip: 0,
+ copies: written & reads,
+ reads: reads | written,
+ shadow_reads,
+ written,
+ mipmaps: 0,
+ draw: false,
+ atlas: None,
+ ids: false,
+ groups: HashMap::new(),
+ buffers: !interface.buffers.is_empty(),
+ images,
+ compute: Vec::new(),
})
}
@@ -969,13 +1413,16 @@ impl PackPost {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
- post: Post,
+ mut post: Post,
screen: wgpu::TextureFormat,
world: World<'_>,
) -> Self {
let features = device.features();
let formats: [wgpu::TextureFormat; BUFFERS] =
std::array::from_fn(|i| texture_format(post.config.buffers[i].format, features));
+ let sky_clear = Some(&post.config.buffers[0])
+ .filter(|b| b.clear)
+ .and_then(|b| b.clear_color);
let clears = std::array::from_fn(|i| {
let b = &post.config.buffers[i];
b.clear.then(|| {
@@ -985,11 +1432,19 @@ impl PackPost {
});
let mut notes = post.notes;
notes.extend(post.config.notes);
+ let mut sizes = post.sizes;
+ // The blit writes colortex0 beside the depth buffer, at the render size.
+ if sizes[0].take().is_some() {
+ notes.push("size.buffer.colortex0: colortex0 keeps the render size".into());
+ }
+ let textures = pack_textures::upload(device, queue, post.textures, &mut notes);
let shadow_config = post.config.shadow.clone();
let mut custom = post.custom;
let custom_names: Vec = custom.uniforms().map(|u| u.0.to_owned()).collect();
// A first run finds the names neither the frame nor the pack defines.
custom.eval(0.0, &|n| pack_uniforms::index(n).map(|_| Val::scalar(0.0)));
+ // Its zeros are no start for `smooth`: the first frame's values are, as in Iris.
+ custom.restart_smoothing();
if !custom.notes.is_empty() {
notes.push(format!("custom uniforms: {}", custom.notes.join("; ")));
custom.notes.clear();
@@ -1008,21 +1463,91 @@ impl PackPost {
.filter(|_| i < 2)
.map_or(formats[i], |f| f[i])
});
- let mut build = |p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
+ let (mut storage, mut copied) = (0u16, 0u16);
+ // An image this device can't make: the programs using it are skipped.
+ let mut image_defs = std::mem::take(&mut post.images);
+ let mut left_out = Vec::new();
+ image_defs.retain(|def| match pack_images::makeable(device, def) {
+ Ok(()) => true,
+ Err(e) => {
+ notes.push(format!("image `{}`: {e}", def.name));
+ left_out.push(def.clone());
+ false
+ }
+ });
+ let images = pack_images::images(image_defs);
+ let buffers = pack_images::Buffers::new(device, std::mem::take(&mut post.buffer_objects));
+ let pack = Resources {
+ textures: &textures,
+ images: &images,
+ left_out: &left_out,
+ buffers: &buffers,
+ };
+ let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
- let pass = build_pass(
- device,
- p,
- if in_shadow { &shadow_space } else { &formats },
- shadow_formats.as_ref(),
- screen,
- &mut notes,
- geometry,
- &custom_names,
- &draws.layout,
- )?;
- if !in_shadow {
- used |= pass.reads | pass.written | pass.flip;
+ let compute = std::mem::take(&mut p.compute);
+ let group = p.group;
+ let mut computes: Vec = compute
+ .into_iter()
+ .filter_map(|(name, c)| {
+ build_compute(
+ device,
+ name,
+ group,
+ c,
+ if in_shadow { &shadow_space } else { &formats },
+ shadow_formats.as_ref(),
+ &mut notes,
+ &custom_names,
+ &pack,
+ )
+ })
+ .collect();
+ let mut pass = if p.program.is_none() {
+ // Compute stages only: the last is the pass, the others run before it.
+ let mut last = computes.pop()?;
+ last.compute = computes;
+ computes = Vec::new();
+ last
+ } else {
+ build_pass(
+ device,
+ p,
+ if in_shadow { &shadow_space } else { &formats },
+ shadow_formats.as_ref(),
+ screen,
+ &mut notes,
+ geometry,
+ &custom_names,
+ &draws.layout,
+ &pack,
+ )?
+ };
+ pass.compute.append(&mut computes);
+ // A pass's attachments share a size: the depth buffer's, for the passes that draw
+ // the world.
+ let size =
+ |b: usize| sizes[b].unwrap_or(horizon_shaders::BufferSize::Relative([1.0; 2]));
+ let depth = matches!(group, Group::Gbuffers | Group::Translucent);
+ let mut written = pass.writes.iter().map(|w| size(w.1));
+ let first = if depth {
+ Some(horizon_shaders::BufferSize::Relative([1.0; 2]))
+ } else {
+ written.next()
+ };
+ if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
+ notes.push(format!(
+ "{}: writes buffers of different sizes (size.buffer); skipped",
+ pass.name
+ ));
+ return None;
+ }
+ for p in std::iter::once(&pass).chain(&pass.compute) {
+ if !in_shadow {
+ used |= p.reads | p.written | p.flip;
+ }
+ storage |= image_buffers(&p.images);
+ copied |= p.copies;
}
Some(pass)
};
@@ -1056,6 +1581,7 @@ impl PackPost {
.shadow_water
.zip(terrain_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
+ let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
let geometry = PackSky::new(device);
let basic = Geometry {
@@ -1323,8 +1849,11 @@ impl PackPost {
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
});
+ // Buffers of a size of their own clear apart from the blit, which writes colortex0.
+ let (mut cleared, mut other_clears): (Vec, Vec) =
+ cleared.into_iter().partition(|&b| sizes[b].is_none());
let room = 8 - 1;
- let other_clears = cleared.split_off(room.min(cleared.len()));
+ other_clears.extend(cleared.split_off(room.min(cleared.len())));
let float = wgpu::TextureSampleType::Float { filterable: true };
let blit_targets = std::iter::once(target(formats[0]));
// Cleared by the pass's load operation; the shader writes nothing there.
@@ -1417,6 +1946,9 @@ impl PackPost {
clouds,
cloud_draw: None,
hides_clouds,
+ hides_sky,
+ hides_celestial,
+ sky_clear,
weather,
weather_draw: None,
weather_generation: 0,
@@ -1441,14 +1973,54 @@ impl PackPost {
formats,
clears,
used,
+ storage,
+ copied,
+ sizes,
blit_depth,
blit_clears: cleared,
other_clears,
blit_group: None,
main: 0,
targets: None,
- noise: noise(device, queue),
+ noise: pack_textures::noise(&textures)
+ .cloned()
+ .unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
+ images,
+ buffers,
+ dummy_3d: device
+ .create_texture(&wgpu::TextureDescriptor {
+ label: Some("Shader pack 3D stand-in"),
+ size: wgpu::Extent3d {
+ width: 1,
+ height: 1,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D3,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ })
+ .create_view(&Default::default()),
+ dummy_1d: device
+ .create_texture(&wgpu::TextureDescriptor {
+ label: Some("Shader pack 1D stand-in"),
+ size: wgpu::Extent3d {
+ width: 1,
+ height: 1,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D1,
+ format: wgpu::TextureFormat::Rgba8Unorm,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ })
+ .create_view(&Default::default()),
+ textures,
shadow: (
device
.create_texture(&wgpu::TextureDescriptor {
@@ -1498,14 +2070,28 @@ impl PackPost {
}
/// Writes `pass`'s uniforms for a target of `out` pixels; its bind group for the buffers'
- /// current mapping.
+ /// current mapping. The buffers it both samples and stores to are copied first, and it
+ /// samples the copies.
fn bind(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
pass: &mut Pass,
out: [u32; 2],
) -> wgpu::BindGroup {
+ if let Some(targets) = &self.targets {
+ for i in (0..BUFFERS).filter(|i| pass.copies & 1 << i != 0) {
+ if let (Some(t), Some(copy)) = (&targets.color[i], &targets.scratch[i]) {
+ let side = usize::from(self.main & 1 << i != 0);
+ encoder.copy_texture_to_texture(
+ t[side].texture().as_image_copy(),
+ copy.texture().as_image_copy(),
+ t[side].texture().size(),
+ );
+ }
+ }
+ }
let fog = self.uniforms.fog();
// Our block members first (`hz_target`, `hz_fog_color`, `hz_fog`), then the pack's.
let ours = [
@@ -1567,6 +2153,9 @@ impl PackPost {
},
None => &self.shadow.0,
},
+ Source::Color(i) if pass.copies & 1 << i != 0 => {
+ targets.scratch[i].as_ref().unwrap_or(&self.dummy)
+ }
Source::Color(i) => {
let side = usize::from(key & 1 << i != 0);
match (&targets.mips[i], &targets.color[i]) {
@@ -1589,7 +2178,18 @@ impl PackPost {
_ => &targets.depth,
},
Source::Noise => &self.noise,
+ Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
+ &self.dummy_3d
+ }
+ Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
+ &self.dummy_1d
+ }
Source::Dummy => &self.dummy,
+ Source::Image(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
+ Source::Custom { index, .. } => match &self.textures[index].image {
+ pack_textures::Image::View(v) => v,
+ _ => &self.dummy,
+ },
Source::Shadow => &self.shadow.0,
Source::Atlas => pass
.atlas
@@ -1616,6 +2216,20 @@ impl PackPost {
});
}
}
+ // An image is the side its buffer's sampler reads.
+ for &(binding, image) in &pass.images {
+ let view = match image {
+ Image::Color(i) => {
+ let side = usize::from(key & 1 << i != 0);
+ targets.color[i].as_ref().map_or(&self.dummy, |t| &t[side])
+ }
+ Image::Custom(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
+ };
+ entries.push(wgpu::BindGroupEntry {
+ binding,
+ resource: wgpu::BindingResource::TextureView(view),
+ });
+ }
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some(&pass.name),
layout: &pass.layout,
@@ -1625,6 +2239,46 @@ impl PackPost {
group.clone()
}
+ /// Dispatches compute pass `pass` over a target of `size`.
+ fn dispatch(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ encoder: &mut wgpu::CommandEncoder,
+ pass: &mut Pass,
+ size: [u32; 2],
+ ) {
+ let Pipeline::Compute {
+ pipeline,
+ groups,
+ render,
+ local,
+ } = &pass.pipeline
+ else {
+ return;
+ };
+ let groups = groups.unwrap_or_else(|| {
+ let n = |k: usize| (size[k] as f32 * render[k] / local[k] as f32).ceil() as u32;
+ [n(0), n(1), 1]
+ });
+ let (pipeline, groups) = (pipeline.clone(), groups);
+ let group = self.bind(device, queue, encoder, pass, size);
+ let mut cp = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
+ label: Some(&pass.name),
+ timestamp_writes: None,
+ });
+ cp.set_pipeline(&pipeline);
+ cp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ cp.set_bind_group(4, g, &[]);
+ }
+ cp.dispatch_workgroups(groups[0], groups[1], groups[2]);
+ drop(cp);
+ self.mips_valid &= !pass.written;
+ }
+
/// A render pass into the buffers `pass` writes, in place, tested against the world's depth.
fn begin<'e>(
&mut self,
@@ -1703,11 +2357,16 @@ impl PackPost {
t.depth_texture.size(),
);
}
- let group = self.bind(device, queue, &mut pass, size);
+ let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
if pass.draw {
let stage = if translucent {
"TERRAIN_TRANSLUCENT"
@@ -1754,7 +2413,7 @@ impl PackPost {
let mut groups = [None, None];
for (i, pass) in [&mut sky.basic, &mut sky.textured].into_iter().enumerate() {
if let Some(pass) = pass {
- groups[i] = Some(self.bind(device, queue, pass, size));
+ groups[i] = Some(self.bind(device, queue, encoder, pass, size));
self.mips_valid &= !pass.written;
}
}
@@ -1775,7 +2434,7 @@ impl PackPost {
open = Some((self.begin(encoder, pass).forget_lifetime(), i));
}
let rp = &mut open.as_mut().expect("open").0;
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, group, &[]);
sky.geometry.bind(rp, part.textured);
open.as_mut().expect("open").1 = i;
@@ -1817,11 +2476,16 @@ impl PackPost {
if draws.peek().is_none() {
continue;
}
- let group = self.bind(device, queue, pass, size);
+ let group = self.bind(device, queue, encoder, pass, size);
{
let mut rp = self.begin(encoder, pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -1860,10 +2524,15 @@ impl PackPost {
if draws.peek().is_none() {
continue;
}
- let group = self.bind(device, queue, pass, size);
+ let group = self.bind(device, queue, encoder, pass, size);
let mut rp = self.begin(encoder, pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.set_bind_group(1, &geometry.table_group, &[]);
rp.set_vertex_buffer(0, geometry.vertices());
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -1959,19 +2628,26 @@ impl PackPost {
let Some((mut pass, mut geometry)) = self.hand.take() else {
return;
};
+ // The hand draws through `gbufferModelView`, which holds the nausea spin the world
+ // turns with; the hand doesn't (as vanilla's), so its projection takes the spin out.
+ let unspin = frame.bob * frame.spin.inverse() * frame.bob.inverse();
let projection = super::pack_hand::projection(
frame.hand_fov_degrees,
size[0] as f32 / size[1].max(1) as f32,
frame.depth_far,
- frame.bob,
- );
+ ) * unspin;
geometry.prepare(device, queue, frame.hand, projection);
if geometry.quads > 0 {
- let group = self.bind(device, queue, &mut pass, size);
+ let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.set_bind_group(1, &geometry.group, &[]);
if pass.draw {
self.draws.bind(&mut rp, "HAND_SOLID");
@@ -2001,11 +2677,16 @@ impl PackPost {
let (Some(mut pass), Some(clouds)) = (self.clouds.take(), self.cloud_draw.take()) else {
return;
};
- let group = self.bind(device, queue, &mut pass, size);
+ let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.set_bind_group(1, &clouds.group, &[]);
if pass.draw {
self.draws.bind(&mut rp, "CLOUDS");
@@ -2072,11 +2753,16 @@ impl PackPost {
if quads.is_empty() {
continue;
}
- let group = self.bind(device, queue, pass, size);
+ let group = self.bind(device, queue, encoder, pass, size);
{
let mut rp = self.begin(encoder, pass);
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
if pass.draw {
self.draws.bind(&mut rp, "RAIN_SNOW");
}
@@ -2145,13 +2831,20 @@ impl PackPost {
}
/// Whether the pack draws the sky (disc, sunset, stars, void) itself.
- pub fn has_sky(&self) -> bool {
- self.sky.as_ref().is_some_and(|s| s.basic.is_some())
+ /// What the world's background is when the pack draws the sky: colortex0's clear colour
+ /// (`None`: the fog colour, its default).
+ pub fn sky_clear(&self) -> Option<[f32; 4]> {
+ self.sky_clear.filter(|_| self.has_sky())
}
- /// Whether the pack draws the sun and moon itself.
+ /// Whether the pack has a sky program, drawing or not: vanilla's sky is then not drawn.
+ pub fn has_sky(&self) -> bool {
+ self.hides_sky
+ }
+
+ /// Whether the pack has the sun and moon's program, drawing or not.
pub fn has_celestial(&self) -> bool {
- self.sky.as_ref().is_some_and(|s| s.textured.is_some())
+ self.hides_celestial
}
/// Whether the pack draws translucent terrain itself.
@@ -2164,12 +2857,50 @@ impl PackPost {
self.passes.iter().map(|p| p.name.as_str()).collect()
}
+ /// Drops every pass's cached bind groups, compute stages included: they name views that
+ /// were made again.
+ fn forget_groups(&mut self) {
+ let sky = self
+ .sky
+ .iter_mut()
+ .flat_map(|s| s.basic.iter_mut().chain(&mut s.textured));
+ let entities = self
+ .entities
+ .iter_mut()
+ .flat_map(|e| e.passes.iter_mut().chain(&mut e.shadow).flatten());
+ let passes = self
+ .passes
+ .iter_mut()
+ .chain(&mut self.terrain)
+ .chain(&mut self.water)
+ .chain(sky)
+ .chain(entities)
+ .chain(&mut self.clouds)
+ .chain(self.weather.iter_mut().flatten())
+ .chain(self.hand.iter_mut().map(|h| &mut h.0))
+ .chain(&mut self.shadow_terrain)
+ .chain(&mut self.shadow_water)
+ .chain(&mut self.shadow_comp);
+ for pass in passes {
+ pass.groups.clear();
+ for c in &mut pass.compute {
+ c.groups.clear();
+ }
+ }
+ }
+
fn resize(&mut self, device: &wgpu::Device, size: [u32; 2]) {
+ if pack_images::resize_images(device, &mut self.images, size) {
+ self.forget_groups();
+ }
+ self.buffers.resize(device, size);
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
- let full_chain = 32 - size[0].max(size[1]).max(1).leading_zeros();
- let texture_levels = |label: &str, format, usage, levels| {
+ let sizes: [[u32; 2]; BUFFERS] =
+ std::array::from_fn(|i| self.sizes[i].map_or(size, |s| s.resolve(size)));
+ let full_chain = |size: [u32; 2]| 32 - size[0].max(size[1]).max(1).leading_zeros();
+ let texture_sized = |label: &str, format, usage, levels, size: [u32; 2]| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
@@ -2185,7 +2916,7 @@ impl PackPost {
view_formats: &[],
})
};
- let texture = |label: &str, format, usage| texture_levels(label, format, usage, 1);
+ let texture = |label: &str, format, usage| texture_sized(label, format, usage, 1, size);
let level = |t: &wgpu::Texture, level: u32| {
t.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: level,
@@ -2198,10 +2929,16 @@ impl PackPost {
(self.used & 1 << i != 0).then(|| {
let label = format!("colortex{i}");
let mipped = self.mipped & 1 << i != 0;
- let levels = if mipped { full_chain } else { 1 };
- let attachment = wgpu::TextureUsages::RENDER_ATTACHMENT;
- let sides =
- [0, 1].map(|_| texture_levels(&label, self.formats[i], attachment, levels));
+ let levels = if mipped { full_chain(sizes[i]) } else { 1 };
+ let mut attachment = wgpu::TextureUsages::RENDER_ATTACHMENT;
+ if self.storage & 1 << i != 0 {
+ attachment |= wgpu::TextureUsages::STORAGE_BINDING;
+ }
+ if self.copied & 1 << i != 0 {
+ attachment |= wgpu::TextureUsages::COPY_SRC;
+ }
+ let sides = [0, 1]
+ .map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
mips[i] = Some(sides.each_ref().map(|t| {
Mips {
@@ -2247,18 +2984,29 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
+ let scratch = std::array::from_fn(|i| {
+ (self.copied & 1 << i != 0).then(|| {
+ texture_sized(
+ &format!("colortex{i} copy"),
+ self.formats[i],
+ wgpu::TextureUsages::COPY_DST,
+ 1,
+ sizes[i],
+ )
+ .create_view(&Default::default())
+ })
+ });
self.targets = Some(Targets {
size,
+ sizes,
+ scratch,
color,
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
mips,
});
- let gbuffers = self.terrain.iter_mut().chain(&mut self.water);
- for pass in self.passes.iter_mut().chain(gbuffers) {
- pass.groups.clear();
- }
+ self.forget_groups();
}
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
@@ -2297,7 +3045,16 @@ impl PackPost {
);
let mut comp = std::mem::take(&mut self.shadow_comp);
for pass in &mut comp {
- let group = self.bind(device, queue, pass, size);
+ let mut compute = std::mem::take(&mut pass.compute);
+ for c in &mut compute {
+ self.dispatch(device, queue, encoder, c, size);
+ }
+ pass.compute = compute;
+ if matches!(pass.pipeline, Pipeline::Compute { .. }) {
+ self.dispatch(device, queue, encoder, pass, size);
+ continue;
+ }
+ let group = self.bind(device, queue, encoder, pass, size);
let t = self.shadow_targets.as_ref().expect("shadow pass");
let mut attachments: Vec> = Vec::new();
for &(location, buffer) in &pass.writes {
@@ -2319,8 +3076,13 @@ impl PackPost {
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(&pass.name)),
..Default::default()
});
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.draw(0..6, 0..1);
}
if let Some(t) = &mut self.shadow_targets {
@@ -2377,10 +3139,17 @@ impl PackPost {
t.begin(device, queue);
}
self.uniforms.update(frame, inputs, size);
+ // Until the camera's biome is known, `smooth` waits for the world's values.
+ if !inputs.biome_known {
+ self.custom.restart_smoothing();
+ }
self.uniforms.eval_custom(&mut self.custom);
if self.targets.is_none() {
return;
}
+ // Images the shadow and shadowcomp programs write start the frame cleared, as every
+ // other program sees them.
+ pack_images::clear(encoder, &self.images);
self.run_shadow(device, queue, encoder, terrain);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
@@ -2422,7 +3191,16 @@ impl PackPost {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
};
- for chunk in self.other_clears.chunks(8) {
+ // Attachments of a pass share a size.
+ let mut by_size: Vec<([u32; 2], Vec)> = Vec::new();
+ for &b in &self.other_clears {
+ let at = targets.sizes[b];
+ match by_size.iter_mut().find(|(s, l)| *s == at && l.len() < 8) {
+ Some((_, l)) => l.push(b),
+ None => by_size.push((at, vec![b])),
+ }
+ }
+ for (_, chunk) in &by_size {
let attachments: Vec<_> = chunk
.iter()
.map(|&b| attach(view(self.main, b, false).expect("used buffer"), clear_op(b)))
@@ -2519,13 +3297,28 @@ impl PackPost {
seen.1 = true;
self.main ^= self.composite_pre;
}
+ let mut compute = std::mem::take(&mut pass.compute);
+ for c in &mut compute {
+ self.dispatch(device, queue, encoder, c, size);
+ }
+ pass.compute = compute;
+ if matches!(pass.pipeline, Pipeline::Compute { .. }) {
+ self.dispatch(device, queue, encoder, pass, size);
+ continue;
+ }
let stale = pass.mipmaps & self.mipped & !self.mips_valid;
for b in (0..BUFFERS).filter(|b| stale & 1 << b != 0) {
self.mipmap(encoder, b);
}
self.mips_valid |= stale;
let final_pass = pass.group == Group::Final;
- let group = self.bind(device, queue, pass, if final_pass { window } else { size });
+ // A pass writing buffers of a size of their own draws at that size (they share it).
+ let out = match pass.writes.first() {
+ _ if final_pass => window,
+ Some(&(_, b)) => self.targets.as_ref().map_or(size, |t| t.sizes[b]),
+ None => size,
+ };
+ let group = self.bind(device, queue, encoder, pass, out);
let targets = self.targets.as_ref().expect("resized");
let view = |i: usize| {
targets.color[i]
@@ -2552,8 +3345,13 @@ impl PackPost {
timestamp_writes: self.timing.as_mut().and_then(|t| t.writes(&pass.name)),
..Default::default()
});
- rp.set_pipeline(&pass.pipeline);
+ rp.set_pipeline(pass.render());
rp.set_bind_group(0, &group, &[]);
+ if pass.buffers
+ && let Some(g) = &self.buffers.group
+ {
+ rp.set_bind_group(4, g, &[]);
+ }
rp.draw(0..6, 0..1);
}
self.main ^= pass.flip;
@@ -2683,6 +3481,19 @@ mod tests {
assert_eq!(g("R11F_G11F_B10F"), T::Rg11b10Ufloat);
}
+ #[test]
+ fn samplers_read_custom_textures_of_their_type() {
+ use wgpu::TextureSampleType as T;
+ let float = T::Float { filterable: true };
+ assert!(fits("sampler2D", float));
+ assert!(fits("sampler3D", float));
+ assert!(fits("isampler2D", T::Sint));
+ assert!(fits("usampler1D", T::Uint));
+ assert!(!fits("sampler2D", T::Uint));
+ assert!(!fits("isampler2D", float));
+ assert!(!fits("sampler2DShadow", float));
+ }
+
#[test]
fn sampler_names() {
let main = |n| source(n, false, false, false);
diff --git a/crates/horizon-client-render/src/gfx/pack_textures.rs b/crates/horizon-client-render/src/gfx/pack_textures.rs
new file mode 100644
index 0000000000..61b1eab2b2
--- /dev/null
+++ b/crates/horizon-client-render/src/gfx/pack_textures.rs
@@ -0,0 +1,339 @@
+//! A shader pack's custom textures (`texture..`, `texture.noise`) on the GPU, and
+//! which of them a program's sampler reads.
+
+use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
+
+/// What a custom texture is once uploaded: an image of ours, or one the game already has.
+pub(super) enum Image {
+ View(wgpu::TextureView),
+ Lightmap,
+ Atlas,
+}
+
+pub(super) struct PackTexture {
+ pub(super) stage: String,
+ pub(super) name: String,
+ pub(super) dimension: wgpu::TextureViewDimension,
+ pub(super) sample_type: wgpu::TextureSampleType,
+ pub(super) blur: bool,
+ pub(super) clamp: bool,
+ pub(super) image: Image,
+}
+
+/// Uploads `textures`, with a note for each the game can't provide.
+pub(super) fn upload(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ textures: Vec,
+ notes: &mut Vec,
+) -> Vec {
+ let features = device.features();
+ let mut out = Vec::new();
+ for t in textures {
+ let (image, dimension, sample_type, blur, clamp) = match t.source {
+ TextureSource::Image(texture) => {
+ let (view, sample_type) = match create(device, queue, &texture, features) {
+ Ok(created) => created,
+ Err(e) => {
+ notes.push(format!("texture.{}.{}: {e}", t.stage, t.name));
+ continue;
+ }
+ };
+ let dimension = match texture.dim {
+ TextureDim::D1 => wgpu::TextureViewDimension::D1,
+ TextureDim::D2 => wgpu::TextureViewDimension::D2,
+ TextureDim::D3 => wgpu::TextureViewDimension::D3,
+ };
+ (
+ Image::View(view),
+ dimension,
+ sample_type,
+ texture.blur,
+ texture.clamp,
+ )
+ }
+ TextureSource::Resource(location) => {
+ let path = location.strip_prefix("minecraft:").unwrap_or(&location);
+ let image = if path.starts_with("dynamic/lightmap") {
+ Image::Lightmap
+ } else if path.starts_with("textures/atlas/blocks") || path == "dynamic/atlas" {
+ Image::Atlas
+ } else {
+ notes.push(format!(
+ "texture.{}.{}: `{location}` is not provided yet",
+ t.stage, t.name
+ ));
+ continue;
+ };
+ let float = wgpu::TextureSampleType::Float { filterable: true };
+ (image, wgpu::TextureViewDimension::D2, float, true, true)
+ }
+ };
+ out.push(PackTexture {
+ stage: t.stage,
+ name: t.name,
+ dimension,
+ sample_type,
+ blur,
+ clamp,
+ image,
+ });
+ }
+ out
+}
+
+/// The stage name of `texture..*` a program of `group` reads.
+fn stage(group: Group) -> &'static str {
+ match group {
+ Group::Gbuffers | Group::Translucent | Group::Shadow => "gbuffers",
+ Group::Prepare => "prepare",
+ Group::Deferred => "deferred",
+ Group::Composite | Group::Final => "composite",
+ Group::ShadowComp => "shadowcomp",
+ }
+}
+
+/// The custom texture a `ty name` sampler of a `group` program reads: same stage, the same
+/// name (or another name of the same buffer, `gaux1` for `colortex4`) and dimension. A unit
+/// may hold one texture per dimension.
+pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str) -> Option {
+ let dimension = if ty.contains("1D") {
+ wgpu::TextureViewDimension::D1
+ } else if ty.contains("3D") {
+ wgpu::TextureViewDimension::D3
+ } else {
+ wgpu::TextureViewDimension::D2
+ };
+ let buffer = horizon_shaders::buffer_index(name);
+ textures.iter().position(|t| {
+ t.stage == stage(group)
+ && t.dimension == dimension
+ && (t.name == name
+ || buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
+ })
+}
+
+/// `texture.noise`, if the pack has one.
+pub(super) fn noise(textures: &[PackTexture]) -> Option<&wgpu::TextureView> {
+ textures
+ .iter()
+ .find(|t| t.stage == "noise" && t.dimension == wgpu::TextureViewDimension::D2)
+ .and_then(|t| match &t.image {
+ Image::View(v) => Some(v),
+ _ => None,
+ })
+}
+
+/// The wgpu format a texture of `texture.format` is stored in: sampled, never rendered to, so
+/// any format whose texels hold the values.
+fn format(texture: &Texture) -> wgpu::TextureFormat {
+ use wgpu::TextureFormat as T;
+ let f = texture.format;
+ let pick = |r, rg, rgba| match texture.components() {
+ 1 => r,
+ 2 => rg,
+ _ => rgba,
+ };
+ match (f.kind, f.bits) {
+ (Kind::Unorm, b) if b <= 8 => pick(T::R8Unorm, T::Rg8Unorm, T::Rgba8Unorm),
+ (Kind::Snorm, 8) => pick(T::R8Snorm, T::Rg8Snorm, T::Rgba8Snorm),
+ (Kind::Float, 32) => pick(T::R32Float, T::Rg32Float, T::Rgba32Float),
+ (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, 16) => pick(T::R16Uint, T::Rg16Uint, T::Rgba16Uint),
+ (Kind::Uint, _) => pick(T::R32Uint, T::Rg32Uint, T::Rgba32Uint),
+ // 16-bit and packed normalized formats, half floats and the small floats: halves hold
+ // them closely enough.
+ _ => pick(T::R16Float, T::Rg16Float, T::Rgba16Float),
+ }
+}
+
+/// `texture`'s texels as the bytes of `format`, row after row.
+fn bytes(texture: &Texture, format: wgpu::TextureFormat) -> Vec {
+ match (&texture.texels, format) {
+ (Texels::Float(v), f) if f.block_copy_size(None) == Some(texture.components() as u32) => {
+ if matches!(
+ f,
+ wgpu::TextureFormat::R8Snorm
+ | wgpu::TextureFormat::Rg8Snorm
+ | wgpu::TextureFormat::Rgba8Snorm
+ ) {
+ v.iter()
+ .map(|x| (x.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8)
+ .collect()
+ } else {
+ v.iter()
+ .map(|x| (x.clamp(0.0, 1.0) * 255.0).round() as u8)
+ .collect()
+ }
+ }
+ (Texels::Float(v), f)
+ if f.block_copy_size(None) == Some(4 * texture.components() as u32) =>
+ {
+ v.iter().flat_map(|x| x.to_le_bytes()).collect()
+ }
+ (Texels::Float(v), _) => v
+ .iter()
+ .flat_map(|&x| half::f16::from_f32(x).to_le_bytes())
+ .collect(),
+ (Texels::Int(v), f) => {
+ let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
+ v.iter()
+ .flat_map(|x| x.to_le_bytes().into_iter().take(width))
+ .collect()
+ }
+ (Texels::Uint(v), f) => {
+ let width = f.block_copy_size(None).unwrap_or(4) as usize / texture.components();
+ v.iter()
+ .flat_map(|x| x.to_le_bytes().into_iter().take(width))
+ .collect()
+ }
+ }
+}
+
+fn create(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ texture: &Texture,
+ features: wgpu::Features,
+) -> Result<(wgpu::TextureView, wgpu::TextureSampleType), String> {
+ let format = format(texture);
+ let bytes = bytes(texture, format);
+ let [width, height, depth] = texture.size;
+ let limits = device.limits();
+ let largest = match texture.dim {
+ TextureDim::D1 => limits.max_texture_dimension_1d,
+ TextureDim::D2 => limits.max_texture_dimension_2d,
+ TextureDim::D3 => limits.max_texture_dimension_3d,
+ };
+ if [width, height, depth].iter().any(|&n| n > largest) {
+ return Err(format!(
+ "{width}x{height}x{depth} is larger than this device's {largest}"
+ ));
+ }
+ let row = width * format.block_copy_size(None).unwrap_or(4);
+ if bytes.len() as u64 != u64::from(row) * u64::from(height) * u64::from(depth) {
+ return Err(format!(
+ "{} bytes of texels for {width}x{height}x{depth}",
+ bytes.len()
+ ));
+ }
+ let size = wgpu::Extent3d {
+ width,
+ height,
+ depth_or_array_layers: depth,
+ };
+ let dimension = match texture.dim {
+ TextureDim::D1 => wgpu::TextureDimension::D1,
+ TextureDim::D2 => wgpu::TextureDimension::D2,
+ TextureDim::D3 => wgpu::TextureDimension::D3,
+ };
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("Shader pack custom texture"),
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension,
+ format,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+ queue.write_texture(
+ tex.as_image_copy(),
+ &bytes,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(row),
+ rows_per_image: Some(height),
+ },
+ size,
+ );
+ let sample_type = format
+ .sample_type(None, Some(features))
+ .unwrap_or(wgpu::TextureSampleType::Float { filterable: false });
+ Ok((tex.create_view(&Default::default()), sample_type))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use horizon_shaders::Format;
+
+ fn lightmap(stage: &str, name: &str, dimension: wgpu::TextureViewDimension) -> PackTexture {
+ PackTexture {
+ stage: stage.into(),
+ name: name.into(),
+ dimension,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ blur: false,
+ clamp: false,
+ image: Image::Lightmap,
+ }
+ }
+
+ fn texture(format: &str, texels: Texels) -> Texture {
+ Texture {
+ dim: TextureDim::D2,
+ size: [1, 1, 1],
+ format: Format::parse(format).unwrap(),
+ texels,
+ blur: false,
+ clamp: false,
+ }
+ }
+
+ #[test]
+ fn samplers_find_their_stage_name_and_dimension() {
+ use wgpu::TextureViewDimension as D;
+ let textures = [
+ lightmap("composite", "colortex4", D::D2),
+ lightmap("composite", "noisetex", D::D3),
+ lightmap("gbuffers", "gaux2", D::D2),
+ lightmap("composite", "noisetex", D::D1),
+ ];
+ assert_eq!(find(&textures, Group::Final, "sampler2D", "gaux1"), Some(0));
+ assert_eq!(
+ find(&textures, Group::Composite, "sampler3D", "noisetex"),
+ Some(1)
+ );
+ assert_eq!(
+ find(&textures, Group::Composite, "sampler1D", "noisetex"),
+ Some(3)
+ );
+ assert_eq!(
+ find(&textures, Group::Composite, "sampler2D", "noisetex"),
+ None
+ );
+ assert_eq!(
+ find(&textures, Group::Shadow, "sampler2D", "colortex5"),
+ Some(2)
+ );
+ assert_eq!(
+ find(&textures, Group::Deferred, "sampler2D", "colortex4"),
+ None
+ );
+ }
+
+ #[test]
+ fn texels_pack_into_their_format() {
+ let t = texture("RGBA8", Texels::Float(vec![0.0, 0.5, 1.0, 2.0]));
+ assert_eq!(format(&t), wgpu::TextureFormat::Rgba8Unorm);
+ assert_eq!(bytes(&t, format(&t)), [0, 128, 255, 255]);
+ let t = texture("RG8_SNORM", Texels::Float(vec![-1.0, 0.5]));
+ assert_eq!(format(&t), wgpu::TextureFormat::Rg8Snorm);
+ assert_eq!(bytes(&t, format(&t)), [0x81, 64]);
+ let t = texture("R32F", Texels::Float(vec![1.0]));
+ assert_eq!(bytes(&t, format(&t)), 1.0f32.to_le_bytes());
+ let t = texture("R16F", Texels::Float(vec![1.0]));
+ assert_eq!(format(&t), wgpu::TextureFormat::R16Float);
+ assert_eq!(bytes(&t, format(&t)), [0x00, 0x3c]);
+ let t = texture("RG16I", Texels::Int(vec![-2, 3]));
+ assert_eq!(format(&t), wgpu::TextureFormat::Rg16Sint);
+ assert_eq!(bytes(&t, format(&t)), [0xfe, 0xff, 3, 0]);
+ let t = texture("R8UI", Texels::Uint(vec![7]));
+ assert_eq!(format(&t), wgpu::TextureFormat::R8Uint);
+ assert_eq!(bytes(&t, format(&t)), [7]);
+ }
+}
diff --git a/crates/horizon-client-render/src/gfx/pack_uniforms.rs b/crates/horizon-client-render/src/gfx/pack_uniforms.rs
index 6203ab04b6..486148d05e 100644
--- a/crates/horizon-client-render/src/gfx/pack_uniforms.rs
+++ b/crates/horizon-client-render/src/gfx/pack_uniforms.rs
@@ -30,6 +30,8 @@ pub struct PackInputs {
pub biome_precipitation: i32,
pub temperature: f32,
pub rainfall: f32,
+ /// Whether the biome at the camera is known (its chunk is loaded).
+ pub biome_known: bool,
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
@@ -284,14 +286,15 @@ impl FrameUniforms {
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);
+ // Packs take gbufferProjection for a plain perspective (they invert only its perspective
+ // terms): the view bob and the nausea spin belong to the model-view, as GL drew them.
+ let view = frame.bob * frame.spin * 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
- * frame.spin;
+ );
let camera = frame.camera.as_vec3();
let (previous_camera, previous_view, previous_projection) =
self.previous
diff --git a/crates/horizon-client-render/src/gfx/resident.rs b/crates/horizon-client-render/src/gfx/resident.rs
index 850871948a..020f6254e2 100644
--- a/crates/horizon-client-render/src/gfx/resident.rs
+++ b/crates/horizon-client-render/src/gfx/resident.rs
@@ -2,7 +2,9 @@
//! per-frame culling walk: a column whose box misses the frustum or lies past
//! the render distance rejects all of its sections with one test, and the
//! sections that remain are reached by index rather than by hashing keys.
-use super::{Frustum, SectionKey};
+#[cfg(test)]
+use super::Frustum;
+use super::{Cull, SectionKey};
use crate::{fast_hash::FxHashMap, streaming::in_view_distance};
use glam::{DVec3, Vec3};
@@ -226,7 +228,7 @@ fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
- frustum: &Frustum,
+ frustum: &impl Cull,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
@@ -258,7 +260,7 @@ impl Resident {
&self,
camera: DVec3,
radius: i32,
- frustum: &Frustum,
+ frustum: &impl Cull,
reachable: impl Fn(SectionKey) -> bool,
beyond: impl Fn(i32, i32) -> bool,
out: &mut Vec,
diff --git a/crates/horizon-client-render/src/graphics_style.rs b/crates/horizon-client-render/src/graphics_style.rs
index 2d9851099f..eb74309798 100644
--- a/crates/horizon-client-render/src/graphics_style.rs
+++ b/crates/horizon-client-render/src/graphics_style.rs
@@ -147,6 +147,34 @@ impl Style {
dragging && active,
);
}
+ pub fn slider_ext(
+ &self,
+ batch: &mut HudBatch,
+ atlas: &HudAtlas,
+ (x, y, w): (f32, f32, f32),
+ label: &Text,
+ value: f32,
+ hover: bool,
+ focused: bool,
+ can_change_value: bool,
+ active: bool,
+ dragging: bool,
+ ) {
+ ui::slider_ext(
+ batch,
+ atlas,
+ x,
+ y,
+ w,
+ label,
+ value,
+ hover && active,
+ focused,
+ can_change_value,
+ active,
+ dragging && active,
+ );
+ }
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
diff --git a/crates/horizon-client-render/src/graphics_ui.rs b/crates/horizon-client-render/src/graphics_ui.rs
index 603dea4ec7..c9bd264ba7 100644
--- a/crates/horizon-client-render/src/graphics_ui.rs
+++ b/crates/horizon-client-render/src/graphics_ui.rs
@@ -474,6 +474,19 @@ impl H {
_ => None,
}
}
+ pub fn slider_steps(self) -> Option {
+ match self {
+ H::ChunkUpdates => Some(64),
+ H::UploadBudget => Some((UPLOAD_STEPS.len() - 1) as u32),
+ H::Workers => Some(max_workers()),
+ H::ResolutionScale => Some(30),
+ H::HdrPaperWhite => Some(41),
+ H::HdrPeak => Some(37),
+ H::HdrBlackLevel => Some(100),
+ H::HdrUiBrightness => Some(15),
+ _ => None,
+ }
+ }
fn label(self, s: &Settings) -> Text {
let g = &s.horizon;
match self {
@@ -777,6 +790,39 @@ impl Item {
s.horizon.preset = Preset::Custom;
}
}
+ pub fn slider_steps(self) -> Option {
+ match self {
+ Item::Vanilla(o) => o.slider_steps(),
+ Item::Horizon(h) => h.slider_steps(),
+ }
+ }
+ pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
+ if !self.available() || self.blocked(s).is_some() {
+ return;
+ }
+ let Some(pos) = self.slider(s) else {
+ return;
+ };
+ let next_pos = if let Some(steps) = self.slider_steps() {
+ let steps = steps as f32;
+ let current = (pos * steps).round();
+ let next = if forward {
+ current + 1.0
+ } else {
+ current - 1.0
+ };
+ (next / steps).clamp(0.0, 1.0)
+ } else {
+ let delta = 1.0 / (width - 8.0).max(1.0);
+ if forward {
+ (pos + delta).min(1.0)
+ } else {
+ (pos - delta).max(0.0)
+ }
+ };
+ self.set_slider(s, next_pos);
+ self.touched(s);
+ }
}
/// What the options screens must do after an interaction here.
@@ -795,8 +841,8 @@ pub enum Action {
Done,
}
-#[derive(Clone, Copy)]
-enum Target {
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Target {
Item(Item),
Button(Action),
}
@@ -814,6 +860,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option- ,
+ pub focus: Option
,
+ pub can_change_value: bool,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
@@ -822,6 +870,8 @@ impl GraphicsUi {
pub fn reset(&mut self) {
self.scroll = ui::Scroll::default();
self.dragging = None;
+ self.focus = None;
+ self.can_change_value = false;
}
/// The scrollbar's column: right of the descriptions.
fn scrollbar_x(w: f32) -> f32 {
@@ -939,8 +989,10 @@ impl GraphicsUi {
let mut index = 0;
let mut tooltip = None;
for wd in &widgets {
- let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
- && (!wd.list || (cursor.1 >= top && cursor.1 < bottom));
+ let is_focused = self.focus == Some(wd.target);
+ let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
+ && (!wd.list || (cursor.1 >= top && cursor.1 < bottom)))
+ || is_focused;
match wd.target {
Target::Item(item) => {
let (cost, text) = descriptions[index];
@@ -961,13 +1013,15 @@ impl GraphicsUi {
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
- Some(value) => STYLE.slider(
+ Some(value) => STYLE.slider_ext(
batch,
atlas,
(wd.x, wd.y, wd.w),
&label,
value,
hover || self.dragging == Some(item),
+ is_focused,
+ self.can_change_value && is_focused,
live,
self.dragging == Some(item),
),
@@ -1072,11 +1126,16 @@ impl GraphicsUi {
if wd.list && (y < top || y >= bottom || wd.y < top || wd.y + 20.0 > bottom) {
continue;
}
+ self.focus = Some(wd.target);
return match wd.target {
- Target::Button(action) => vec![Action::ButtonClick, action],
+ Target::Button(action) => {
+ self.can_change_value = false;
+ vec![Action::ButtonClick, action]
+ }
// A disabled control does nothing: no sound, no preset change.
Target::Item(item) if item.blocked(s).is_some() => Vec::new(),
Target::Item(Item::Horizon(H::HdrSettings)) => {
+ self.can_change_value = false;
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
@@ -1095,10 +1154,12 @@ impl GraphicsUi {
}
Target::Item(item) => {
if item.slider(s).is_some() {
+ self.can_change_value = true;
self.dragging = Some(item);
item.set_slider(s, ui::slider_value(wd.x, wd.w, x));
vec![Action::SliderDrag]
} else {
+ self.can_change_value = false;
item.cycle(s, right, framebuffer);
vec![Action::ButtonClick, Action::Changed]
}
@@ -1118,6 +1179,246 @@ impl GraphicsUi {
self.scroll.clamp(top, bottom, content);
self.scroll.wheel(amount, 18.0, top, bottom, content);
}
+
+ /// Mouse wheel over a control cycles it (cycle button). Sliders and disabled
+ /// rows fall through to scroll the page, matching Vanilla list behavior.
+ /// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
+ /// cycle item is keyboard-focused (`self.focus`), the wheel steps/cycles it
+ /// regardless of cursor position. Vanilla 26.3 does not intercept the
+ /// wheel for sliders at all.
+ pub fn wheel(
+ &mut self,
+ level: Level,
+ s: &mut Settings,
+ cursor: (f32, f32),
+ (w, h): (f32, f32),
+ framebuffer: [i32; 2],
+ amount: f32,
+ ) -> Vec {
+ // Horizon improvement: focused slider/cycle intercepts wheel first.
+ if amount != 0.0
+ && self.can_change_value
+ && let Some(Target::Item(item)) = self.focus
+ && item.available()
+ && item.blocked(s).is_none()
+ {
+ if item.slider(s).is_some() {
+ item.step_slider(s, amount > 0.0, w);
+ return vec![Action::SliderDrag, Action::Changed];
+ } else if !matches!(item, Item::Horizon(H::HdrSettings)) {
+ item.cycle(s, amount > 0.0, framebuffer);
+ return vec![Action::ButtonClick, Action::Changed];
+ }
+ }
+ let (widgets, _, _) = self.layout(level, s, w, h);
+ let (top, bottom) = Self::bounds(h);
+ let hovered = widgets.iter().find(|wd| {
+ ui::hovered(cursor, wd.x, wd.y, wd.w, 20.0)
+ && (!wd.list
+ || (cursor.1 >= top
+ && cursor.1 < bottom
+ && wd.y >= top
+ && wd.y + 20.0 <= bottom))
+ });
+ if let Some(wd) = hovered
+ && let Target::Item(item) = wd.target
+ && item.available()
+ && item.blocked(s).is_none()
+ && item.slider(s).is_none()
+ && !matches!(item, Item::Horizon(H::HdrSettings))
+ {
+ if amount != 0.0 {
+ item.cycle(s, amount > 0.0, framebuffer);
+ return vec![Action::ButtonClick, Action::Changed];
+ }
+ return Vec::new();
+ }
+ self.scroll(level, s, h, amount);
+ Vec::new()
+ }
+
+ /// Keyboard navigation and value adjustments.
+ pub fn key_modifiers(
+ &mut self,
+ level: Level,
+ s: &mut Settings,
+ key: winit::keyboard::KeyCode,
+ shift: bool,
+ (w, h): (f32, f32),
+ framebuffer: [i32; 2],
+ ) -> (Vec, bool) {
+ use winit::keyboard::KeyCode as K;
+ let (widgets, _, content) = self.layout(level, s, w, h);
+ let (top, bottom) = Self::bounds(h);
+ let focusables: Vec<(Target, [f32; 4], bool)> = widgets
+ .iter()
+ .filter_map(|wd| match wd.target {
+ Target::Item(item) if item.available() && item.blocked(s).is_none() => {
+ Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list))
+ }
+ Target::Button(_) => Some((wd.target, [wd.x, wd.y, wd.w, 20.0], wd.list)),
+ _ => None,
+ })
+ .collect();
+
+ if focusables.is_empty() {
+ return (Vec::new(), false);
+ }
+
+ // 1. Tab / Shift+Tab navigation
+ if key == K::Tab {
+ let cur = self
+ .focus
+ .and_then(|f| focusables.iter().position(|&(t, ..)| t == f));
+ let next_idx = match cur {
+ Some(i) => {
+ if shift {
+ if i == 0 { focusables.len() - 1 } else { i - 1 }
+ } else {
+ (i + 1) % focusables.len()
+ }
+ }
+ None => {
+ if shift {
+ focusables.len() - 1
+ } else {
+ 0
+ }
+ }
+ };
+ let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
+ self.focus = Some(target);
+ let is_slider = matches!(target, Target::Item(item) if item.slider(s).is_some());
+ self.can_change_value = is_slider;
+ if is_list {
+ if wy < top + 4.0 {
+ self.scroll.amount -= top + 4.0 - wy;
+ } else if wy + wh > bottom - 4.0 {
+ self.scroll.amount += wy + wh - (bottom - 4.0);
+ }
+ self.clamp_scroll(h, content);
+ }
+ return (Vec::new(), true);
+ }
+
+ // 2. Left / Right Arrow keys
+ if matches!(key, K::ArrowLeft | K::ArrowRight) {
+ let forward = key == K::ArrowRight;
+ if let Some(Target::Item(item)) = self.focus
+ && item.slider(s).is_some()
+ && self.can_change_value
+ {
+ let width = widgets
+ .iter()
+ .find(|wd| wd.target == Target::Item(item))
+ .map_or(STYLE.control_width, |wd| wd.w);
+ item.step_slider(s, forward, width);
+ return (vec![Action::SliderDrag], true);
+ }
+ if let Some(cur_target) = self.focus
+ && let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
+ {
+ let from = focusables[cur_pos].1;
+ let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
+ let dir = if forward {
+ crate::gamepad::Button::DPadRight
+ } else {
+ crate::gamepad::Button::DPadLeft
+ };
+ if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
+ let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
+ self.focus = Some(target);
+ self.can_change_value = false;
+ if is_list {
+ if wy < top + 4.0 {
+ self.scroll.amount -= top + 4.0 - wy;
+ } else if wy + wh > bottom - 4.0 {
+ self.scroll.amount += wy + wh - (bottom - 4.0);
+ }
+ self.clamp_scroll(h, content);
+ }
+ return (Vec::new(), true);
+ }
+ }
+ return (Vec::new(), false);
+ }
+
+ // 3. Up / Down Arrow keys
+ if matches!(key, K::ArrowUp | K::ArrowDown) {
+ let down = key == K::ArrowDown;
+ if let Some(cur_target) = self.focus
+ && let Some(cur_pos) = focusables.iter().position(|&(t, ..)| t == cur_target)
+ {
+ let from = focusables[cur_pos].1;
+ let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
+ let dir = if down {
+ crate::gamepad::Button::DPadDown
+ } else {
+ crate::gamepad::Button::DPadUp
+ };
+ if let Some(next_idx) = crate::gamepad::focus_step_rect(from, dir, &rects) {
+ let (target, [_, wy, _, wh], is_list) = focusables[next_idx];
+ self.focus = Some(target);
+ self.can_change_value = false;
+ if is_list {
+ if wy < top + 4.0 {
+ self.scroll.amount -= top + 4.0 - wy;
+ } else if wy + wh > bottom - 4.0 {
+ self.scroll.amount += wy + wh - (bottom - 4.0);
+ }
+ self.clamp_scroll(h, content);
+ }
+ return (Vec::new(), true);
+ }
+ } else if !focusables.is_empty() {
+ let first = if down { 0 } else { focusables.len() - 1 };
+ let (target, [_, wy, _, wh], is_list) = focusables[first];
+ self.focus = Some(target);
+ self.can_change_value = false;
+ if is_list {
+ if wy < top + 4.0 {
+ self.scroll.amount -= top + 4.0 - wy;
+ } else if wy + wh > bottom - 4.0 {
+ self.scroll.amount += wy + wh - (bottom - 4.0);
+ }
+ self.clamp_scroll(h, content);
+ }
+ return (Vec::new(), true);
+ }
+ return (Vec::new(), false);
+ }
+
+ // 4. Space / Enter / NumpadEnter
+ if matches!(key, K::Space | K::Enter | K::NumpadEnter)
+ && let Some(target) = self.focus
+ {
+ match target {
+ Target::Item(item) => {
+ if item.slider(s).is_some() {
+ self.can_change_value = !self.can_change_value;
+ return (Vec::new(), true);
+ } else if item == Item::Horizon(H::HdrSettings) {
+ return (vec![Action::ButtonClick, Action::OpenHdr], true);
+ } else {
+ item.cycle(s, shift, framebuffer);
+ return (vec![Action::ButtonClick, Action::Changed], true);
+ }
+ }
+ Target::Button(action) => {
+ return (vec![Action::ButtonClick, action], true);
+ }
+ }
+ }
+
+ // 5. Escape: the screen closes on the first press, as `Screen.keyPressed` handles it
+ // before the focused slider.
+ if key == K::Escape {
+ self.can_change_value = false;
+ return (Vec::new(), false);
+ }
+
+ (Vec::new(), false)
+ }
}
#[cfg(test)]
@@ -1514,4 +1815,159 @@ mod tests {
}
assert_eq!(item.label(&s).plain(), "Entity Culling: On");
}
+
+ #[test]
+ fn graphics_slider_keyboard_navigation_and_selection_toggle_parity() {
+ use winit::keyboard::KeyCode as K;
+ let mut s = Settings::default();
+ let mut ui = GraphicsUi::default();
+ let size = (480.0, 600.0);
+ let fb = [854, 480];
+
+ s.horizon.chunk_updates = 10;
+ s.horizon.resolution_scale = 100;
+
+ // 1. Tab navigates down to Chunk Updates slider (in Level::Advanced)
+ let mut found_chunk_updates = false;
+ for _ in 0..15 {
+ ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
+ if ui.focus == Some(Target::Item(Item::Horizon(H::ChunkUpdates))) {
+ found_chunk_updates = true;
+ break;
+ }
+ }
+ assert!(found_chunk_updates);
+ assert!(
+ ui.can_change_value,
+ "Tab onto slider activates can_change_value"
+ );
+
+ // 2. Right Arrow steps chunk updates from 10 to 11
+ let (actions, handled) =
+ ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
+ assert!(handled);
+ assert_eq!(actions, vec![Action::SliderDrag]);
+ assert_eq!(s.horizon.chunk_updates, 11);
+
+ // 3. Left Arrow steps back to 10
+ ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
+ assert_eq!(s.horizon.chunk_updates, 10);
+
+ // 4. Space toggles can_change_value to false
+ ui.key_modifiers(Level::Advanced, &mut s, K::Space, false, size, fb);
+ assert!(!ui.can_change_value);
+
+ // 5. Space toggles can_change_value back to true
+ ui.key_modifiers(Level::Advanced, &mut s, K::Enter, false, size, fb);
+ assert!(ui.can_change_value);
+
+ // 6. Escape is left to the screen, which closes, even while a slider changes values
+ let (actions, handled) =
+ ui.key_modifiers(Level::Advanced, &mut s, K::Escape, false, size, fb);
+ assert!(!handled);
+ assert!(actions.is_empty());
+ assert!(!ui.can_change_value);
+
+ // 8. Tab to ResolutionScale (steps by 5%)
+ let mut found_res = false;
+ for _ in 0..20 {
+ ui.key_modifiers(Level::Advanced, &mut s, K::Tab, false, size, fb);
+ if ui.focus == Some(Target::Item(Item::Horizon(H::ResolutionScale))) {
+ found_res = true;
+ break;
+ }
+ }
+ assert!(found_res);
+ assert!(ui.can_change_value);
+ ui.key_modifiers(Level::Advanced, &mut s, K::ArrowRight, false, size, fb);
+ assert_eq!(s.horizon.resolution_scale, 105);
+ ui.key_modifiers(Level::Advanced, &mut s, K::ArrowLeft, false, size, fb);
+ assert_eq!(s.horizon.resolution_scale, 100);
+ }
+
+ #[test]
+ fn graphics_cycle_wheel_cycles_and_slider_falls_through_to_scroll() {
+ let mut s = Settings::default();
+ let mut ui = GraphicsUi::default();
+ let size = (480.0, 300.0);
+ let fb = [854, 480];
+
+ // 1. Wheel on a Cycle button: Far Detail cycles mode
+ let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
+ let far = widgets
+ .iter()
+ .find(|w| matches!(w.target, Target::Item(Item::Horizon(H::FarDetail))))
+ .unwrap();
+ let cursor = (far.x + 5.0, far.y + 5.0);
+ s.horizon.far_detail = FarDetail::Full;
+ let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
+ assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
+ assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
+
+ // 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
+ s.horizon.chunk_updates = 16;
+ let (widgets, _, _) = ui.layout(Level::Advanced, &s, size.0, size.1);
+ let cu = widgets
+ .iter()
+ .find(|w| matches!(w.target, Target::Item(Item::Horizon(H::ChunkUpdates))))
+ .unwrap();
+ let cursor = (cu.x + 5.0, cu.y + 5.0);
+ let scroll_before = ui.scroll.amount;
+ let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
+ assert!(actions.is_empty());
+ assert_eq!(s.horizon.chunk_updates, 16);
+ assert!(ui.scroll.amount > scroll_before);
+ }
+
+ #[test]
+ fn graphics_step_slider_matches_set_slider_for_all_options() {
+ let options = [
+ H::ChunkUpdates,
+ H::UploadBudget,
+ H::Workers,
+ H::ResolutionScale,
+ H::HdrPaperWhite,
+ H::HdrPeak,
+ H::HdrBlackLevel,
+ H::HdrUiBrightness,
+ ];
+ for h in options {
+ let item = Item::Horizon(h);
+ let steps = h.slider_steps().expect("steps defined");
+ for i in 0..steps {
+ let pos = i as f32 / steps as f32;
+ let mut s1 = Settings::default();
+ let mut s2 = Settings::default();
+ s1.horizon.hdr = true;
+ s2.horizon.hdr = true;
+ h.set_slider(&mut s1, pos);
+
+ item.step_slider(&mut s1, true, 200.0);
+ let next_pos = (i + 1) as f32 / steps as f32;
+ h.set_slider(&mut s2, next_pos);
+ assert_eq!(
+ item.slider(&s1),
+ item.slider(&s2),
+ "{h:?} step forward from {i}"
+ );
+ }
+ for i in 1..=steps {
+ let pos = i as f32 / steps as f32;
+ let mut s1 = Settings::default();
+ let mut s2 = Settings::default();
+ s1.horizon.hdr = true;
+ s2.horizon.hdr = true;
+ h.set_slider(&mut s1, pos);
+
+ item.step_slider(&mut s1, false, 200.0);
+ let prev_pos = (i - 1) as f32 / steps as f32;
+ h.set_slider(&mut s2, prev_pos);
+ assert_eq!(
+ item.slider(&s1),
+ item.slider(&s2),
+ "{h:?} step backward from {i}"
+ );
+ }
+ }
+ }
}
diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs
index 400922d388..c8fb46f62b 100644
--- a/crates/horizon-client-render/src/hud.rs
+++ b/crates/horizon-client-render/src/hud.rs
@@ -1898,6 +1898,38 @@ impl HudRenderer {
*previous = state;
}
}
+ /// Stage `patches` into the shared atlas texture.
+ pub fn upload_patches(
+ &self,
+ uploads: &mut crate::gfx::upload::Uploads,
+ encoder: &mut wgpu::CommandEncoder,
+ patches: &[crate::font::Patch],
+ ) {
+ if let Some(texture) = &self.atlas_texture {
+ for p in patches {
+ if p.x.saturating_add(p.width) <= texture.width()
+ && p.y.saturating_add(p.height) <= texture.height()
+ {
+ uploads.texture(
+ encoder,
+ wgpu::TexelCopyTextureInfo {
+ texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d {
+ x: p.x,
+ y: p.y,
+ z: 0,
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ p.width,
+ p.height,
+ &p.rgba,
+ );
+ }
+ }
+ }
+ }
/// Stage the batch's atlas patches, vertices and uniform: before the
/// frame's first pass, so the copies share one command buffer.
pub fn prepare(
@@ -1908,26 +1940,7 @@ impl HudRenderer {
batch: &HudBatch,
size: [u32; 2],
) {
- if let Some(texture) = &self.atlas_texture {
- for p in &batch.patches {
- uploads.texture(
- encoder,
- wgpu::TexelCopyTextureInfo {
- texture,
- mip_level: 0,
- origin: wgpu::Origin3d {
- x: p.x,
- y: p.y,
- z: 0,
- },
- aspect: wgpu::TextureAspect::All,
- },
- p.width,
- p.height,
- &p.rgba,
- );
- }
- }
+ self.upload_patches(uploads, encoder, &batch.patches);
if batch.vertices.is_empty() || size.contains(&0) {
return;
}
diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs
index 7072bf5d52..a56c9291f6 100644
--- a/crates/horizon-client-render/src/lib.rs
+++ b/crates/horizon-client-render/src/lib.rs
@@ -54,6 +54,7 @@ pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
+pub mod native_emissive;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
diff --git a/crates/horizon-client-render/src/lod/mesh.rs b/crates/horizon-client-render/src/lod/mesh.rs
index 65c379b1fd..96abd0c06a 100644
--- a/crates/horizon-client-render/src/lod/mesh.rs
+++ b/crates/horizon-client-render/src/lod/mesh.rs
@@ -2,7 +2,7 @@
use super::{Column, Span, TileKey};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
- mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex},
+ mesher::{OPAQUE_LAYER, SectionMesh, TerrainVertex, fluid_face},
streaming::WorkerContext,
};
use horizon_assets::model::Direction;
@@ -253,6 +253,13 @@ type Faces = BTreeMap>;
fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, material: Material) {
for (overlay, paint) in material.faces.into_iter().enumerate() {
let Some(paint) = paint else { continue };
+ // The same fluid flags the section mesher sets, so the material stage's water
+ // reaches the distant tiles too and the LOD boundary keeps one look.
+ let color = if material.fluid != 0 {
+ fluid_face(paint.color, direction as usize)
+ } else {
+ paint.color
+ };
faces
.entry(FaceKey {
direction,
@@ -260,7 +267,7 @@ fn add_face(faces: &mut Faces, direction: Direction, plane: f32, rect: Rect, mat
overlay: overlay as u8,
layer: paint.layer,
light: paint.light,
- color: paint.color,
+ color,
translucent: paint.translucent,
})
.or_default()
diff --git a/crates/horizon-client-render/src/mesher.rs b/crates/horizon-client-render/src/mesher.rs
index 7ce2fda1ed..9ed34d1be3 100644
--- a/crates/horizon-client-render/src/mesher.rs
+++ b/crates/horizon-client-render/src/mesher.rs
@@ -147,6 +147,36 @@ mod vertex_tests {
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
+ #[test]
+ fn fluid_faces_keep_the_colour_and_the_emission() {
+ let lit = super::emitting(super::pack_rgb([0x12, 0x34, 0x56]), super::INCANDESCENT);
+ assert_eq!(super::fluid_face(lit, 0), 0x8D56_3412);
+ assert_eq!(super::fluid_face(lit, 5), 0xDD56_3412);
+ // Another face replaces the first.
+ assert_eq!(super::fluid_face(super::fluid_face(lit, 5), 1), 0x9D56_3412);
+ }
+ /// What `gfx/native_material.rs` reads back from the colour's alpha byte: the face of a
+ /// fluid quad, and the HDR tier.
+ fn decoded(color: u32) -> (Option, u32) {
+ let flags = color >> 24;
+ let high = flags >> 4;
+ let fluid = high >= 8 && (high & 7) < 6;
+ (fluid.then_some(high & 7), 15 - (flags & 15))
+ }
+ #[test]
+ fn the_material_stage_reads_back_what_the_mesher_encodes() {
+ let rgb = super::pack_rgb([0x12, 0x34, 0x56]);
+ for tier in 0..=super::VERY_BRIGHT {
+ let lit = super::emitting(rgb, tier);
+ assert_eq!(decoded(lit), (None, u32::from(tier)));
+ for face in 0..6 {
+ assert_eq!(
+ decoded(super::fluid_face(lit, face)),
+ (Some(face as u32), u32::from(tier))
+ );
+ }
+ }
+ }
}
#[cfg(test)]
@@ -794,7 +824,8 @@ mod reuse_tests;
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
/// in HDR output (`gfx/hdr.rs`): 15 minus the tier, so the usual 0xFF means none. The
-/// byte's other four bits stay set.
+/// byte's high nibble carries the fluid face flags (`fluid_face`); the shaders ignore the
+/// vertex alpha, so both travel to the material stage untouched.
pub const EMISSION_BITS: u32 = 0x0F00_0000;
/// HDR tiers, by the kind of light rather than its level: a candle's flame is as bright as
/// a torch's, it only lights less around it. 0 stays as in SDR.
@@ -807,6 +838,17 @@ pub const VERY_BRIGHT: u8 = 4;
pub fn emitting(color: u32, tier: u8) -> u32 {
color & !EMISSION_BITS | u32::from(15 - tier.min(VERY_BRIGHT)) << 24
}
+/// The high nibble of `TerrainVertex::color`'s alpha byte: 8 plus the face (`Direction`
+/// as usize) on a fluid quad, 0xF on every other. `terrain.wgsl` reads only the low nibble
+/// (the emission), so only the material stage of Horizon's own packs reads it
+/// (`gfx/native_material.rs`); whether the fluid is water or lava follows from the pass
+/// that draws it.
+pub const FLUID_FACE_BITS: u32 = 0xF000_0000;
+/// `color` marked as a fluid quad of face `face` (`Direction` as usize, 0 to 5).
+pub fn fluid_face(color: u32, face: usize) -> u32 {
+ debug_assert!(face < 6);
+ color & !FLUID_FACE_BITS | 0x8000_0000 | (face as u32) << 28
+}
/// The HDR tier of block `name` (no namespace), which emits light `level`.
pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
diff --git a/crates/horizon-client-render/src/mesher/fluid.rs b/crates/horizon-client-render/src/mesher/fluid.rs
index a230c87c4b..6e69ef467a 100644
--- a/crates/horizon-client-render/src/mesher/fluid.rs
+++ b/crates/horizon-client-render/src/mesher/fluid.rs
@@ -3,7 +3,8 @@
//! beside glass and leaves, back faces, cardinal shading and max(self, above)
//! light.
use super::{
- Info, MeshTables, Padded, SectionMesh, TerrainVertex, lighting, pack_coords, pack_rgb, pi,
+ Info, MeshTables, Padded, SectionMesh, TerrainVertex, fluid_face, lighting, pack_coords,
+ pack_rgb, pi,
};
use horizon_core::{
geometry::{Axis, Direction, VoxelShape, trig},
@@ -319,14 +320,19 @@ pub(super) fn tesselate(
} else {
&mut out.solid
};
- let mut quad =
- |v: [([f32; 3], [f32; 2]); 4], layer: u16, color: u32, light: u16, back: bool| {
- let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
- target.extend(v.map(vertex));
- if back {
- target.extend([v[0], v[3], v[2], v[1]].map(vertex));
- }
- };
+ let mut quad = |v: [([f32; 3], [f32; 2]); 4],
+ layer: u16,
+ color: u32,
+ light: u16,
+ back: bool,
+ face: usize| {
+ let color = fluid_face(color, face);
+ let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
+ target.extend(v.map(vertex));
+ if back {
+ target.extend([v[0], v[3], v[2], v[1]].map(vertex));
+ }
+ };
let (fx, fy, fz) = (x as f32, y as f32, z as f32);
let bottom = if render_down { EPSILON } else { 0.0 };
if render_up && !occluded_by(Direction::Up, nw.min(sw).min(se.min(ne)), up) {
@@ -375,6 +381,7 @@ pub(super) fn tesselate(
color(cardinal(D::Up)),
light([x, y, z]),
backward,
+ Direction::Up as usize,
);
}
if render_down {
@@ -390,6 +397,7 @@ pub(super) fn tesselate(
color(cardinal(D::Down)),
light([x, y - 1, z]),
false,
+ Direction::Down as usize,
);
}
let side_light = light([x, y, z]);
@@ -444,6 +452,7 @@ pub(super) fn tesselate(
color(up_shade * side),
side_light,
!is_overlay,
+ d as usize,
);
}
}
diff --git a/crates/horizon-client-render/src/mesher/sides.rs b/crates/horizon-client-render/src/mesher/sides.rs
index 797d60127b..bfb3a4066d 100644
--- a/crates/horizon-client-render/src/mesher/sides.rs
+++ b/crates/horizon-client-render/src/mesher/sides.rs
@@ -74,7 +74,13 @@ impl Sides {
/// Calls `draw(first, count)` for each run of quads an eye at `eye`
/// (relative to the mesh's origin) can see the front of, nearest the
/// start first. Empty buckets do not break a run.
- pub fn visible(&self, eye: [f32; 3], mut draw: impl FnMut(u32, u32)) {
+ pub fn visible(&self, eye: [f32; 3], draw: impl FnMut(u32, u32)) {
+ self.visible_from(eye, eye[1], draw);
+ }
+
+ /// `visible` for every eye from `eye` straight down to the height `low`: the camera and
+ /// its water mirror's eye below the plane, which differ only in y.
+ pub fn visible_from(&self, eye: [f32; 3], low: f32, mut draw: impl FnMut(u32, u32)) {
let mut start = 0;
let mut run: Option = None;
for (i, bucket) in BUCKETS.iter().enumerate() {
@@ -84,6 +90,7 @@ impl Sides {
}
let seen = match *bucket {
Bucket::Side(axis, true) => eye[axis] > self.planes[i] - MARGIN,
+ Bucket::Side(1, false) => low < self.planes[i] + MARGIN,
Bucket::Side(axis, false) => eye[axis] < self.planes[i] + MARGIN,
Bucket::Other => true,
};
@@ -312,6 +319,24 @@ mod tests {
assert_eq!(runs(&sides, [0.5, 1.0, 0.5]), [(2, 1)]);
}
+ #[test]
+ fn an_eye_and_its_mirror_below_see_the_faces_of_both() {
+ let mut solid = cube([0.0; 3], 0);
+ let sides = sort(&mut solid);
+ let both = |eye: [f32; 3], low: f32| {
+ let mut out = Vec::new();
+ sides.visible_from(eye, low, |first, count| out.push((first, count)));
+ out
+ };
+ // Above the block: +X, +Z and +Y; with the mirror's eye below it, -Y too.
+ assert_eq!(runs(&sides, [8.0, 8.0, 8.0]), [(0, 3)]);
+ assert_eq!(both([8.0, 8.0, 8.0], -8.0), [(0, 3), (5, 1)]);
+ // A mirror above the block's bottom adds nothing.
+ assert_eq!(both([8.0, 8.0, 8.0], 0.5), [(0, 3)]);
+ // The sides depend on x and z alone.
+ assert_eq!(both([-8.0, 8.0, -8.0], -8.0), [(2, 4)]);
+ }
+
#[test]
fn unsorted_meshes_draw_everything() {
let sides = Sides::unsorted(9);
diff --git a/crates/horizon-client-render/src/native_emissive.rs b/crates/horizon-client-render/src/native_emissive.rs
new file mode 100644
index 0000000000..2ed843ec97
--- /dev/null
+++ b/crates/horizon-client-render/src/native_emissive.rs
@@ -0,0 +1,61 @@
+//! Optional, texture-matched native overlays. No extra draw calls or world light changes.
+use crate::sprites::Sprites;
+use std::{collections::BTreeMap, path::Path};
+
+pub const MAX: usize = 32;
+
+/// Load only bounded, explicitly named emissive textures from the active pack.
+pub fn load(root: &Path, sprites: &mut Sprites) {
+ let mut names: Vec<_> = crate::atlas_sources::list_textures(root)
+ .into_iter()
+ .filter(|name| {
+ name.split_once(':')
+ .is_some_and(|(_, p)| p.starts_with("emissive/"))
+ })
+ .take(MAX)
+ .collect();
+ names.sort();
+ for name in names {
+ let file = crate::atlas_sources::texture_path(root, &name, "textures");
+ if std::fs::metadata(file).is_ok_and(|m| m.len() <= 1 << 20) {
+ let _ = sprites.insert(root, &name);
+ }
+ }
+}
+
+/// Only loaded base/overlay pairs are sent to the GPU; sorted for vertex lookup.
+pub fn mappings<'a>(
+ layers: &BTreeMap,
+ requests: impl IntoIterator- ,
+) -> Vec<[u32; 4]> {
+ let mut pairs = BTreeMap::new();
+ for (base, overlay) in requests.into_iter().take(MAX) {
+ if let (Some(&b), Some(&o)) = (layers.get(base), layers.get(overlay)) {
+ pairs.entry(b).or_insert(o);
+ }
+ }
+ pairs.into_iter().map(|(b, o)| [b, o, 0, 0]).collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[test]
+ fn only_existing_pairs_are_mapped() {
+ let layers = [
+ ("minecraft:block/ore".into(), 3),
+ ("mine:emissive/ore".into(), 7),
+ ]
+ .into();
+ assert_eq!(
+ mappings(
+ &layers,
+ [
+ ("minecraft:block/ore", "mine:emissive/ore"),
+ ("missing", "mine:emissive/ore")
+ ]
+ ),
+ vec![[3, 7, 0, 0]]
+ );
+ }
+}
diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs
index d99c19a962..5ef8e9279b 100644
--- a/crates/horizon-client-render/src/options_ui.rs
+++ b/crates/horizon-client-render/src/options_ui.rs
@@ -402,6 +402,54 @@ impl Opt {
Kind::Cycle => None,
}
}
+ /// The discrete step count for stepped sliders, or `None` if continuous.
+ pub fn slider_steps(self) -> Option
{
+ match self {
+ Opt::Fov => Some(80),
+ Opt::RenderDistance => Some((crate::options_file::MAX_RENDER_DISTANCE - 2) as u32),
+ Opt::SimulationDistance => Some(27),
+ Opt::MaxFps => Some(25),
+ Opt::CloudRange => Some(126),
+ Opt::EntityDistance => Some(18),
+ Opt::BiomeBlend => Some(7),
+ Opt::Preset => Some(3),
+ Opt::Mipmaps => Some(4),
+ Opt::WeatherRadius => Some(7),
+ Opt::ChunkFade => Some(40),
+ Opt::MenuBlur => Some(10),
+ Opt::NotificationDisplayTime => Some(95),
+ Opt::PadMoveDeadzone | Opt::PadLookDeadzone => Some(50),
+ Opt::SprintWindow => Some(10),
+ _ => None,
+ }
+ }
+ /// Step a slider forward or backward, derived from its own slider definition and step count.
+ pub fn step_slider(self, s: &mut Settings, forward: bool, width: f32) {
+ if !self.available() {
+ return;
+ }
+ let Some(pos) = self.slider_position(s) else {
+ return;
+ };
+ let next_pos = if let Some(steps) = self.slider_steps() {
+ let steps = steps as f32;
+ let current = (pos * steps).round();
+ let next = if forward {
+ current + 1.0
+ } else {
+ current - 1.0
+ };
+ (next / steps).clamp(0.0, 1.0)
+ } else {
+ let delta = 1.0 / (width - 8.0).max(1.0);
+ if forward {
+ (pos + delta).min(1.0)
+ } else {
+ (pos - delta).max(0.0)
+ }
+ };
+ self.set_slider(s, next_pos);
+ }
/// The widget message (OptionInstance.toString / CycleButton full name).
pub fn label(self, s: &Settings) -> Text {
let c = self.caption();
@@ -1257,6 +1305,47 @@ struct Widget {
/// Scrolls with the list (clipped to the list area).
list: bool,
}
+
+/// The keyboard-focused UI target on an options page.
+#[derive(Clone, Debug, PartialEq)]
+enum FocusId {
+ Search,
+ Slider(Opt),
+ Cycle(Opt),
+ Act(Act),
+ Lang(String),
+ Index(usize),
+}
+
+fn focus_id(i: usize, wd: &Widget) -> FocusId {
+ match &wd.kind {
+ WKind::Slider { opt, .. } => FocusId::Slider(*opt),
+ WKind::Button {
+ act: Act::Cycle(opt),
+ ..
+ } => FocusId::Cycle(*opt),
+ WKind::Button { act, .. } if *act != Act::None => FocusId::Act(act.clone()),
+ WKind::LanguageRow { code, .. } => FocusId::Lang(code.clone()),
+ _ => FocusId::Index(i),
+ }
+}
+
+fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
+ match (id, &wd.kind) {
+ (FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
+ (
+ FocusId::Cycle(a),
+ WKind::Button {
+ act: Act::Cycle(opt),
+ ..
+ },
+ ) => a == opt,
+ (FocusId::Act(a), WKind::Button { act, .. }) => a == act,
+ (FocusId::Lang(a), WKind::LanguageRow { code, .. }) => a == code,
+ (FocusId::Index(idx), _) => *idx == i,
+ _ => false,
+ }
+}
/// What the game must do after an interaction.
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
@@ -1367,6 +1456,14 @@ pub struct OptionsUi {
pub calibration_fullscreen: Option,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec,
+ /// Keyboard focus: the interactive target currently focused.
+ focus: Option,
+ /// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
+ pub(crate) can_change_value: bool,
+ /// The current GUI size (w, h).
+ pub size: (f32, f32),
+ /// The current window framebuffer dimensions [w, h].
+ pub framebuffer: [i32; 2],
/// Splitscreen: another player's options. Language and resource packs are the whole
/// process's (every player's text and textures), so they're P1's to change.
pub split_guest: bool,
@@ -1407,6 +1504,10 @@ impl Default for OptionsUi {
calibration: None,
calibration_fullscreen: None,
agent_status: Vec::new(),
+ focus: None,
+ can_change_value: false,
+ size: (427.0, 240.0),
+ framebuffer: [1280, 720],
split_guest: false,
}
}
@@ -1561,10 +1662,36 @@ impl OptionsUi {
self.packs(atlas, size).changed = p.changed;
}
}
+ /// Steps a slider option forward or backward, derived from its own slider definition.
+ pub fn step_slider_option(
+ &mut self,
+ s: &mut Settings,
+ opt: Opt,
+ forward: bool,
+ width: f32,
+ ) -> Vec {
+ let volume = |s: &Settings| match opt {
+ Opt::Volume(0) => Some(s.master_volume),
+ Opt::Volume(i) => Some(s.volumes[i]),
+ _ => None,
+ };
+ let before_vol = volume(s);
+ opt.step_slider(s, forward, width);
+ self.slider_value = None;
+ let mut events = vec![Event::Changed, Event::SliderDrag];
+ if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
+ && before_vol != Some(after)
+ {
+ events.push(Event::Volume(source, after));
+ }
+ events
+ }
/// Open a page whose Done/Escape returns directly to the calling screen.
pub fn open_at(&mut self, in_world: bool, page: Page) {
self.stack.clear();
self.data_pack_dir = None;
+ self.focus = None;
+ self.can_change_value = false;
self.push(page);
self.in_world = in_world;
self.selected_key = None;
@@ -1573,6 +1700,8 @@ impl OptionsUi {
self.stack.push(page);
self.scroll = 0.0;
self.scrollbar_drag_y = None;
+ self.focus = None;
+ self.can_change_value = false;
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
self.settings_search.set_value("");
self.settings_search.focused = false;
@@ -2019,9 +2148,12 @@ impl OptionsUi {
label = label.append(tr("horizon.mods.dev"));
}
let dead = installed.dev || installed.id.is_empty();
- // A native mod gets its settings page's button beside
- // the toggle.
- if installed.native.is_some() {
+ // Only native mods with actual settings need a second button.
+ if installed
+ .native
+ .as_ref()
+ .is_some_and(|page| !page.keys.is_empty() || !page.buttons.is_empty())
+ {
let toggle = button(0.0, 0.0, 150.0, label, Act::ToggleMod(i));
let settings = button(
0.0,
@@ -2670,6 +2802,7 @@ impl OptionsUi {
return;
}
let (widgets, top, bottom, content) = self.layout(s, w, h);
+ self.size = (w, h);
let in_world = self.in_world;
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let list = bottom > top;
@@ -2678,13 +2811,18 @@ impl OptionsUi {
}
let visible = |wd: &Widget| !wd.list || (wd.y < bottom && wd.y + wd.h > top);
let mut tooltip = None;
- for wd in &widgets {
+ for (wd_idx, wd) in widgets.iter().enumerate() {
if !visible(wd) {
continue;
}
+ let is_focused = self
+ .focus
+ .as_ref()
+ .is_some_and(|fid| matches_focus(fid, wd_idx, wd));
let start = batch.vertices.len();
- let hover = ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
- && (!wd.list || cursor.1 >= top && cursor.1 < bottom);
+ let hover = (ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
+ && (!wd.list || cursor.1 >= top && cursor.1 < bottom))
+ || is_focused;
if hover {
if let WKind::Button {
act: Act::Extra(item),
@@ -2778,7 +2916,7 @@ impl OptionsUi {
WKind::Slider { label, value, opt } => {
let dragging = self.dragging == Some(*opt);
let shown = self.slider_shown(*opt, *value);
- ui::slider(
+ ui::slider_ext(
batch,
atlas,
wd.x,
@@ -2787,6 +2925,8 @@ impl OptionsUi {
label,
shown,
hover || dragging,
+ is_focused,
+ self.can_change_value && is_focused,
true,
dragging,
);
@@ -2823,7 +2963,7 @@ impl OptionsUi {
crate::widgets::Kind::Row,
);
crate::widgets::target_as(rect, crate::widgets::Kind::Row);
- if *selected {
+ if *selected || is_focused {
batch.fill(atlas, wd.x, wd.y, wd.w, wd.h, u32::MAX);
batch.fill(
atlas,
@@ -2856,6 +2996,9 @@ impl OptionsUi {
}
if self.page() == Page::Language {
let mut search = self.language_search.clone();
+ if self.focus == Some(FocusId::Search) {
+ search.focused = true;
+ }
search.draw(batch, atlas, None);
if search.value.is_empty() && !search.focused {
batch.text_styled(
@@ -2871,6 +3014,9 @@ impl OptionsUi {
}
if matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds) {
let mut search = self.settings_search.clone();
+ if self.focus == Some(FocusId::Search) {
+ search.focused = true;
+ }
search.draw(batch, atlas, None);
if search.value.is_empty() && !search.focused {
batch.text_styled(
@@ -3006,17 +3152,32 @@ impl OptionsUi {
return events;
}
let (widgets, top, bottom, content) = self.layout(s, size.0, size.1);
+ self.size = size;
+ self.framebuffer = framebuffer;
let mut events = Vec::new();
if !drag && matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds) {
let search = &mut self.settings_search;
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
+ self.focus = Some(FocusId::Search);
+ self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
events.push(Event::ButtonClick);
return events;
}
}
+ if !drag && self.page() == Page::Language {
+ let search = &mut self.language_search;
+ search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
+ if search.focused {
+ self.focus = Some(FocusId::Search);
+ self.can_change_value = false;
+ search.click(atlas, x, false, false);
+ self.scroll = 0.0;
+ return events;
+ }
+ }
if drag {
if let Some(previous_y) = self.scrollbar_drag_y {
if let Some((_, thumb)) = ui::scrollbar_thumb(top, bottom, content, self.scroll) {
@@ -3070,17 +3231,19 @@ impl OptionsUi {
self.scrollbar_drag_y = Some(y);
return events;
}
- for wd in widgets {
+ for (wd_idx, wd) in widgets.into_iter().enumerate() {
if !ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h) {
continue;
}
if wd.list && (y < top || y >= bottom) {
continue;
}
+ self.focus = Some(focus_id(wd_idx, &wd));
match wd.kind {
WKind::Button {
active: true, act, ..
} => {
+ self.can_change_value = false;
events.push(Event::ButtonClick);
match act {
Act::Push(p) => {
@@ -3152,12 +3315,14 @@ impl OptionsUi {
}
}
WKind::Slider { opt, .. } => {
+ self.can_change_value = true;
self.dragging = Some(opt);
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
drag_slider(opt, s, v, &mut events);
}
WKind::LanguageRow { code, .. } => {
+ self.can_change_value = false;
if self.language_choice.as_deref() == Some(code.as_str()) {
events.extend(self.back(s));
} else {
@@ -3232,7 +3397,14 @@ impl OptionsUi {
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
- /// (`CycleButton.mouseScrolled`: up goes back) and the list stays put.
+ /// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
+ /// over a slider it nudges the slider up or down.
+ ///
+ /// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
+ /// cycle button is keyboard-focused (highlighted), the wheel steps or
+ /// cycles that widget regardless of where the cursor is. Vanilla 26.3
+ /// does not intercept the wheel for sliders at all; this makes keyboard
+ /// navigation far more usable.
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -3241,13 +3413,73 @@ impl OptionsUi {
framebuffer: [i32; 2],
amount: f32,
) -> Vec {
+ self.size = size;
+ self.framebuffer = framebuffer;
+ if let Some(level) = Self::graphics_level(self.page()) {
+ use crate::graphics_ui::Action;
+ let actions = self
+ .graphics
+ .wheel(level, s, cursor, size, framebuffer, amount);
+ let mut events = Vec::new();
+ for action in actions {
+ match action {
+ Action::ButtonClick => events.push(Event::ButtonClick),
+ Action::Changed => events.push(Event::Changed),
+ Action::SliderDrag => events.push(Event::SliderDrag),
+ Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
+ Action::OpenHdr => self.push(Page::GraphicsHdr),
+ Action::OpenSimple => {
+ if self.stack.len() >= 2
+ && self.stack[self.stack.len() - 2] == Page::Graphics
+ {
+ events.extend(self.back(s));
+ } else {
+ self.stack.pop();
+ self.push(Page::Graphics);
+ }
+ }
+ Action::BindKey(name) => self.selected_key = Some(name),
+ Action::Calibrate => {
+ self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
+ self.push(Page::HdrCalibration);
+ }
+ Action::Done => events.extend(self.back(s)),
+ }
+ }
+ return events;
+ }
let plain = self.resource_error.is_none()
- && Self::graphics_level(self.page()).is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
self.scroll_at(s, size, cursor, amount);
return Vec::new();
}
+ // Horizon improvement: a keyboard-focused slider or cycle button
+ // intercepts the wheel before hover detection (see doc-comment above).
+ if amount != 0.0 {
+ let (widgets, _, _, _) = self.layout(s, size.0, size.1);
+ match &self.focus {
+ Some(FocusId::Slider(opt)) => {
+ let opt = *opt;
+ let width = widgets
+ .iter()
+ .find(|wd| matches!(&wd.kind, WKind::Slider { opt: o, .. } if *o == opt))
+ .map_or(150.0, |wd| wd.w);
+ return self.step_slider_option(s, opt, amount > 0.0, width);
+ }
+ Some(FocusId::Cycle(opt)) => {
+ let opt = *opt;
+ let mut events = vec![Event::ButtonClick];
+ events.extend(self.cycle_option(s, opt, amount > 0.0, framebuffer));
+ return events;
+ }
+ _ => {}
+ }
+ }
+ enum HoveredControl {
+ Cycle(Opt),
+ Extra(crate::extras::Item),
+ }
let (widgets, top, bottom, _) = self.layout(s, size.0, size.1);
let hovered = widgets.iter().find_map(|wd| match &wd.kind {
WKind::Button {
@@ -3257,12 +3489,27 @@ impl OptionsUi {
} if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
{
- Some(*opt)
+ Some(HoveredControl::Cycle(*opt))
+ }
+ WKind::Button {
+ active: true,
+ act: Act::Extra(item),
+ ..
+ } if ui::hovered(cursor, wd.x, wd.y, wd.w, wd.h)
+ && !(wd.list && (cursor.1 < top || cursor.1 >= bottom)) =>
+ {
+ Some(HoveredControl::Extra(*item))
}
_ => None,
});
match hovered {
- Some(opt) if amount != 0.0 => self.cycle_option(s, opt, amount > 0.0, framebuffer),
+ Some(HoveredControl::Cycle(opt)) if amount != 0.0 => {
+ self.cycle_option(s, opt, amount > 0.0, framebuffer)
+ }
+ Some(HoveredControl::Extra(item)) if amount != 0.0 => {
+ item.cycle(&mut s.extras, amount > 0.0);
+ vec![Event::Changed]
+ }
Some(_) => Vec::new(),
None => {
self.scroll_at(s, size, cursor, amount);
@@ -3386,6 +3633,10 @@ impl OptionsUi {
self.slider_value = None;
self.selected_key = None;
self.selected_pad_action = None;
+ self.focus = None;
+ self.settings_search.focused = false;
+ self.language_search.focused = false;
+ self.can_change_value = false;
match page {
Page::Language => {
if let Some(code) = self.language_choice.take()
@@ -3514,6 +3765,18 @@ impl OptionsUi {
atlas: &HudAtlas,
key: winit::keyboard::KeyCode,
text: Option<&str>,
+ ) -> Vec {
+ self.key_modifiers(s, atlas, key, text, false)
+ }
+
+ /// Keys while the options are open, including Shift modifier state.
+ pub fn key_modifiers(
+ &mut self,
+ s: &mut Settings,
+ atlas: &HudAtlas,
+ key: winit::keyboard::KeyCode,
+ text: Option<&str>,
+ shift: bool,
) -> Vec {
use winit::keyboard::KeyCode as K;
if self.page() == Page::HdrCalibration
@@ -3566,11 +3829,73 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles && self.split_profiles.key(key, text) {
return Vec::new();
}
+
+ if let Some(level) = Self::graphics_level(self.page()) {
+ use crate::graphics_ui::Action;
+ let size = (self.size.0.max(480.0), self.size.1.max(240.0));
+ let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
+ let (actions, handled) = self.graphics.key_modifiers(level, s, key, shift, size, fb);
+ let mut events = Vec::new();
+ for action in actions {
+ match action {
+ Action::ButtonClick => events.push(Event::ButtonClick),
+ Action::Changed => events.push(Event::Changed),
+ Action::SliderDrag => events.push(Event::SliderDrag),
+ Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
+ Action::OpenHdr => self.push(Page::GraphicsHdr),
+ Action::OpenSimple => {
+ if self.stack.len() >= 2
+ && self.stack[self.stack.len() - 2] == Page::Graphics
+ {
+ events.extend(self.back(s));
+ } else {
+ self.stack.pop();
+ self.push(Page::Graphics);
+ }
+ }
+ Action::BindKey(name) => self.selected_key = Some(name),
+ Action::Calibrate => {
+ self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
+ self.push(Page::HdrCalibration);
+ }
+ Action::Done => events.extend(self.back(s)),
+ }
+ }
+ if key == K::Escape {
+ events.extend(self.back(s));
+ return events;
+ }
+ if handled {
+ return events;
+ }
+ return events;
+ }
+
if key == K::Escape {
return self.back(s);
}
- if self.page() == Page::Language {
- if !self.language_search.key(key, false, false)
+
+ // Search field text entry when search box is focused
+ if self.focus == Some(FocusId::Search) {
+ if key != K::Tab && key != K::ArrowDown {
+ let search = if self.page() == Page::Language {
+ &mut self.language_search
+ } else {
+ &mut self.settings_search
+ };
+ if !search.key(key, false, shift)
+ && let Some(t) = text
+ {
+ let typed: String = t.chars().filter(|c| !c.is_control()).collect();
+ if !typed.is_empty() {
+ search.insert_text(&typed);
+ }
+ }
+ self.scroll = 0.0;
+ return Vec::new();
+ }
+ } else if self.page() == Page::Language && self.focus.is_none() {
+ if !self.language_search.key(key, false, shift)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
@@ -3579,8 +3904,8 @@ impl OptionsUi {
}
}
self.scroll = 0.0;
- } else if self.settings_search_active() {
- if !self.settings_search.key(key, false, false)
+ } else if self.settings_search_active() && self.focus.is_none() {
+ if !self.settings_search.key(key, false, shift)
&& let Some(t) = text
{
let typed: String = t.chars().filter(|c| !c.is_control()).collect();
@@ -3590,6 +3915,260 @@ impl OptionsUi {
}
self.scroll = 0.0;
}
+
+ // Gather focusables on the current page
+ let (widgets, top, bottom, content) = self.layout(s, self.size.0, self.size.1);
+ let has_search = (self.page() == Page::Language) || self.settings_search_active();
+ let mut focusables: Vec<(FocusId, [f32; 4], bool)> = Vec::new();
+ if has_search {
+ let search = if self.page() == Page::Language {
+ &self.language_search
+ } else {
+ &self.settings_search
+ };
+ focusables.push((
+ FocusId::Search,
+ [search.x, search.y, search.width, search.height],
+ false,
+ ));
+ }
+ for (i, wd) in widgets.iter().enumerate() {
+ match wd.kind {
+ WKind::Button { active: true, .. }
+ | WKind::Slider { .. }
+ | WKind::LanguageRow { .. } => {
+ focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
+ }
+ _ => {}
+ }
+ }
+ if focusables.is_empty() {
+ return Vec::new();
+ }
+
+ // 1. Tab / Shift+Tab navigation
+ if key == K::Tab {
+ let at = self
+ .focus
+ .as_ref()
+ .and_then(|fid| focusables.iter().position(|(id, ..)| id == fid));
+ let next_idx = match at {
+ None => {
+ if shift {
+ focusables.len() - 1
+ } else {
+ 0
+ }
+ }
+ Some(p) => {
+ if shift {
+ (p + focusables.len() - 1) % focusables.len()
+ } else {
+ (p + 1) % focusables.len()
+ }
+ }
+ };
+ let (next_id, rect, in_list) = &focusables[next_idx];
+ self.focus = Some(next_id.clone());
+ self.can_change_value = matches!(next_id, FocusId::Slider(_));
+ if *in_list {
+ if rect[1] < top {
+ self.scroll -= (top - rect[1]) + 4.0;
+ } else if rect[1] + rect[3] > bottom {
+ self.scroll += (rect[1] + rect[3] - bottom) + 4.0;
+ }
+ self.clamp_scroll(top, bottom, content);
+ }
+ return Vec::new();
+ }
+
+ // 2. Left / Right Arrow keys
+ if matches!(key, K::ArrowLeft | K::ArrowRight) {
+ let forward = key == K::ArrowRight;
+ // If focused on a slider and in can_change_value mode: adjust the slider value!
+ if let Some(FocusId::Slider(opt)) = self.focus
+ && self.can_change_value
+ {
+ let width = widgets
+ .iter()
+ .find(|wd| matches!(wd.kind, WKind::Slider { opt: o, .. } if o == opt))
+ .map_or(150.0, |wd| wd.w);
+ return self.step_slider_option(s, opt, forward, width);
+ }
+ // Otherwise, navigate horizontally using 2D focus stepping
+ if let Some(fid) = &self.focus {
+ if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
+ let from = focusables[cur_pos].1;
+ let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
+ let dir = if forward {
+ gamepad::Button::DPadRight
+ } else {
+ gamepad::Button::DPadLeft
+ };
+ if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
+ let (next_id, rect, in_list) = &focusables[next_idx];
+ self.focus = Some(next_id.clone());
+ self.can_change_value = false;
+ if *in_list {
+ if rect[1] < top {
+ self.scroll -= (top - rect[1]) + 4.0;
+ } else if rect[1] + rect[3] > bottom {
+ self.scroll += (rect[1] + rect[3] - bottom) + 4.0;
+ }
+ self.clamp_scroll(top, bottom, content);
+ }
+ }
+ }
+ } else {
+ self.focus = Some(focusables[0].0.clone());
+ self.can_change_value = false;
+ }
+ return Vec::new();
+ }
+
+ // 3. Up / Down Arrow keys
+ if matches!(key, K::ArrowUp | K::ArrowDown) {
+ let down = key == K::ArrowDown;
+ if let Some(fid) = &self.focus {
+ if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
+ let from = focusables[cur_pos].1;
+ let rects: Vec<[f32; 4]> = focusables.iter().map(|f| f.1).collect();
+ let dir = if down {
+ gamepad::Button::DPadDown
+ } else {
+ gamepad::Button::DPadUp
+ };
+ let step = gamepad::focus_step_strict(from, dir, &rects)
+ .or_else(|| gamepad::focus_step_vague(from, dir, &rects));
+ if let Some(next_idx) = step {
+ let (next_id, rect, in_list) = &focusables[next_idx];
+ self.focus = Some(next_id.clone());
+ self.can_change_value = false;
+ if *in_list {
+ if rect[1] < top {
+ self.scroll -= (top - rect[1]) + 4.0;
+ } else if rect[1] + rect[3] > bottom {
+ self.scroll += (rect[1] + rect[3] - bottom) + 4.0;
+ }
+ self.clamp_scroll(top, bottom, content);
+ }
+ } else {
+ // Off the edge of visible rows: scroll the list
+ self.scroll += if down { 18.0 } else { -18.0 };
+ self.clamp_scroll(top, bottom, content);
+ }
+ }
+ } else {
+ self.focus = Some(focusables[0].0.clone());
+ self.can_change_value = false;
+ }
+ return Vec::new();
+ }
+
+ // 4. Enter / Space selection keys
+ if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
+ match &self.focus {
+ Some(FocusId::Slider(_)) => {
+ self.can_change_value = !self.can_change_value;
+ return Vec::new();
+ }
+ Some(FocusId::Cycle(opt)) => {
+ let mut events = vec![Event::ButtonClick];
+ events.extend(self.cycle_option(s, *opt, shift, self.framebuffer));
+ return events;
+ }
+ Some(FocusId::Act(act)) => {
+ let mut events = vec![Event::ButtonClick];
+ let act = act.clone();
+ match act {
+ Act::Push(p) => {
+ self.focus = None;
+ self.can_change_value = false;
+ if p == Page::ShaderPacks {
+ events.push(Event::ListShaderPacks);
+ }
+ self.push(p);
+ }
+ Act::Done => {
+ self.focus = None;
+ self.can_change_value = false;
+ events.extend(self.back(s));
+ }
+ Act::Cycle(opt) => {
+ events.extend(self.cycle_option(s, opt, shift, self.framebuffer));
+ }
+ Act::Extra(item) => {
+ if let Some(name) = item.key_name() {
+ self.selected_key = Some(name);
+ } else {
+ item.cycle(&mut s.extras, shift);
+ events.push(Event::Changed);
+ }
+ }
+ Act::Key(name) => self.selected_key = Some(name),
+ Act::ResetKey(name) => {
+ s.keys.set(name, KeyBinds::default_of(name));
+ events.push(Event::Changed);
+ }
+ Act::ResetKeys => {
+ s.keys.reset_all();
+ events.push(Event::Changed);
+ }
+ Act::PadKey(action) => self.selected_pad_action = Some(action),
+ Act::ResetPadKey(action) => {
+ s.controller
+ .bind(action, gamepad::PadSettings::default_of(action));
+ events.push(Event::Changed);
+ }
+ Act::ResetPadKeys => {
+ s.controller.reset_bindings();
+ events.push(Event::Changed);
+ }
+ Act::ToggleMod(i) => {
+ s.mods.toggle(i);
+ events.push(Event::Changed);
+ }
+ Act::ModSettings(i) => {
+ self.focus = None;
+ self.can_change_value = false;
+ self.push(Page::NativeMod(i));
+ events.push(Event::NativeSettings(i));
+ }
+ Act::NativeKey(i, k) => {
+ self.selected_key = Some(NATIVE_KEY);
+ self.selected_native_key = Some((i, k));
+ }
+ Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
+ Act::OpenModFolder => events.push(Event::OpenModFolder),
+ Act::PickShaderPack(i) => {
+ if let Some(pack) = s.shader_packs.get(i) {
+ s.horizon.shader_pack = if s.horizon.shader_pack == pack.file {
+ String::new()
+ } else {
+ pack.file.clone()
+ };
+ events.push(Event::ShaderPack);
+ }
+ }
+ Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
+ Act::Credits => events.push(Event::Credits),
+ Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
+ Act::None => {}
+ }
+ return events;
+ }
+ Some(FocusId::Lang(code)) => {
+ let mut events = vec![Event::ButtonClick];
+ if self.language_choice.as_deref() == Some(code.as_str()) {
+ events.extend(self.back(s));
+ } else {
+ self.language_choice = Some(code.clone());
+ }
+ return events;
+ }
+ _ => {}
+ }
+ }
Vec::new()
}
}
@@ -3981,7 +4560,10 @@ mod tests {
description: String::new(),
problem: None,
dev: false,
- native: Some(crate::mod_list::NativePage::default()),
+ native: Some(crate::mod_list::NativePage {
+ buttons: vec!["Setting".into()],
+ ..Default::default()
+ }),
});
ui.show(Page::Mods);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
@@ -4012,6 +4594,56 @@ mod tests {
assert_eq!(ui.page(), Page::NativeMod(0));
}
+ #[test]
+ fn native_mod_without_settings_has_only_a_full_width_toggle() {
+ let atlas = atlas();
+ let mut s = Settings::default();
+ let mut ui = OptionsUi::default();
+ s.mods.installed.push(crate::mod_list::Installed {
+ id: "no_settings".into(),
+ title: "Native 1.0".into(),
+ description: String::new(),
+ problem: None,
+ dev: false,
+ native: Some(crate::mod_list::NativePage::default()),
+ });
+ ui.show(Page::Mods);
+ let (widgets, ..) = ui.layout(&s, 480.0, 240.0);
+ assert!(!widgets.iter().any(|w| matches!(
+ w.kind,
+ WKind::Button {
+ act: Act::ModSettings(_),
+ ..
+ }
+ )));
+ let toggle = widgets
+ .iter()
+ .find(|w| {
+ matches!(
+ w.kind,
+ WKind::Button {
+ act: Act::ToggleMod(0),
+ ..
+ }
+ )
+ })
+ .unwrap();
+ assert_eq!(toggle.w, 310.0);
+ let events = ui.click(
+ &mut s,
+ &atlas,
+ toggle.x + 1.0,
+ toggle.y + 1.0,
+ (480.0, 240.0),
+ [480, 240],
+ false,
+ false,
+ );
+ assert!(events.contains(&Event::Changed));
+ assert!(!s.mods.is_on(0));
+ assert!(s.mods.disabled.contains("no_settings"));
+ }
+
#[test]
fn a_native_mods_key_shows_and_keeps_the_key_pressed() {
// #3539: Zoom's key set to R showed another key.
@@ -5349,4 +5981,295 @@ mod tests {
}
assert_eq!(s.sound_device, "");
}
+
+ #[test]
+ fn slider_keyboard_navigation_and_selection_toggle_parity() {
+ use winit::keyboard::KeyCode;
+ let atlas = atlas();
+ let mut s = Settings::default();
+ let mut ui = OptionsUi::default();
+ ui.show(Page::Main);
+ s.fov = 70.0;
+
+ // 1. Tab focuses the first interactive widget: FOV slider
+ ui.key(&mut s, &atlas, KeyCode::Tab, None);
+ assert_eq!(ui.focus, Some(FocusId::Slider(Opt::Fov)));
+ assert!(
+ ui.can_change_value,
+ "Tab onto slider enables can_change_value (Vanilla behavior)"
+ );
+
+ // 2. Right Arrow steps FOV up to 71
+ let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
+ assert_eq!(s.fov, 71.0);
+ assert!(events.contains(&Event::Changed));
+
+ // 3. Left Arrow steps FOV down back to 70, then 69
+ ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
+ assert_eq!(s.fov, 70.0);
+ ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
+ assert_eq!(s.fov, 69.0);
+
+ // 4. Space / Enter toggles can_change_value to false
+ ui.key(&mut s, &atlas, KeyCode::Space, None);
+ assert!(
+ !ui.can_change_value,
+ "Space toggles can_change_value to false"
+ );
+
+ // 5. With can_change_value false, Left/Right arrow keys navigate focus instead of changing value!
+ ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
+ assert_eq!(
+ s.fov, 69.0,
+ "FOV must not change when can_change_value is false"
+ );
+ assert_ne!(
+ ui.focus,
+ Some(FocusId::Slider(Opt::Fov)),
+ "Focus moved to next widget horizontally"
+ );
+
+ // 6. Navigate back to FOV slider with Left Arrow
+ ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
+ assert_eq!(ui.focus, Some(FocusId::Slider(Opt::Fov)));
+ assert!(
+ !ui.can_change_value,
+ "Arrow navigation onto slider keeps can_change_value false"
+ );
+
+ // 7. Space toggles can_change_value back to true
+ ui.key(&mut s, &atlas, KeyCode::Enter, None);
+ assert!(ui.can_change_value);
+ ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
+ assert_eq!(s.fov, 70.0);
+
+ // 8. Escape closes the options page immediately, even while a slider
+ // is focused for value changes.
+ let events = ui.key(&mut s, &atlas, KeyCode::Escape, None);
+ assert!(!ui.can_change_value);
+ assert_eq!(
+ ui.page(),
+ Page::Main,
+ "Escape must close the options page from value edit"
+ );
+ assert!(events.contains(&Event::Close));
+ }
+
+ #[test]
+ fn escape_closes_options_with_search_focused() {
+ use winit::keyboard::KeyCode;
+ let atlas = atlas();
+ let mut s = Settings::default();
+ let mut ui = OptionsUi::default();
+ ui.show(Page::Video);
+ ui.focus = Some(FocusId::Search);
+ ui.settings_search.focused = true;
+
+ let events = ui.key(&mut s, &atlas, KeyCode::Escape, None);
+ assert!(events.contains(&Event::Changed));
+ assert_eq!(ui.page(), Page::Main);
+ assert_eq!(ui.focus, None);
+ assert!(!ui.settings_search.focused);
+ }
+
+ #[test]
+ fn slider_mouse_wheel_scrolls_list_parity() {
+ let mut s = Settings::default();
+ let mut ui = OptionsUi::default();
+ ui.show(Page::Main);
+ s.fov = 70.0;
+ let size: (f32, f32) = (427.0, 240.0);
+ let cx = (size.0 / 2.0_f32).floor();
+
+ // Cursor over FOV slider (cx - 154.0, 29.0, 150.0, 20.0)
+ let cursor = (cx - 100.0, 35.0);
+ // In vanilla parity, mouse wheel over a slider does not change the slider value.
+ ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
+ assert_eq!(s.fov, 70.0);
+
+ // Sound page: Master Volume slider
+ ui.show(Page::Sound);
+ s.master_volume = 0.5;
+ let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
+ let master = widgets
+ .iter()
+ .find(|w| {
+ matches!(
+ w.kind,
+ WKind::Slider {
+ opt: Opt::Volume(0),
+ ..
+ }
+ )
+ })
+ .unwrap();
+ let cursor = (master.x + 10.0, master.y + 10.0);
+ let events = ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
+ assert_eq!(s.master_volume, 0.5);
+ assert!(!events.iter().any(|e| matches!(e, Event::Volume(0, _))));
+ }
+
+ #[test]
+ fn horizon_pages_slider_and_wheel_parity() {
+ use winit::keyboard::KeyCode as K;
+ let atlas = atlas();
+ let mut s = Settings::default();
+ let mut ui = OptionsUi::default();
+
+ // 1. Extras page: scrolling wheel on Sort switch cycles it
+ ui.show(Page::Extras);
+ let size = (480.0, 600.0);
+ let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
+ let sort_wd = widgets
+ .iter()
+ .find(|w| {
+ matches!(
+ w.kind,
+ WKind::Button {
+ act: Act::Extra(crate::extras::Item::Sort),
+ ..
+ }
+ )
+ })
+ .unwrap();
+ let cursor = (sort_wd.x + 5.0, sort_wd.y + 5.0);
+ assert!(!s.extras.sort);
+ ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
+ assert!(s.extras.sort);
+ ui.wheel(&mut s, size, cursor, [854, 480], 1.0);
+ assert!(!s.extras.sort);
+
+ // 2. Horizon Graphics page through OptionsUi:
+ // Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
+ ui.show(Page::GraphicsAdvanced);
+ s.horizon.chunk_updates = 10;
+ ui.size = size;
+ ui.framebuffer = [854, 480];
+
+ // Tab into controls
+ for _ in 0..15 {
+ ui.key(&mut s, &atlas, K::Tab, None);
+ if ui.graphics.focus
+ == Some(crate::graphics_ui::Target::Item(
+ crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates),
+ ))
+ {
+ break;
+ }
+ }
+ assert_eq!(
+ ui.graphics.focus,
+ Some(crate::graphics_ui::Target::Item(
+ crate::graphics_ui::Item::Horizon(crate::graphics_ui::H::ChunkUpdates)
+ ))
+ );
+ assert!(ui.graphics.can_change_value);
+
+ // Right arrow steps ChunkUpdates to 11
+ let events = ui.key(&mut s, &atlas, K::ArrowRight, None);
+ assert_eq!(s.horizon.chunk_updates, 11);
+ assert!(events.contains(&Event::SliderDrag));
+
+ // Space toggles can_change_value to false
+ ui.key(&mut s, &atlas, K::Space, None);
+ assert!(!ui.graphics.can_change_value);
+
+ // ArrowRight does not change ChunkUpdates when can_change_value is false
+ ui.key(&mut s, &atlas, K::ArrowRight, None);
+ assert_eq!(s.horizon.chunk_updates, 11);
+ }
+
+ #[test]
+ fn options_step_slider_matches_set_slider_for_all_stepped_options() {
+ let stepped_opts = [
+ Opt::Fov,
+ Opt::RenderDistance,
+ Opt::SimulationDistance,
+ Opt::MaxFps,
+ Opt::CloudRange,
+ Opt::EntityDistance,
+ Opt::BiomeBlend,
+ Opt::Preset,
+ Opt::Mipmaps,
+ Opt::WeatherRadius,
+ Opt::ChunkFade,
+ Opt::MenuBlur,
+ Opt::NotificationDisplayTime,
+ Opt::PadMoveDeadzone,
+ Opt::PadLookDeadzone,
+ Opt::SprintWindow,
+ ];
+ for opt in stepped_opts {
+ let steps = opt.slider_steps().expect("slider steps");
+ for i in 0..steps {
+ let pos = i as f32 / steps as f32;
+ let mut s1 = Settings::default();
+ let mut s2 = Settings::default();
+ opt.set_slider(&mut s1, pos);
+
+ opt.step_slider(&mut s1, true, 200.0);
+ let next_pos = (i + 1) as f32 / steps as f32;
+ opt.set_slider(&mut s2, next_pos);
+ assert_eq!(
+ opt.slider_position(&s1),
+ opt.slider_position(&s2),
+ "{opt:?} forward from {i}"
+ );
+ }
+ for i in 1..=steps {
+ let pos = i as f32 / steps as f32;
+ let mut s1 = Settings::default();
+ let mut s2 = Settings::default();
+ opt.set_slider(&mut s1, pos);
+
+ opt.step_slider(&mut s1, false, 200.0);
+ let prev_pos = (i - 1) as f32 / steps as f32;
+ opt.set_slider(&mut s2, prev_pos);
+ assert_eq!(
+ opt.slider_position(&s1),
+ opt.slider_position(&s2),
+ "{opt:?} backward from {i}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn options_keyboard_space_enter_emits_button_click_for_cycle_and_default_buttons() {
+ use winit::keyboard::KeyCode;
+ let mut ui = OptionsUi::default();
+ let mut s = Settings::default();
+ let atlas = atlas();
+
+ // 1. Focus a cycle button (e.g. Opt::ViewBobbing)
+ ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
+ let events = ui.key(&mut s, &atlas, KeyCode::Space, None);
+ assert!(
+ events.contains(&Event::ButtonClick),
+ "Space on FocusId::Cycle must emit Event::ButtonClick"
+ );
+ assert!(events.contains(&Event::Changed));
+
+ let events = ui.key(&mut s, &atlas, KeyCode::Enter, None);
+ assert!(
+ events.contains(&Event::ButtonClick),
+ "Enter on FocusId::Cycle must emit Event::ButtonClick"
+ );
+ assert!(events.contains(&Event::Changed));
+
+ // 2. Focus a default/action button (e.g. Act::Done)
+ ui.focus = Some(FocusId::Act(Act::Done));
+ let events = ui.key(&mut s, &atlas, KeyCode::Space, None);
+ assert!(
+ events.contains(&Event::ButtonClick),
+ "Space on FocusId::Act must emit Event::ButtonClick"
+ );
+
+ ui.focus = Some(FocusId::Act(Act::Done));
+ let events = ui.key(&mut s, &atlas, KeyCode::Enter, None);
+ assert!(
+ events.contains(&Event::ButtonClick),
+ "Enter on FocusId::Act must emit Event::ButtonClick"
+ );
+ }
}
diff --git a/crates/horizon-client-render/src/packs/cache.rs b/crates/horizon-client-render/src/packs/cache.rs
index 60b531c747..f270dabfcf 100644
--- a/crates/horizon-client-render/src/packs/cache.rs
+++ b/crates/horizon-client-render/src/packs/cache.rs
@@ -1,6 +1,7 @@
//! Overlay reuse validated by source topology and filesystem stamps.
//! Published output is an owned cache artifact; hits do not traverse its tree.
use super::{Pack, Source, build_overlay_uncached};
+use crate::zip::Zip;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::io::Read;
@@ -77,6 +78,24 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
+/// The largest zip hashed by its bytes: vanilla's limit on a server pack.
+const WHOLE_ZIP: u64 = 250 << 20;
+/// A zip up to `whole` by its bytes, which also catches an edit its checksums
+/// don't show (a pack may leave them empty, nothing reads them). A larger one
+/// would take minutes on every check, so by its stamp and its directory: what a
+/// local pack changes when it is replaced, and the directory reads in a moment.
+fn zip_content(hash: &mut Sha256, path: &Path, whole: u64) -> io::Result<()> {
+ let meta = fs::metadata(path)?;
+ match Zip::open(path) {
+ Ok(zip) if meta.len() > whole => {
+ hash.update(meta.len().to_le_bytes());
+ field(hash, format!("{:?}", meta.modified()?).as_bytes());
+ hash.update(zip.directory());
+ Ok(())
+ }
+ _ => content(hash, path),
+ }
+}
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
@@ -250,8 +269,8 @@ fn pack_signature(packs: &[Pack]) -> io::Result {
field(&mut hash, absolute(path)?.as_os_str().as_encoded_bytes());
// By its bytes, not its stamp: a server pack found in the download cache is
// marked used on every join (`HttpUtil.updateModificationTime`), and a new
- // time rebuilt the whole overlay each time. One file reads in a moment.
- content(&mut hash, path)?;
+ // time rebuilt the whole overlay each time.
+ zip_content(&mut hash, path, WHOLE_ZIP)?;
}
}
}
@@ -632,6 +651,36 @@ mod tests {
assert!(!marker.exists());
}
+ #[test]
+ fn a_large_zip_is_stamped_by_its_directory_and_time() {
+ let f = Fixture::new();
+ let path = f.0.join("large.zip");
+ let at = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1 << 30);
+ let write = |name: &str, first: u8, modified| {
+ let mut body = vec![0u8; 1 << 18];
+ body[0] = first;
+ fs::write(&path, archive(&[(name, body)], false)).unwrap();
+ let file = fs::File::options().write(true).open(&path).unwrap();
+ file.set_modified(modified).unwrap();
+ };
+ let signature = |whole| {
+ let mut hash = Sha256::new();
+ zip_content(&mut hash, &path, whole).unwrap();
+ finish(hash)
+ };
+ write("a", 0, at);
+ let (bytes, directory) = (signature(u64::MAX), signature(0));
+ // Above the limit the entries' bytes go unread; at or under it they count.
+ write("a", 1, at);
+ assert_eq!(signature(0), directory);
+ assert_ne!(signature(u64::MAX), bytes);
+ write("b", 0, at);
+ assert_ne!(signature(0), directory);
+ write("a", 0, at + std::time::Duration::from_secs(60));
+ assert_ne!(signature(0), directory);
+ assert_eq!(signature(u64::MAX), bytes);
+ }
+
#[test]
fn zip_content_and_failed_build_invalidate() {
let f = Fixture::new();
diff --git a/crates/horizon-client-render/src/particles.rs b/crates/horizon-client-render/src/particles.rs
index f65e282b3b..7d58c69073 100644
--- a/crates/horizon-client-render/src/particles.rs
+++ b/crates/horizon-client-render/src/particles.rs
@@ -1075,6 +1075,8 @@ pub struct ParticleEngine {
}
/// `ParticleGroup.MAX_PARTICLES` (per render group; one group here).
const MAX_PARTICLES: usize = 16384;
+/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
+const SPORE_BLOSSOM_LIMIT: usize = 1000;
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap, translucent: &HashSet) -> Self {
let mut engine = Self {
@@ -1480,11 +1482,27 @@ impl ParticleEngine {
self.add(options, p, v);
}
}
- /// `ParticleEngine.createParticle`: always spawned.
+ /// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
+ /// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
let seed = self.seeds.next_long();
if let Some(particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
+ // The provider has drawn, so rejecting here keeps vanilla's RNG order.
+ // shortcut: counts live plus queued particles by scanning instead of
+ // vanilla's counted map; use a counter if spore spawning shows in profiles.
+ if particle.ty == ParticleType::SporeBlossomAir
+ && self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
+ && self
+ .particles
+ .iter()
+ .chain(self.pending.iter())
+ .filter(|q| q.ty == ParticleType::SporeBlossomAir)
+ .count()
+ >= SPORE_BLOSSOM_LIMIT
+ {
+ return;
+ }
self.pending.push(particle);
}
}
@@ -4346,6 +4364,27 @@ mod tests {
assert!(tall.iter().all(|p| p[1] < 1.0));
}
#[test]
+ fn spore_blossom_air_stops_at_its_particle_limit() {
+ let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
+ let spore = ParticleOptions::simple(ParticleType::SporeBlossomAir);
+ for i in 0..1001 {
+ engine.create(&spore, [f64::from(i % 16), 70.0, 0.0], [0.0; 3]);
+ }
+ assert_eq!(engine.pending.len(), 1000);
+ assert!(
+ engine
+ .pending
+ .iter()
+ .all(|p| p.ty == ParticleType::SporeBlossomAir)
+ );
+ // A type without a `ParticleLimit` is not capped.
+ let smoke = ParticleOptions::simple(ParticleType::Smoke);
+ for _ in 0..10 {
+ engine.create(&smoke, [0.0; 3], [0.0; 3]);
+ }
+ assert_eq!(engine.pending.len(), 1010);
+ }
+ #[test]
fn particle_names_round_trip() {
for &ty in ParticleType::ALL {
assert_eq!(
diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs
index 75a682db36..67ab295166 100644
--- a/crates/horizon-client-render/src/streaming.rs
+++ b/crates/horizon-client-render/src/streaming.rs
@@ -311,6 +311,9 @@ impl Mailbox {
}
pub struct Streaming {
shared: Arc,
+ /// Columns generated and saved as they were left behind: new when they
+ /// next arrive, though read from the save (`World::loaded_chunks`).
+ dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc Option + Send + Sync>,
non_air_sections: Arc Option + Send + Sync>,
threads: Vec>,
@@ -656,7 +659,11 @@ impl Streaming {
generator.decorate(&mut worker, &mut region);
Done::Decorated(x, z, region)
}
- Job::Finish(proto) => Done::Generated(generator.finish(proto)),
+ Job::Finish(proto) => {
+ let mut chunk = generator.finish(proto);
+ chunk.generated = true;
+ Done::Generated(chunk)
+ }
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
@@ -734,6 +741,7 @@ impl Streaming {
protos: Default::default(),
light_views: Default::default(),
decorated: Default::default(),
+ dropped_new: Default::default(),
decorating: 0,
saving: 0,
decoration_order: DecorationOrder::default(),
@@ -943,6 +951,7 @@ impl Streaming {
// not give it back.
if !self.ranges().chunk_kept(key.0, key.1, self.decorates) {
if chunk.fresh {
+ self.dropped_new.insert(key);
self.saves.save_finished(chunk);
}
return;
@@ -966,6 +975,8 @@ impl Streaming {
// A chunk saved with its light needs no light job; its unlit
// neighbours still light themselves from it.
let loaded = chunk.light_loaded;
+ let new = self.dropped_new.remove(&key) || chunk.generated;
+ world.loaded_chunks.push((key.0, key.1, new));
world.chunks.insert(key, Arc::new(chunk));
if loaded {
self.lit.insert(key);
diff --git a/crates/horizon-client-render/src/terrain.wgsl b/crates/horizon-client-render/src/terrain.wgsl
index bcb7d848c7..44774c36d9 100644
--- a/crates/horizon-client-render/src/terrain.wgsl
+++ b/crates/horizon-client-render/src/terrain.wgsl
@@ -24,6 +24,10 @@ struct Globals {
outline_params: vec4,
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array, 16>,
+ colored_lights: array, 2>, 16>,
+ colored_count: vec4,
+ emissive_layers: array, 32>,
+ emissive_count: vec4,
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array, 32>,
@@ -151,6 +155,9 @@ struct TerrainOut {
@location(7) local: vec3,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
+ // The camera-relative height, for the water mirror's cut at the plane.
+ @location(9) height: f32,
+ @location(10) @interpolate(flat) emissive_layer: u32,
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
@@ -221,6 +228,15 @@ fn dynamic_block_light(pos: vec3) -> f32 {
}
return level * 16.0;
}
+fn native_colored_light(pos: vec3) -> vec3 {
+ var rgb = vec3(0.0);
+ for (var i = 0u; i < min(globals.colored_count.x, 16u); i++) {
+ let at = globals.colored_lights[i][0];
+ let color = globals.colored_lights[i][1];
+ rgb += color.rgb * max(0.0, 1.0 - distance(pos, at.xyz) / at.w) * color.a;
+ }
+ return min(rgb, vec3(1.0));
+}
fn sample_lightmap(sky: f32, block: f32) -> vec4 {
let uv = vec2(block, sky) * 16.0;
return textureSampleLevel(lightmap, lightmap_sampler,
@@ -282,9 +298,20 @@ fn terrain_vertex(position: vec3, uv: vec2, layer: u32, color: vec4 0u {
+ out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
+ }
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
+ out.height = pos.y;
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
out.fade = fade_code(w);
@@ -304,6 +331,15 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 {
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4 {
+ return solid_color(in);
+}
+// The water mirror (gfx.rs's reflection pass): what lies below the water plane
+// (`fog_ends.w`, camera-relative) would show above it in the mirror, so it is cut away.
+@fragment fn fs_solid_mirror(in: TerrainOut) -> @location(0) vec4 {
+ if in.height < globals.fog_ends.w { discard; }
+ return solid_color(in);
+}
+fn solid_color(in: TerrainOut) -> vec4 {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -316,6 +352,10 @@ fn apply_fog(color: vec4, spherical: f32, cylindrical: f32) -> vec4 {
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
// lowest, 0 at the brightest. Nothing else reads the frame's alpha.
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
+ if in.emissive_layer != 0xffffffffu {
+ let mask = sprite(in.uv, in.emissive_layer);
+ return apply_fog(vec4(mix(color.rgb, mask.rgb, mask.a), mix(emissive, 0.1, mask.a)), in.spherical, in.cylindrical);
+ }
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// Water, glass and other see-through faces draw whole as they fade in, the
diff --git a/crates/horizon-client-render/src/ui.rs b/crates/horizon-client-render/src/ui.rs
index 340d20743e..00592766c5 100644
--- a/crates/horizon-client-render/src/ui.rs
+++ b/crates/horizon-client-render/src/ui.rs
@@ -391,6 +391,27 @@ pub fn slider(
hover: bool,
active: bool,
dragging: bool,
+) {
+ slider_ext(
+ batch, atlas, x, y, w, label, value, hover, false, false, active, dragging,
+ );
+}
+
+/// `AbstractSliderButton.extractWidgetRenderState`: track, 8px handle at `value` (0..1),
+/// with support for keyboard focus and `canChangeValue`.
+pub fn slider_ext(
+ batch: &mut HudBatch,
+ atlas: &HudAtlas,
+ x: f32,
+ y: f32,
+ w: f32,
+ label: &Text,
+ value: f32,
+ hover: bool,
+ focused: bool,
+ can_change_value: bool,
+ active: bool,
+ dragging: bool,
) {
let kind = crate::widgets::Kind::Slider {
value,
@@ -402,7 +423,7 @@ pub fn slider(
// A controller's D-pad can focus it (Horizon's controller support).
crate::widgets::target_as([x, y, w, 20.0], kind);
}
- let (track, handle) = slider_sprites(hover, active, dragging);
+ let (track, handle) = slider_sprites_ext(hover, active, focused, can_change_value, dragging);
nine_slice(batch, atlas, track, x, y, w, 20.0, u32::MAX);
nine_slice(
batch,
@@ -421,15 +442,26 @@ pub fn slider(
};
centered_text(batch, atlas, label, x + 2.0, y + 6.0, w - 4.0, color);
}
-/// Track and handle sprites: the track outline shows only while the slider is
-/// dragged, hovering highlights just the handle.
+#[cfg(test)]
fn slider_sprites(hover: bool, active: bool, dragging: bool) -> (&'static str, &'static str) {
- let track = if dragging {
+ slider_sprites_ext(hover, active, false, false, dragging)
+}
+
+/// Track and handle sprites: the track outline shows while dragged or focused with
+/// keyboard navigation (!can_change_value), hovering or editing highlights the handle.
+fn slider_sprites_ext(
+ hover: bool,
+ active: bool,
+ focused: bool,
+ can_change_value: bool,
+ dragging: bool,
+) -> (&'static str, &'static str) {
+ let track = if dragging || (active && focused && !can_change_value) {
"gui/sprites/widget/slider_highlighted"
} else {
"gui/sprites/widget/slider"
};
- let handle = if hover && active {
+ let handle = if active && (hover || can_change_value || dragging) {
"gui/sprites/widget/slider_handle_highlighted"
} else {
"gui/sprites/widget/slider_handle"
diff --git a/crates/horizon-client-render/src/world.rs b/crates/horizon-client-render/src/world.rs
index bf33d734c5..8eae439c31 100644
--- a/crates/horizon-client-render/src/world.rs
+++ b/crates/horizon-client-render/src/world.rs
@@ -70,6 +70,9 @@ pub struct World {
pub light_edits: Vec<((i32, i32, i32), u32, u32)>,
/// Saved entities of chunks that just loaded, spawned by the game.
pub loaded_entities: Vec<((i32, i32), Vec)>,
+ /// Chunks that just came into this level from a worker, and whether each
+ /// was generated rather than read from the save: the game tells native mods.
+ pub loaded_chunks: Vec<(i32, i32, bool)>,
/// Chunks changed since the last save.
pub modified: FxHashSet<(i32, i32)>,
/// Block entities, and the order they tick in.
@@ -109,6 +112,10 @@ pub struct World {
/// The moving-piston block entities of the level, by position, copied
/// from the block ticks after each tick: collision and rendering read them.
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
+ /// The `ShulkerBoxBlockEntity` lid state of the level, by position, copied
+ /// from the block entities after each tick: the collision queries read it
+ /// (`ShulkerBoxBlockEntity.getBoundingBox`), as `moving_pistons` for pistons.
+ pub shulkers: FxHashMap<[i32; 3], crate::block_entity_anim::Shulker>,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct Weather {
@@ -188,6 +195,7 @@ impl World {
dirty_light: Default::default(),
light_edits: Vec::new(),
loaded_entities: Vec::new(),
+ loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
@@ -203,6 +211,7 @@ impl World {
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
+ shulkers: FxHashMap::default(),
pois: Default::default(),
}
}
diff --git a/crates/horizon-client-render/src/zip.rs b/crates/horizon-client-render/src/zip.rs
index fbdb77a2b2..f1bf42f904 100644
--- a/crates/horizon-client-render/src/zip.rs
+++ b/crates/horizon-client-render/src/zip.rs
@@ -146,6 +146,11 @@ impl Zip {
len,
})
}
+ /// The central directory on, as read at opening: every entry's name, sizes
+ /// and checksum, so what changes with the archive's content.
+ pub fn directory(&self) -> Arc<[u8]> {
+ self.archive.clone().into_inner().tail
+ }
/// The files, as `FilePackResources.listResources` lists them (folder
/// entries skipped).
pub fn names(&self) -> impl Iterator- {
diff --git a/crates/horizon-client/assets/horizon/lang/es_es.json b/crates/horizon-client/assets/horizon/lang/es_es.json
index 1309e1fe25..d619dca426 100644
--- a/crates/horizon-client/assets/horizon/lang/es_es.json
+++ b/crates/horizon-client/assets/horizon/lang/es_es.json
@@ -12,6 +12,191 @@
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.menu_navigation": "Navegación de menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
+ "horizon.options.chat_delay": "Retardo del chat",
+ "horizon.options.lightbar.health": "Salud",
+ "horizon.options.lightbar.dimension": "Dimensión",
+ "horizon.options.glyphs.auto": "Automático",
+ "horizon.options.glyphs.xbox": "Xbox",
+ "horizon.options.glyphs.playstation": "PlayStation",
+ "horizon.options.glyphs.nintendo": "Nintendo",
+ "horizon.options.menu_focus": "Foco",
+ "horizon.options.menu_pointer": "Puntero",
+ "horizon.options.keyboard.auto": "Automático",
+ "horizon.options.keyboard.horizon": "Horizon",
+ "horizon.options.keyboard.steam": "Steam",
+ "horizon.options.unavailable": "Esta función aún no está disponible en Horizon.",
+ "horizon.options.search": "Buscar ajustes...",
+ "horizon.pages.mods": "Mods",
+ "horizon.pages.shader_packs": "Paquetes de shaders",
+ "horizon.pages.controller": "Ajustes del mando",
+ "horizon.pages.controller_binds": "Asignaciones del mando",
+ "horizon.pages.extras": "Extras de Horizon",
+ "horizon.pages.settings": "Ajustes de Horizon",
+ "horizon.pages.agent": "Agente de IA",
+ "horizon.pages.settings.tooltip": "Los ajustes propios de Horizon: gráficos, mandos, extras y mods.",
+ "horizon.pages.graphics.tooltip": "Gráficos, sin complicaciones: cada ajuste dice lo que cuesta.",
+ "horizon.pages.graphics_advanced.tooltip": "Todos los ajustes gráficos, incluidos los de Horizon, con lo que cuesta cada uno.",
+ "horizon.pages.controller.tooltip": "Mandos: sus botones, palancas, vibración y navegación de menús.",
+ "horizon.pages.extras.tooltip": "Añadidos opcionales, todos desactivados por defecto: el juego sigue siendo vanilla hasta que actives uno.",
+ "horizon.pages.agent.tooltip": "Deja que tu propio agente de IA juegue: horizon-ctl y clientes MCP en este equipo.",
+ "horizon.pages.shader_packs.tooltip": "Paquetes de shaders de la carpeta shaderpacks: los propios de Horizon y los del formato shaders/. Se ejecuta uno a la vez.",
+ "horizon.pages.mods.tooltip": "Mods hechos para Horizon, de la carpeta horizon-mods. Cada uno se ejecuta en un entorno aislado y sigue apagado hasta que lo actives.",
+ "horizon.pages.open.graphics": "Gráficos...",
+ "horizon.pages.open.graphics_advanced": "Gráficos avanzados...",
+ "horizon.pages.open.shader_packs": "Paquetes de shaders...",
+ "horizon.pages.open.controller": "Ajustes del mando...",
+ "horizon.pages.open.extras": "Extras...",
+ "horizon.pages.open.mods": "Mods...",
+ "horizon.pages.open.agent": "Agente de IA...",
+ "horizon.pages.open.horizon_graphics": "Gráficos de Horizon...",
+ "horizon.pages.open.controller_binds": "Asignaciones del mando...",
+ "horizon.pages.open.horizon_settings": "Ajustes de Horizon...",
+ "horizon.mods.empty": "No hay mods: pon archivos .hmod en la carpeta horizon-mods",
+ "horizon.mods.stopped": " (Detenido)",
+ "horizon.mods.not_built": " (Sin compilar)",
+ "horizon.mods.dev": " (Desarrollo)",
+ "horizon.mods.settings": "Ajustes",
+ "horizon.mods.open_folder": "Abrir carpeta de mods",
+ "horizon.shader_packs.empty": "No hay paquetes de shaders: ponlos en la carpeta shaderpacks",
+ "horizon.shader_packs.kind_horizon": " (Horizon)",
+ "horizon.agent.copy_details": "Copiar datos de conexión",
+ "horizon.multiplayer.join": "Unirse",
+ "horizon.multiplayer.cancel_reconnecting": "Cancelar reconexión",
+ "horizon.multiplayer.join_friend": "Unirse a un amigo",
+ "horizon.multiplayer.invite_code": "Código de invitación",
+ "horizon.multiplayer.relay": "Relay (opcional)",
+ "Preset": "Preajuste",
+ "LOD Distance": "Distancia LOD",
+ "Far Detail": "Detalle lejano",
+ "Cave Culling": "Oclusión de cuevas",
+ "Entity Culling": "Oclusión de entidades",
+ "Chunk Updates": "Actualizaciones de chunks",
+ "Upload Budget": "Presupuesto de subida",
+ "Sky": "Cielo",
+ "Fog": "Niebla",
+ "Weather": "Clima",
+ "Animated Textures": "Texturas animadas",
+ "Present Mode": "Modo de presentación",
+ "Frame Pacing": "Ritmo de fotogramas",
+ "Worker Threads": "Hilos de trabajo",
+ "Resolution Scale": "Escala de resolución",
+ "Anti-aliasing": "Antialiasing",
+ "Upscaling": "Reescalado",
+ "HDR Settings...": "Ajustes HDR...",
+ "Paper White": "Blanco de papel",
+ "Peak Brightness": "Brillo máximo",
+ "Black Level": "Nivel de negro",
+ "UI Brightness": "Brillo de la interfaz",
+ "Battery Mode": "Modo batería",
+ "Graphics API": "API de gráficos",
+ "Performance Overlay": "Superposición de rendimiento",
+ "Full Detail Distance": "Distancia de detalle completo",
+ "On": "Activado",
+ "Off": "Desactivado",
+ "Unlimited": "Ilimitado",
+ "Auto": "Automático",
+ "Custom": "Personalizado",
+ "Performance": "Rendimiento",
+ "Balanced": "Equilibrado",
+ "Quality": "Calidad",
+ "Ultra": "Ultra",
+ "Ultra Performance": "Rendimiento ultra",
+ "Native": "Nativo",
+ "Full": "Completo",
+ "Reduced": "Reducido",
+ "Efficient": "Eficiente",
+ "Precise": "Preciso",
+ "Auto (VSync)": "Automático (VSync)",
+ "FIFO": "FIFO",
+ "Mailbox": "Mailbox",
+ "Immediate": "Inmediato",
+ "Vulkan": "Vulkan",
+ "DirectX 12": "DirectX 12",
+ "OpenGL": "OpenGL",
+ "FPS and Graph": "FPS y gráfico",
+ "FPS": "FPS",
+ "TAA": "TAA",
+ "FXAA": "FXAA",
+ "No cost": "Sin coste",
+ "Low cost": "Coste bajo",
+ "Medium cost": "Coste medio",
+ "High cost": "Coste alto",
+ "Saves time": "Ahorra tiempo",
+ "Rendering": "Renderizado",
+ "Display": "Pantalla",
+ "Preferences": "Preferencias",
+ "Overlay": "Superposición",
+ "HDR": "HDR",
+ "Graphics": "Gráficos",
+ "Graphics: Advanced": "Gráficos: avanzados",
+ "Graphics: HDR": "Gráficos: HDR",
+ "Advanced...": "Avanzados...",
+ "Simple...": "Simples...",
+ "Done": "Hecho",
+ "This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
+ "Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
+ "Turn HDR on to use it.": "Activa el HDR para usarlo.",
+ "%s chunks": "%s chunks",
+ "%s per frame": "%s por fotograma",
+ "%s MiB/frame": "%s MiB/fotograma",
+ "%s KiB/frame": "%s KiB/fotograma",
+ "%s nits": "%s nits",
+ "Auto (%s)": "Automático (%s)",
+ "Auto (%s nits)": "Automático (%s nits)",
+ "%.2f nits": "%.2f nits",
+ "Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
+ "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
+ "Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
+ "Reduced draws far terrain coarser, for GPUs it slows down.": "Reducido dibuja el terreno lejano con menos detalle, para GPU a las que ralentiza.",
+ "Field of view. Wider shows more terrain at once.": "Campo de visión. Más amplio muestra más terreno a la vez.",
+ "Caps frames per second; a cap saves power and heat.": "Limita los fotogramas por segundo; un límite ahorra energía y calor.",
+ "Waits for the display's refresh: no tearing, a little latency.": "Espera al refresco de la pantalla: sin desgarro, algo de latencia.",
+ "Size of menus and the HUD.": "Tamaño de los menús y del HUD.",
+ "Sets the options below together. Changing one makes it Custom.": "Ajusta juntas las opciones siguientes. Cambiar una lo pasa a Personalizado.",
+ "Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "La lava, el fuego y la luz del sol brillan más que el blanco. Requiere una pantalla HDR.",
+ "Your display's darkest black. 0 for OLED; LCDs glow a little.": "El negro más oscuro de tu pantalla. 0 para OLED; las LCD brillan un poco.",
+ "How bright highlights get. Auto: the peak your system reports.": "Cuánto brillan los reflejos. Automático: el pico que informa tu sistema.",
+ "How bright white paper and menus look. Auto: your desktop's white.": "Qué tan brillante se ve el blanco del papel y los menús. Automático: el blanco de tu escritorio.",
+ "How bright the HUD and menus are, against paper white.": "Qué tan brillantes son el HUD y los menús respecto al blanco de papel.",
+ "How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
+ "How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
+ "Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
+ "Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
+ "Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
+ "Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
+ "Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
+ "Blends grass and water colours across biomes when building chunks.": "Mezcla los colores del césped y el agua entre biomas al construir chunks.",
+ "Cloud layer: off, flat (fast) or 3D (fancy).": "Capa de nubes: desactivada, plana (rápida) o 3D (detallada).",
+ "How far clouds reach, in chunks.": "Hasta dónde llegan las nubes, en chunks.",
+ "Draws the sky, sun, moon and stars.": "Dibuja el cielo, el sol, la luna y las estrellas.",
+ "Distance fog. Off shows the edge of the loaded world.": "Niebla de distancia. Desactivada muestra el borde del mundo cargado.",
+ "Draws rain and snow.": "Dibuja la lluvia y la nieve.",
+ "How far rain and snow fall around you.": "Hasta dónde caen la lluvia y la nieve a tu alrededor.",
+ "How many particles effects spawn.": "Cuántas partículas generan los efectos.",
+ "Round shadows under mobs and items.": "Sombras redondas bajo mobs y objetos.",
+ "Leaves with see-through gaps. Off draws them solid.": "Hojas con huecos transparentes. Desactivado las dibuja sólidas.",
+ "Sorts water and glass more exactly behind each other.": "Ordena el agua y el cristal con más precisión unos tras otros.",
+ "Which chunk rebuilds wait for the frame: none, nearby or yours.": "Qué reconstrucciones de chunks esperan al fotograma: ninguna, cercanas o la tuya.",
+ "Animates water, lava, fire and other moving textures.": "Anima el agua, la lava, el fuego y otras texturas en movimiento.",
+ "World pixels per screen pixel. Below 100% is faster on the GPU.": "Píxeles del mundo por píxel de pantalla. Por debajo del 100 % es más rápido en la GPU.",
+ "Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Suaviza los bordes dentados. FXAA: más suave. TAA: el más suave, lo que se mueve puede dejar estela.",
+ "Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderiza el mundo más pequeño y lo reconstruye con TAA. Más rápido y más suave.",
+ "HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Salida HDR en una pantalla HDR: la lava, el fuego y la luz del sol brillan más.",
+ "How frames reach the screen while VSync is off.": "Cómo llegan los fotogramas a la pantalla con VSync desactivado.",
+ "Efficient sleeps until the next frame; Precise spins, like vanilla.": "Eficiente duerme hasta el siguiente fotograma; Preciso gira, como en vanilla.",
+ "Fills the screen (borderless).": "Llena la pantalla (sin bordes).",
+ "Brightens dark places.": "Ilumina los lugares oscuros.",
+ "Lowers the frame rate when minimised, or idle too.": "Reduce los fotogramas por segundo al minimizar o al estar inactivo.",
+ "Sways the view while walking.": "Balancea la vista al caminar.",
+ "Fades newly loaded chunks in.": "Hace aparecer gradualmente los chunks recién cargados.",
+ "Darkens the screen's edges.": "Oscurece los bordes de la pantalla.",
+ "Shows when the world saves.": "Muestra cuándo se guarda el mundo.",
+ "Shows attack cooldown.": "Muestra el enfriamiento del ataque.",
+ "60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Límite de 60 FPS (30 en menús o sin foco), la mitad de los hilos: menos consumo.",
+ "Threads that generate, light and mesh chunks. Next start.": "Hilos que generan, iluminan y mallan chunks. Próximo inicio.",
+ "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan y, donde falla, DirectX 12 (Windows) u OpenGL (Linux). Próximo inicio.",
+ "Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Datos de malla enviados a la GPU por fotograma. Menos es más fluido durante la carga.",
+ "Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
diff --git a/crates/horizon-client/examples/probe/mob_ai_probe.rs b/crates/horizon-client/examples/probe/mob_ai_probe.rs
index a0f4be39f9..b20e5d2ceb 100644
--- a/crates/horizon-client/examples/probe/mob_ai_probe.rs
+++ b/crates/horizon-client/examples/probe/mob_ai_probe.rs
@@ -1194,6 +1194,7 @@ pub fn main() -> horizon_client::Result<()> {
sea_level: -63,
items: Some(&items),
lid_openers: &lid_openers,
+ mod_effects: None,
}
};
}
diff --git a/crates/horizon-client/examples/probe/section_mesh_fixture.rs b/crates/horizon-client/examples/probe/section_mesh_fixture.rs
index 1b54dc0c5b..93bbd6b6e9 100644
--- a/crates/horizon-client/examples/probe/section_mesh_fixture.rs
+++ b/crates/horizon-client/examples/probe/section_mesh_fixture.rs
@@ -7,7 +7,7 @@ use horizon_client::{
biome::{BiomeInfo, Biomes, GrassModifier},
blocks::BlockFacts,
chunk::{Chunk, Light, MIN_Y, index},
- mesher::{EMISSION_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
+ mesher::{EMISSION_BITS, FLUID_FACE_BITS, MeshTables, Mesher, OPAQUE_LAYER, TerrainVertex},
models,
};
use horizon_core::biome::BiomeZoom;
@@ -168,7 +168,7 @@ fn scene(
z,
u,
w,
- argb(v.color | EMISSION_BITS),
+ argb(v.color | EMISSION_BITS | FLUID_FACE_BITS),
light_coords(v.light)
])
})
diff --git a/crates/horizon-client/examples/probe/trading_fixture.rs b/crates/horizon-client/examples/probe/trading_fixture.rs
index f87fc1bce7..36804fba9e 100644
--- a/crates/horizon-client/examples/probe/trading_fixture.rs
+++ b/crates/horizon-client/examples/probe/trading_fixture.rs
@@ -241,7 +241,7 @@ pub fn main() -> horizon_client::Result<()> {
let items = Items::catalog(root, &PackOverlay::NONE)?;
let enchantments = Enchantments::load(root);
let names = horizon_client::creative::Names::default();
- let trades = Trades::load(root);
+ let trades = Trades::load(root, &PackOverlay::NONE);
let recipes = horizon_client::crafting::Recipes::load(root, &items, &PackOverlay::NONE)?;
let brewing = horizon_client::brewing::Brewing::load(root, &items, &PackOverlay::NONE);
let banners = horizon_client::banner::Banners::load(root);
diff --git a/crates/horizon-client/src/bin/horizon-ctl/launch.rs b/crates/horizon-client/src/bin/horizon-ctl/launch.rs
index ee0b2b8b3c..3034fe41d3 100644
--- a/crates/horizon-client/src/bin/horizon-ctl/launch.rs
+++ b/crates/horizon-client/src/bin/horizon-ctl/launch.rs
@@ -204,6 +204,9 @@ pub fn launch(socket: &Path, args: &[String]) -> Result
{
let stderr = file
.try_clone()
.map_err(|e| json!({ "error": e.to_string(), "kind": "launch" }))?;
+ // Windows: the game would otherwise hold our caller's pipes open (horizon-threads).
+ #[cfg(windows)]
+ horizon_threads::keep_stdio_from_children();
let mut child = cmd
.args(&o.game_args)
.arg("--control-socket")
diff --git a/crates/horizon-client/src/confirm_screen.rs b/crates/horizon-client/src/confirm_screen.rs
index 5e3f4d9796..afb0f5fe3e 100644
--- a/crates/horizon-client/src/confirm_screen.rs
+++ b/crates/horizon-client/src/confirm_screen.rs
@@ -13,6 +13,7 @@ pub struct Confirm {
pub lines: Vec<(Text, f32, f32)>,
/// The yes and no buttons' corners.
pub buttons: [(f32, f32); 2],
+ pub focus: Option,
}
impl Confirm {
pub fn new(font: &Font, size: (f32, f32), title: &Text, message: &Text) -> Self {
@@ -20,6 +21,28 @@ impl Confirm {
Self {
lines,
buttons: [buttons[0], buttons[1]],
+ focus: None,
+ }
+ }
+ /// Tab cycles focus between yes (0) and no (1).
+ pub fn tab(&mut self, reverse: bool) {
+ self.focus = match self.focus {
+ Some(0) => Some(1),
+ Some(1) => Some(0),
+ None => Some(if reverse { 1 } else { 0 }),
+ _ => Some(0),
+ };
+ }
+ /// Left or right arrow key focuses yes or no.
+ pub fn arrow(&mut self, left: bool) {
+ self.focus = Some(if left { 0 } else { 1 });
+ }
+ /// The answer of the currently focused button.
+ pub fn answer(&self) -> Option {
+ match self.focus {
+ Some(0) => Some(true),
+ Some(1) => Some(false),
+ _ => None,
}
}
/// The answer a click at `cursor` gives: true for yes, false for no.
@@ -56,8 +79,9 @@ impl Confirm {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
- for ((bx, by), label) in self.buttons.into_iter().zip(labels) {
- let hover = active && ui::hovered(cursor, bx, by, 150.0, 20.0);
+ for (i, ((bx, by), label)) in self.buttons.into_iter().zip(labels).enumerate() {
+ let is_focused = self.focus == Some(i);
+ let hover = active && (ui::hovered(cursor, bx, by, 150.0, 20.0) || is_focused);
ui::button(batch, atlas, bx, by, 150.0, 20.0, label, active, hover);
}
}
@@ -71,6 +95,7 @@ pub struct Choice {
/// The buttons' corners, left to right.
pub buttons: Vec<(f32, f32)>,
pub width: f32,
+ pub focus: Option,
}
impl Choice {
pub fn new(
@@ -111,8 +136,47 @@ impl Choice {
.map(|i| (bx + (width + 4.0) * i as f32, by))
.collect(),
width,
+ focus: None,
}
}
+ /// Tab cycles focus across the buttons.
+ pub fn tab(&mut self, reverse: bool) {
+ let count = self.buttons.len();
+ if count == 0 {
+ return;
+ }
+ self.focus = match self.focus {
+ Some(cur) => {
+ if reverse {
+ Some(if cur == 0 { count - 1 } else { cur - 1 })
+ } else {
+ Some((cur + 1) % count)
+ }
+ }
+ None => Some(if reverse { count - 1 } else { 0 }),
+ };
+ }
+ /// Left or right arrow key moves focus.
+ pub fn arrow(&mut self, left: bool) {
+ let count = self.buttons.len();
+ if count == 0 {
+ return;
+ }
+ self.focus = match self.focus {
+ Some(cur) => {
+ if left {
+ Some(cur.saturating_sub(1))
+ } else {
+ Some((cur + 1).min(count - 1))
+ }
+ }
+ None => Some(if left { 0 } else { count - 1 }),
+ };
+ }
+ /// The index of the currently focused button.
+ pub fn pressed(&self) -> Option {
+ self.focus
+ }
/// The button a click at `cursor` hits, counted from the left.
pub fn pressed_at(&self, cursor: (f32, f32)) -> Option {
self.buttons
@@ -130,8 +194,9 @@ impl Choice {
for (line, x, y) in &self.lines {
batch.text_styled(atlas, line, *x, *y, u32::MAX, true);
}
- for (&(bx, by), label) in self.buttons.iter().zip(labels) {
- let hover = ui::hovered(cursor, bx, by, self.width, 20.0);
+ for (i, (&(bx, by), label)) in self.buttons.iter().zip(labels).enumerate() {
+ let is_focused = self.focus == Some(i);
+ let hover = ui::hovered(cursor, bx, by, self.width, 20.0) || is_focused;
ui::button(batch, atlas, bx, by, self.width, 20.0, label, true, hover);
}
}
@@ -177,4 +242,50 @@ mod tests {
assert_eq!(two.buttons[0].0, 88.0);
assert_eq!(two.buttons[1].0, 242.0);
}
+
+ #[test]
+ fn confirm_and_choice_keyboard_navigation() {
+ let (title, message) = (Text::literal("Title"), Text::literal("Message"));
+ let mut confirm = Confirm::new(&font(), (480.0, 270.0), &title, &message);
+ assert_eq!(confirm.focus, None);
+ assert_eq!(confirm.answer(), None);
+
+ confirm.tab(false);
+ assert_eq!(confirm.focus, Some(0));
+ assert_eq!(confirm.answer(), Some(true));
+
+ confirm.tab(false);
+ assert_eq!(confirm.focus, Some(1));
+ assert_eq!(confirm.answer(), Some(false));
+
+ confirm.tab(true);
+ assert_eq!(confirm.focus, Some(0));
+
+ confirm.arrow(false);
+ assert_eq!(confirm.focus, Some(1));
+ confirm.arrow(true);
+ assert_eq!(confirm.focus, Some(0));
+
+ let mut choice = Choice::new(&font(), (480.0, 270.0), &title, &message, 3, 100.0);
+ assert_eq!(choice.focus, None);
+ assert_eq!(choice.pressed(), None);
+
+ choice.tab(false);
+ assert_eq!(choice.focus, Some(0));
+ assert_eq!(choice.pressed(), Some(0));
+
+ choice.tab(false);
+ assert_eq!(choice.focus, Some(1));
+
+ choice.tab(false);
+ assert_eq!(choice.focus, Some(2));
+
+ choice.tab(false);
+ assert_eq!(choice.focus, Some(0));
+
+ choice.arrow(false);
+ assert_eq!(choice.focus, Some(1));
+ choice.arrow(true);
+ assert_eq!(choice.focus, Some(0));
+ }
}
diff --git a/crates/horizon-client/src/container_screen.rs b/crates/horizon-client/src/container_screen.rs
index c8a690e943..5674a500c9 100644
--- a/crates/horizon-client/src/container_screen.rs
+++ b/crates/horizon-client/src/container_screen.rs
@@ -117,8 +117,8 @@ impl Clicks {
self.quick_crafting
}
- /// A press Horizon handled itself (a double click's `PICKUP_ALL`): its
- /// release clicks nothing more.
+ /// A press another screen control handled (such as a book button or
+ /// double click's `PICKUP_ALL`): its release clicks nothing more.
pub fn skip_release(&mut self) {
self.quick_crafting = None;
self.skip_next_release = true;
@@ -269,6 +269,32 @@ mod tests {
);
}
+ #[test]
+ fn a_recipe_book_click_does_not_throw_a_carried_stack_after_the_layout_shifts() {
+ let mut clicks = Clicks::default();
+ let button = Target::::Nothing;
+ // The book button consumes the press while the player carries a stack.
+ assert_eq!(
+ clicks.press(&button, Button::Left, false, false, false),
+ None
+ );
+ clicks.skip_release();
+
+ // Closing the book shifts the inventory before mouseReleased tests the
+ // pointer again; it may now look like an outside click.
+ assert_eq!(
+ clicks.release(
+ &Target::::Outside,
+ Button::Left,
+ false,
+ false,
+ false,
+ false,
+ ),
+ Release::Nothing
+ );
+ }
+
#[test]
fn a_creative_page_release_does_not_throw_a_carried_stack() {
let mut clicks = Clicks::default();
diff --git a/crates/horizon-client/src/content_identity.rs b/crates/horizon-client/src/content_identity.rs
index 5e13cf6cab..1e379989f5 100644
--- a/crates/horizon-client/src/content_identity.rs
+++ b/crates/horizon-client/src/content_identity.rs
@@ -71,6 +71,12 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
}
}
}
+ let components: Vec<_> = horizon_protocol::item::custom::entries()
+ .map(|(id, name)| (id as u64, name.to_owned()))
+ .collect();
+ if !components.is_empty() {
+ hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
+ }
identities.insert(root.to_owned(), hash);
Ok(hash)
}
diff --git a/crates/horizon-client/src/debug_chart.rs b/crates/horizon-client/src/debug_chart.rs
index 1fe7984139..92bbf9385f 100644
--- a/crates/horizon-client/src/debug_chart.rs
+++ b/crates/horizon-client/src/debug_chart.rs
@@ -468,7 +468,7 @@ fn gradient(
}
}
/// `ARGB.srgbLerp`: each channel by `Mth.lerpInt`.
-fn srgb_lerp(alpha: f32, p0: u32, p1: u32) -> u32 {
+pub fn srgb_lerp(alpha: f32, p0: u32, p1: u32) -> u32 {
let channel = |shift: u32| {
let (a, b) = (((p0 >> shift) & 255) as i32, ((p1 >> shift) & 255) as i32);
(a + (alpha * (b - a) as f32).floor() as i32) as u32
diff --git a/crates/horizon-client/src/debug_entries.rs b/crates/horizon-client/src/debug_entries.rs
index 8a2b6bd1d9..48d01d5bfe 100644
--- a/crates/horizon-client/src/debug_entries.rs
+++ b/crates/horizon-client/src/debug_entries.rs
@@ -243,7 +243,8 @@ impl DebugEntries {
/// `SerializedOptions.CODEC`: an optional `profile` name and an optional
/// `custom` map of identifiers to statuses.
fn parse(text: &str) -> Option<(Option, BTreeMap)> {
- let json: serde_json::Value = serde_json::from_str(text).ok()?;
+ let mut json: serde_json::Value = serde_json::from_str(text).ok()?;
+ upgrade(&mut json);
let object = json.as_object()?;
let profile = match object.get("profile") {
Some(name) => Some(Profile::parse(name.as_str()?)?),
@@ -350,6 +351,54 @@ impl DebugEntries {
}
}
+/// `DataFixTypes.DEBUG_PROFILE.wrapCodec(..., 4649)`: a file without a
+/// `DataVersion` is from 4649, the version the file first had.
+const FIRST_VERSION: i64 = 4649;
+
+/// The `DataFixers` rules for `References.DEBUG_PROFILE`, each run when the
+/// file is older than its schema, on the `custom` map.
+fn upgrade(json: &mut serde_json::Value) {
+ let version = json
+ .get("DataVersion")
+ .and_then(serde_json::Value::as_i64)
+ .unwrap_or(FIRST_VERSION);
+ let Some(custom) = json.get_mut("custom").and_then(|c| c.as_object_mut()) else {
+ return;
+ };
+ // 4650 DebugProfileOverlayReferenceFix: the status `inF3` is `inOverlay`.
+ if version < 4650 {
+ for status in custom.values_mut() {
+ if status.as_str() == Some("inF3") {
+ *status = "inOverlay".into();
+ }
+ }
+ }
+ // 4763 DebugProfileLookingAtSplitFix: the targeted block and fluid
+ // entries became their `_state` halves; a key already there wins.
+ if version < 4763 {
+ for (old, new) in [
+ (
+ "minecraft:looking_at_block",
+ "minecraft:looking_at_block_state",
+ ),
+ (
+ "minecraft:looking_at_fluid",
+ "minecraft:looking_at_fluid_state",
+ ),
+ ] {
+ if let Some(status) = custom.remove(old) {
+ custom.entry(new).or_insert(status);
+ }
+ }
+ }
+ // 5001 DebugProfileRenameEntryFix: `post_effect` is `post_effects`.
+ if version < 5001
+ && let Some(status) = custom.remove("minecraft:post_effect")
+ {
+ custom.insert("minecraft:post_effects".into(), status);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -410,6 +459,33 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
+ #[test]
+ fn old_files_are_upgraded_by_their_data_version() {
+ // No DataVersion: every fix runs.
+ let (_, custom) = DebugEntries::parse(
+ r#"{"custom":{"minecraft:fps":"inF3","minecraft:looking_at_block":"alwaysOn",
+ "minecraft:looking_at_fluid":"inF3","minecraft:looking_at_fluid_state":"never",
+ "minecraft:post_effect":"alwaysOn"}}"#,
+ )
+ .unwrap();
+ assert_eq!(custom["minecraft:fps"], Status::InOverlay);
+ assert_eq!(custom["minecraft:looking_at_block_state"], Status::AlwaysOn);
+ assert_eq!(custom["minecraft:looking_at_fluid_state"], Status::Never);
+ assert_eq!(custom["minecraft:post_effects"], Status::AlwaysOn);
+ assert!(!custom.contains_key("minecraft:looking_at_block"));
+ assert!(!custom.contains_key("minecraft:post_effect"));
+ // From 4763 on, only the post effects rename is left.
+ let (_, custom) = DebugEntries::parse(
+ r#"{"DataVersion":4763,"custom":{"minecraft:looking_at_block":"alwaysOn",
+ "minecraft:post_effect":"alwaysOn"}}"#,
+ )
+ .unwrap();
+ assert_eq!(custom["minecraft:looking_at_block"], Status::AlwaysOn);
+ assert_eq!(custom["minecraft:post_effects"], Status::AlwaysOn);
+ // `inF3` is no status any more once the file is past 4650.
+ assert!(DebugEntries::parse(r#"{"DataVersion":4650,"custom":{"fps":"inF3"}}"#).is_none());
+ }
+
#[test]
fn the_list_sorts_text_before_renderers() {
let all = search("");
diff --git a/crates/horizon-client/src/debug_overlay.rs b/crates/horizon-client/src/debug_overlay.rs
index f55d4ee6e2..c8991488c8 100644
--- a/crates/horizon-client/src/debug_overlay.rs
+++ b/crates/horizon-client/src/debug_overlay.rs
@@ -1,6 +1,5 @@
-//! `DebugScreenOverlay`: the F3 text. The entries of the default profile
-//! (`DebugScreenEntries.PROFILES`) report priority lines, regular lines and
-//! groups; the overlay splits them into a left and a right column and draws
+//! `DebugScreenOverlay`: the F3 text. The enabled entries
+//! (`debug-profile.json`) report priority lines, regular lines and groups; the overlay splits them into a left and a right column and draws
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
@@ -242,7 +241,12 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
}
for (line, left, top, _) in placed {
out.push(GuiDraw::Text {
- text: Text::literal(line.clone()),
+ // Entries colour their text with legacy codes.
+ text: if line.contains('§') {
+ Text::legacy(line)
+ } else {
+ Text::literal(line.clone())
+ },
x: left,
y: top,
color: TEXT,
@@ -254,6 +258,234 @@ pub fn draw(font: &Font, columns: [&[String]; 2], scale: f32, width: i32) -> Vec
out
}
+/// `ChatFormatting.UNDERLINE`, `GREEN` and `RED` as legacy codes.
+pub const UNDERLINE: &str = "§n";
+pub const GREEN: &str = "§a";
+pub const RED: &str = "§c";
+
+/// `DebugEntryLookingAtState.extractInfo`: the underlined heading with the
+/// position, the state's registered name, then one line per property
+/// (`true` green, `false` red).
+pub fn state_lines<'a>(
+ prefix: &str,
+ pos: [i32; 3],
+ name: &str,
+ properties: impl IntoIterator- ,
+) -> Vec
{
+ let mut out = vec![
+ format!("{UNDERLINE}{prefix}: {}, {}, {}", pos[0], pos[1], pos[2]),
+ name.to_owned(),
+ ];
+ out.extend(properties.into_iter().map(|(key, value)| {
+ let color = match value {
+ "true" => GREEN,
+ "false" => RED,
+ _ => "",
+ };
+ format!("{key}: {color}{value}")
+ }));
+ out
+}
+
+/// `DebugEntryLookingAt.addTagEntries`: `#` and each tag holding `id`.
+/// Vanilla walks a hash set; these are sorted, so the order holds still.
+pub fn tag_lines<'a>(
+ tags: impl IntoIterator- )>,
+ id: &str,
+) -> Vec
{
+ let mut out: Vec = tags
+ .into_iter()
+ .filter(|(_, members)| members.contains(id))
+ .map(|(tag, _)| format!("#{tag}"))
+ .collect();
+ out.sort();
+ out
+}
+
+/// `DebugEntryHeightmap.HEIGHTMAP_NAMES`.
+fn heightmap_name(kind: horizon_world::heightmap::HeightmapType) -> &'static str {
+ use horizon_world::heightmap::HeightmapType::*;
+ match kind {
+ WorldSurfaceWg => "SW",
+ WorldSurface => "S",
+ OceanFloorWg => "OW",
+ OceanFloor => "O",
+ MotionBlocking => "M",
+ MotionBlockingNoLeaves => "ML",
+ }
+}
+
+/// `DebugEntryHeightmap`: the client chunk's heightmaps (`sendToClient`),
+/// then the server chunk's (`keepAfterWorldgen`, `??` without one), each
+/// type's height from `height`.
+pub fn heightmap_lines(
+ client: impl Fn(horizon_world::heightmap::HeightmapType) -> i32,
+ server: Option<&dyn Fn(horizon_world::heightmap::HeightmapType) -> i32>,
+) -> [String; 2] {
+ use horizon_world::heightmap::HeightmapType;
+ let mut ch = String::from("CH");
+ for kind in HeightmapType::ALL
+ .into_iter()
+ .filter(|k| k.send_to_client())
+ {
+ ch.push_str(&format!(" {}: {}", heightmap_name(kind), client(kind)));
+ }
+ let mut sh = String::from("SH");
+ for kind in HeightmapType::ALL
+ .into_iter()
+ .filter(|k| k.keep_after_worldgen())
+ {
+ let height = server.map_or_else(|| "??".to_owned(), |h| h(kind).to_string());
+ sh.push_str(&format!(" {}: {height}", heightmap_name(kind)));
+ }
+ [ch, sh]
+}
+
+/// `MobCategory.getDebugAbbreviation`, in `MobCategory.values()` order.
+const CATEGORY_ABBREVIATIONS: [&str; 8] = ["MO", "C", "AM", "AX", "UWC", "WC", "WA", "MI"];
+
+/// `DebugEntrySpawnCounts`: the spawnable chunks of the last spawn state,
+/// then each category's count.
+pub fn spawn_counts_line(chunks: i32, counts: &[i32; 8]) -> String {
+ let categories: Vec = CATEGORY_ABBREVIATIONS
+ .iter()
+ .zip(counts)
+ .map(|(abbreviation, n)| format!("{abbreviation}: {n}"))
+ .collect();
+ format!("SC: {chunks}, {}", categories.join(", "))
+}
+
+/// `DebugEntryLocalDifficulty` with `DifficultyInstance.getSpecialMultiplier`.
+pub fn local_difficulty_line(effective: f32) -> String {
+ let special = if effective < 2.0 {
+ 0.0
+ } else if effective > 4.0 {
+ 1.0
+ } else {
+ (effective - 2.0) / 2.0
+ };
+ format!("Local Difficulty: {effective:.2} // {special:.2}")
+}
+
+/// `DebugEntryGpuUtilization`: past 100% in red.
+pub fn gpu_line(utilization: f64) -> String {
+ if utilization > 100.0 {
+ format!("GPU: {RED}100%")
+ } else {
+ format!("GPU: {}%", utilization.round() as i64)
+ }
+}
+
+/// `DebugEntrySoundMood`: `Library.getChannelDebugString` and the mood.
+pub fn sound_mood_line(used: [usize; 2], max: [usize; 2], mood: f32) -> String {
+ format!(
+ "Sounds: {}/{} + {}/{} (Mood {}%)",
+ used[0],
+ max[0],
+ used[1],
+ max[1],
+ (mood * 100.0).round() as i32
+ )
+}
+
+/// `DebugEntrySoundCache`: the decoded buffers and their size, in MiB
+/// rounded up.
+pub fn sound_cache_line(buffers: usize, bytes: usize) -> String {
+ let mib = (bytes as f64 / 1024.0 / 1024.0).ceil() as i64;
+ format!("Sound cache: {buffers} buffers, {mib} MiB")
+}
+
+/// `DebugEntryLight` without the server's values
+/// (`DEBUG_SHOW_SERVER_DEBUG_VALUES` is off): the raw brightness is the
+/// brighter of the two at the feet.
+pub fn light_line(sky: u8, block: u8) -> String {
+ format!(
+ "Client Light: {} ({sky} sky, {block} block)",
+ sky.max(block)
+ )
+}
+
+/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
+/// anonymous resident memory for the heap's used part, all resident memory
+/// for its committed part, the machine's memory for its maximum.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct ProcessMemory {
+ pub used: u64,
+ pub allocated: u64,
+ pub max: u64,
+}
+
+impl ProcessMemory {
+ /// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
+ pub fn read() -> Option {
+ if !cfg!(target_os = "linux") {
+ return None;
+ }
+ let status = std::fs::read_to_string("/proc/self/status").ok()?;
+ let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
+ Self::parse(&status, &meminfo)
+ }
+ fn parse(status: &str, meminfo: &str) -> Option {
+ let kib = |text: &str, key: &str| -> Option {
+ let line = text.lines().find(|l| l.starts_with(key))?;
+ Some(line.split_whitespace().nth(1)?.parse::().ok()? * 1024)
+ };
+ Some(Self {
+ used: kib(status, "RssAnon:")?,
+ allocated: kib(status, "VmRSS:")?,
+ max: kib(meminfo, "MemTotal:")?.max(1),
+ })
+ }
+}
+
+/// `DebugEntryMemory` and its `AllocationRateCalculator`: read twice a
+/// second; a drop in use (a GC in vanilla) keeps the last rate.
+#[derive(Debug, Default)]
+pub struct MemoryMeter {
+ last: Option<(std::time::Instant, ProcessMemory)>,
+ rate: i64,
+}
+
+impl MemoryMeter {
+ pub fn lines(&mut self) -> Option<[String; 3]> {
+ let now = std::time::Instant::now();
+ let memory = match self.last {
+ Some((at, memory)) if now.duration_since(at).as_millis() < 500 => memory,
+ last => {
+ let memory = ProcessMemory::read()?;
+ if let Some((at, before)) = last
+ && memory.used >= before.used
+ {
+ let seconds = now.duration_since(at).as_secs_f64();
+ self.rate = ((memory.used - before.used) as f64 / seconds).round() as i64;
+ }
+ self.last = Some((now, memory));
+ memory
+ }
+ };
+ Some(memory_lines(memory, self.rate))
+ }
+}
+
+/// `DebugEntryMemory`'s three lines.
+pub fn memory_lines(m: ProcessMemory, rate: i64) -> [String; 3] {
+ let mib = |bytes: u64| bytes / 1024 / 1024;
+ [
+ format!(
+ "Mem: {:2}% {:03}/{:03}MiB",
+ m.used * 100 / m.max,
+ mib(m.used),
+ mib(m.max)
+ ),
+ format!("Allocation rate: {:03}MiB/s", rate / 1024 / 1024),
+ format!(
+ "Allocated: {:2}% {:03}MiB",
+ m.allocated * 100 / m.max,
+ mib(m.allocated)
+ ),
+ ]
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -262,6 +494,103 @@ mod tests {
v.iter().map(|s| (*s).to_owned()).collect()
}
+ #[test]
+ fn looking_at_lines_match_vanilla() {
+ let props = [
+ ("facing", "north"),
+ ("lit", "true"),
+ ("waterlogged", "false"),
+ ];
+ assert_eq!(
+ state_lines("Targeted Block", [1, -2, 3], "minecraft:furnace", props),
+ lines(&[
+ "§nTargeted Block: 1, -2, 3",
+ "minecraft:furnace",
+ "facing: north",
+ "lit: §atrue",
+ "waterlogged: §cfalse",
+ ])
+ );
+ let tags: std::collections::HashMap> = [
+ ("minecraft:logs", vec!["minecraft:oak_log"]),
+ ("minecraft:dirt", vec!["minecraft:dirt"]),
+ ("minecraft:burnable", vec!["minecraft:oak_log"]),
+ ]
+ .into_iter()
+ .map(|(t, m)| (t.to_owned(), m.into_iter().map(str::to_owned).collect()))
+ .collect();
+ assert_eq!(
+ tag_lines(&tags, "minecraft:oak_log"),
+ lines(&["#minecraft:burnable", "#minecraft:logs"])
+ );
+ }
+
+ #[test]
+ fn world_lines_match_vanilla() {
+ use horizon_world::heightmap::HeightmapType::*;
+ let heights = |k| match k {
+ WorldSurface => 70,
+ OceanFloor => 64,
+ MotionBlocking => 69,
+ _ => 68,
+ };
+ assert_eq!(
+ heightmap_lines(heights, Some(&heights)),
+ [
+ "CH S: 70 M: 69 ML: 68".to_owned(),
+ "SH S: 70 O: 64 M: 69 ML: 68".to_owned()
+ ]
+ );
+ assert_eq!(
+ heightmap_lines(heights, None)[1],
+ "SH S: ?? O: ?? M: ?? ML: ??"
+ );
+ assert_eq!(
+ spawn_counts_line(289, &[3, 10, 1, 0, 0, 2, 4, 0]),
+ "SC: 289, MO: 3, C: 10, AM: 1, AX: 0, UWC: 0, WC: 2, WA: 4, MI: 0"
+ );
+ assert_eq!(local_difficulty_line(1.5), "Local Difficulty: 1.50 // 0.00");
+ assert_eq!(local_difficulty_line(3.0), "Local Difficulty: 3.00 // 0.50");
+ assert_eq!(light_line(12, 14), "Client Light: 14 (12 sky, 14 block)");
+ }
+
+ #[test]
+ fn performance_lines_match_vanilla() {
+ assert_eq!(gpu_line(42.4), "GPU: 42%");
+ assert_eq!(gpu_line(130.0), "GPU: §c100%");
+ assert_eq!(
+ sound_mood_line([3, 1], [247, 8], 0.256),
+ "Sounds: 3/247 + 1/8 (Mood 26%)"
+ );
+ assert_eq!(
+ sound_cache_line(12, 3 * 1024 * 1024 + 1),
+ "Sound cache: 12 buffers, 4 MiB"
+ );
+ let mib = 1024 * 1024;
+ let memory = ProcessMemory {
+ used: 512 * mib,
+ allocated: 900 * mib,
+ max: 16384 * mib,
+ };
+ assert_eq!(
+ memory_lines(memory, 40 * mib as i64),
+ [
+ "Mem: 3% 512/16384MiB".to_owned(),
+ "Allocation rate: 040MiB/s".to_owned(),
+ "Allocated: 5% 900MiB".to_owned(),
+ ]
+ );
+ let status = "Name:\thorizon\nVmRSS:\t 2048 kB\nRssAnon:\t 1024 kB\n";
+ assert_eq!(
+ ProcessMemory::parse(status, "MemTotal: 8192 kB\n"),
+ Some(ProcessMemory {
+ used: 1024 * 1024,
+ allocated: 2048 * 1024,
+ max: 8192 * 1024,
+ })
+ );
+ }
+
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs
index fd54dddd43..3765b8625a 100644
--- a/crates/horizon-client/src/entities.rs
+++ b/crates/horizon-client/src/entities.rs
@@ -10,8 +10,14 @@ use crate::{
items::ItemStack,
world::World,
};
-use horizon_core::geometry::{Aabb, Direction, Vec3};
-use horizon_world::block_collision::BlockCollisionRegistry;
+use horizon_core::{
+ geometry::{Aabb, Direction, Vec3, VoxelShape},
+ position::BlockPos,
+};
+use horizon_world::{
+ block_collision::BlockCollisionRegistry,
+ level::portal_travel::{BorderBounds, find_free_position},
+};
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
const ITEM_GRAVITY: f64 = 0.04;
@@ -76,6 +82,9 @@ pub struct ItemEntity {
/// `Entity.tickCount`: ticks since the item entered the level, which
/// paces `ItemEntity.mergeWithNeighbours`.
pub ticks: u32,
+ /// `Entity.needsSync` / `syncPosition`: immediate `ServerEntity.sendChanges`
+ /// after a bounce or velocity jump (`> 0.1` in `ItemEntity.tick`).
+ pub needs_sync: bool,
}
/// Environment results needed by the entity owner, including lethal lava damage.
#[derive(Debug)]
@@ -232,6 +241,7 @@ impl ItemEntity {
self.pickup_delay -= 1;
}
let (water_height, lava_height) = self.update_fluids(world, fluids);
+ let old_velocity = self.velocity;
let in_water = water_height > f64::from(0.1f32);
if in_water {
// ItemEntity.setUnderwaterMovement
@@ -329,6 +339,9 @@ impl ItemEntity {
&mut self.velocity,
);
}
+ if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
+ self.needs_sync = true;
+ }
self.age += 1;
// ItemEntity.tick refreshes immersion after moving.
self.update_fluids(world, fluids);
@@ -341,6 +354,7 @@ impl ItemEntity {
pub fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
self.on_ground = false;
+ self.needs_sync = true;
}
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
@@ -541,17 +555,55 @@ impl ExperienceOrb {
self.health = (self.health as f32 - damage) as i32;
self.health <= 0
}
- pub fn bounds(&self) -> Aabb {
+ /// The box of an orb whose `position` (the bottom of the box) is `at`.
+ pub fn box_at(at: Vec3) -> Aabb {
let h = 0.25;
Aabb::new(
- Vec3::new(self.position.x - h, self.position.y, self.position.z - h),
- Vec3::new(
- self.position.x + h,
- self.position.y + 0.5,
- self.position.z + h,
- ),
+ Vec3::new(at.x - h, at.y, at.z - h),
+ Vec3::new(at.x + h, at.y + 0.5, at.z + h),
)
}
+ pub fn bounds(&self) -> Aabb {
+ Self::box_at(self.position)
+ }
+ /// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs as
+ /// `if (!level.noCollision(box)) unstuckIfPossible(box.getSize())`: the free
+ /// spot nearest the box's centre, searched in a cube of the box's size
+ /// around it. `None` when the box is already free (the orb then keeps its
+ /// exact position) or when nothing free is that close. `shape(p)` is the
+ /// block's collision shape at `p`, in world coordinates.
+ pub fn unstuck(
+ at: Vec3,
+ border: &BorderBounds,
+ mut shape: impl FnMut(BlockPos) -> Option,
+ ) -> Option {
+ let bounds = Self::box_at(at);
+ let size = bounds.average_size();
+ let lo = bounds.min_position();
+ let cells = |v: f64| (v - 1.0e-7).floor() as i32 - 1..=(v + 1.0e-7).floor() as i32 + 1;
+ // noCollision(box): a block shape reaching into it leaves the orb stuck.
+ let mut stuck = false;
+ 'cells: for x in cells(lo.x) {
+ for y in cells(lo.y) {
+ for z in cells(lo.z) {
+ if shape(BlockPos::new(x, y, z))
+ .is_some_and(|s| s.boxes().iter().any(|b| b.intersects(bounds)))
+ {
+ stuck = true;
+ break 'cells;
+ }
+ }
+ }
+ }
+ if !stuck {
+ return None;
+ }
+ // `AABB.ofSize(center, size, size, size)`: where the box's centre may
+ // go, a cube of the box's mean size around its centre.
+ let allowed = Aabb::of_size(bounds.center(), Vec3::new(size, size, size));
+ find_free_position(allowed, bounds.center(), bounds.size(), border, shape)
+ .map(|p| p.subtract(Vec3::new(0.0, bounds.size().y / 2.0, 0.0)))
+ }
/// `ExperienceOrb.canMerge(orb, id, value)` for an orb that is not removed:
/// the same value and ids a multiple of 40 apart.
pub fn can_merge(&self, id: u32, value: u32) -> bool {
@@ -1171,6 +1223,7 @@ mod fire_environment_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
+ needs_sync: false,
};
let mut wood = make("minecraft:oak_planks");
let mut metal = make("minecraft:netherite_ingot");
@@ -1268,6 +1321,7 @@ mod merge_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: age,
+ needs_sync: false,
}
}
fn max_stack(stack: &ItemStack) -> u32 {
@@ -1541,6 +1595,7 @@ mod honey_slide_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
+ needs_sync: false,
}
}
@@ -1668,6 +1723,7 @@ mod slime_bounce_tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
+ needs_sync: false,
}
}
@@ -1677,9 +1733,12 @@ mod slime_bounce_tests {
item.piston_slime_bounce(Direction::Up);
assert_eq!(item.velocity, Vec3::new(0.0, 1.0, 0.0));
assert!(!item.on_ground);
+ assert!(item.needs_sync);
+ item.needs_sync = false;
item.piston_slime_bounce(Direction::East);
assert_eq!(item.velocity, Vec3::new(1.0, 1.0, 0.0));
assert!(!item.on_ground);
+ assert!(item.needs_sync);
}
#[test]
@@ -1690,4 +1749,100 @@ mod slime_bounce_tests {
assert!(bounced > 0.0);
assert_eq!(bounced, 0.8 * 0.98);
}
+
+ #[test]
+ fn a_slime_bounce_marks_the_item_for_immediate_sync() {
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../.local/horizon-client")
+ .join(horizon_core::VANILLA_VERSION);
+ if !root.join("movement-properties.nbt").exists() {
+ return;
+ }
+ let assets = crate::assets::Assets::load(&root).unwrap();
+ let render = assets.reload(&root).unwrap();
+ let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
+ let fluids = crate::motion::FluidResources::new(&assets, &facts).unwrap();
+ let collision = crate::collisions::load(&root, assets.registry).unwrap();
+ let mut world = World::test_scene(&assets, &render, 3).unwrap();
+ let slime = assets
+ .registry
+ .default_state("minecraft:slime_block")
+ .unwrap();
+ for x in 19..=21 {
+ for z in 19..=21 {
+ world.set(x, 3, z, slime).unwrap();
+ }
+ }
+ // ItemEntity.tick sets needsSync when getDeltaMovement().subtract(oldMovement).lengthSqr() > 0.01.
+ let mut falling = grounded_item();
+ falling.position = Vec3::new(20.5, 4.2, 20.5);
+ falling.previous = falling.position;
+ falling.velocity = Vec3::new(0.0, -0.5, 0.0);
+ falling.on_ground = false;
+ falling.age = 0;
+ falling.id = 1;
+ falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
+ assert_eq!(falling.position.y, 4.0);
+ assert!(falling.velocity.y > 0.0);
+ assert!(falling.on_ground);
+ assert!(falling.needs_sync);
+
+ // An idle tick where velocity only steps by -0.04 (0.04^2 = 0.0016 <= 0.01)
+ // does not set needs_sync.
+ falling.velocity = Vec3::ZERO;
+ falling.needs_sync = false;
+ falling.tick(&world, assets.registry, &facts, &collision, false, &fluids);
+ assert!(!falling.needs_sync);
+ }
+}
+
+#[cfg(test)]
+mod unstuck_tests {
+ use super::*;
+
+ const BORDER: BorderBounds = BorderBounds {
+ min_x: -1.0e6,
+ min_z: -1.0e6,
+ max_x: 1.0e6,
+ max_z: 1.0e6,
+ };
+
+ /// A level of full blocks at `solid`; every other cell is air.
+ fn shapes(solid: &[(i32, i32, i32)]) -> impl FnMut(BlockPos) -> Option + '_ {
+ move |p| {
+ if solid.contains(&(p.x, p.y, p.z)) {
+ VoxelShape::from_box(Aabb::block(p)).ok()
+ } else {
+ None
+ }
+ }
+ }
+
+ /// `level.noCollision(box)` over the same cells the search reads.
+ fn free(solid: &[(i32, i32, i32)], b: Aabb) -> bool {
+ !solid
+ .iter()
+ .any(|&(x, y, z)| Aabb::block(BlockPos::new(x, y, z)).intersects(b))
+ }
+
+ #[test]
+ fn an_orb_spawned_in_a_block_moves_to_a_free_box() {
+ // One block at y 0..1 and an orb standing in its upper half, as a mob
+ // killed inside one leaves it: its box overlaps the block by 0.1.
+ let solid = [(0, 0, 0)];
+ let at = Vec3::new(0.5, 0.9, 0.5);
+ let moved = ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)).expect("the orb moves");
+ // The nearest free box sits on the block's top face.
+ assert_eq!(moved, Vec3::new(0.5, 1.0, 0.5));
+ assert!(!ExperienceOrb::box_at(moved).intersects(Aabb::block(BlockPos::new(0, 0, 0))));
+ }
+
+ #[test]
+ fn an_orb_in_open_air_keeps_its_position() {
+ let solid = [(0, 0, 0)];
+ let at = Vec3::new(0.5, 2.0, 0.5);
+ assert!(free(&solid, ExperienceOrb::box_at(at)));
+ // Nothing to move for: the spawn sites keep the position as given.
+ assert_eq!(ExperienceOrb::unstuck(at, &BORDER, shapes(&solid)), None);
+ }
}
diff --git a/crates/horizon-client/src/entities/fluids.rs b/crates/horizon-client/src/entities/fluids.rs
index 28679dba91..93c822db47 100644
--- a/crates/horizon-client/src/entities/fluids.rs
+++ b/crates/horizon-client/src/entities/fluids.rs
@@ -243,6 +243,7 @@ mod tests {
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
+ needs_sync: false,
};
let mut still = item.clone();
assert!(still.tick(&world, assets.registry, &facts, &collision, false, &fluids));
diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs
index 28492765cd..cfe961d6b7 100644
--- a/crates/horizon-client/src/entity_nbt.rs
+++ b/crates/horizon-client/src/entity_nbt.rs
@@ -276,6 +276,16 @@ fn profile_from_nbt(tag: &Tag) -> Option
slim: text("model").map(|model| model == "slim"),
})
}
+/// `ConversionTracker.addAdditionalSaveData`: vanilla's conversion time, -1
+/// while the mob is not converting. `counter2` is that time plus one while it
+/// converts and 0 when it does not (see `mobs/undead.rs`).
+fn conversion_time(m: &Mob) -> i32 {
+ if m.extra.counter2 > 0 {
+ m.extra.counter2 - 1
+ } else {
+ -1
+ }
+}
/// `BlockState.CODEC`: the block's name when the state is that block's
/// default, `{id, properties}` for any other state.
pub fn state_to_nbt(registry: &BlockRegistry, state: BlockStateId) -> Tag {
@@ -949,7 +959,28 @@ pub fn mob_to_nbt(
)])
});
}
- "minecraft:zombie" | "minecraft:husk" | "minecraft:drowned" => {
+ // Zombie and Skeleton.addAdditionalSaveData (a husk keeps Zombie's):
+ // `ConversionTracker`'s conversion and affliction times, -1 when it is
+ // not converting or not afflicted.
+ "minecraft:zombie" | "minecraft:husk" => {
+ for (key, value) in [
+ ("DrownedConversionTime", conversion_time(m)),
+ ("InWaterTime", m.extra.counter),
+ ] {
+ map.entry(key.into()).or_insert(Tag::Int(value));
+ }
+ }
+ "minecraft:skeleton" => {
+ for (key, value) in [
+ ("StrayConversionTime", conversion_time(m)),
+ ("FreezingTime", m.extra.counter),
+ ] {
+ map.entry(key.into()).or_insert(Tag::Int(value));
+ }
+ }
+ // Drowned.convertsInWater is false, so its tracker never ticks: vanilla
+ // saves -1 for both.
+ "minecraft:drowned" => {
for (key, value) in [
("DrownedConversionTime", Tag::Int(-1)),
("InWaterTime", Tag::Int(-1)),
@@ -1832,6 +1863,23 @@ pub fn mob_from_nbt(
if crate::mobs::doors::is_zombie(m.species()) {
crate::mobs::doors::set_can_break_doors(&mut m, flag(tag, "CanBreakDoors"));
}
+ // Zombie and Skeleton.readAdditionalSaveData: `ConversionTracker` reads its
+ // affliction time (0 when absent) and starts a conversion from a saved time
+ // other than -1, -1 stopping one (`setConverting(false)`).
+ let tracker = match m.species() {
+ crate::mobs::Species::Zombie | crate::mobs::Species::Husk => {
+ Some(("InWaterTime", "DrownedConversionTime"))
+ }
+ crate::mobs::Species::Skeleton => Some(("FreezingTime", "StrayConversionTime")),
+ _ => None,
+ };
+ if let Some((affliction, conversion)) = tracker {
+ m.extra.counter = number(tag.get(affliction)).unwrap_or(0) as i32;
+ let time = number(tag.get(conversion)).unwrap_or(-1) as i32;
+ // Any other time starts a conversion (`startConversion`), a negative
+ // one spent on the next tick.
+ m.extra.counter2 = if time == -1 { 0 } else { time.max(0) + 1 };
+ }
crate::mobs::raider::read_nbt(&mut m, tag);
crate::mobs::riders::read_nbt(&mut m, tag);
// A mob saved mid-death finishes dying; one saved dead is dropped.
@@ -1900,6 +1948,7 @@ pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option {
+ match &$s.world_biomes {
+ Some(b) => b,
+ None => $s.res.biomes(),
+ }
+ };
+}
/// The world's packs' content files, as a field-level borrow (see the macros).
fn pack_files(world_tables: &Option) -> horizon_world::loot::PackOverlay<'_> {
@@ -87,7 +97,9 @@ mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod debug_keys;
+mod debug_lines;
mod debug_options;
+mod debug_renderers;
mod debug_stick;
mod decorated_pot;
mod dedicated;
@@ -101,6 +113,7 @@ mod dynamic_lights;
mod enchant_effects;
mod end;
mod entity_culling;
+mod event_loop;
mod events;
mod execution_role;
mod explosion;
@@ -179,6 +192,7 @@ mod native_panel_recipes;
mod native_recipes;
mod native_server;
mod native_shaders;
+mod native_visuals;
mod native_world_read;
mod natural_spawning;
mod nautili;
@@ -210,6 +224,7 @@ mod raids;
mod recipe_book;
mod remote;
mod remote_camera;
+mod remote_chat;
mod remote_chunks;
mod remote_mob_data;
mod remote_mob_particles;
@@ -279,6 +294,7 @@ mod wither;
mod world_loading;
mod zombie_reinforcements;
mod zombie_villagers;
+use event_loop::EventLoopCtl;
use horizon_client::{
Result,
assets::{Assets, RenderAssets},
@@ -298,7 +314,7 @@ use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
hud_layout, hud_overlays,
items::{ItemStack, Items},
- keymapping::KeyAction,
+ keymapping::{KeyAction, SNEAK, SPRINT},
light::LightTables,
mesher::{MeshTables, Vertex},
mobs::{Mob, MobType, MobTypes},
@@ -467,6 +483,8 @@ impl Options {
Ok(Some(session)) => {
o.player_name = Some(session.name.clone());
horizon_client::auth::set_current(session);
+ // The chat key pair, ready before the first join.
+ horizon_client::auth::prepare_chat_keys(true);
}
Ok(None) => {}
Err(e) => eprintln!("Horizon: not signed in: {e}"),
@@ -740,8 +758,22 @@ enum GuestUse {
OnBlock(Hand),
Item(Hand),
/// `handleInteract`: `Player.interactOn` with the hand, nothing else (the
- /// client sends UseItem itself when the entity passed).
- Entity(Hand),
+ /// client sends UseItem itself when the entity passed), on the entity the
+ /// packet named: `ServerGamePacketListenerImpl.handleInteract` resolves
+ /// `packet.entityId()` once and range checks it, so the click never picks
+ /// an entity again from the view.
+ Entity(Hand, InteractTarget),
+}
+
+/// The entity an interact packet names and where its client clicked it (the
+/// packet's location, measured from the entity's position): what
+/// `ServerGamePacketListenerImpl.handleInteract` takes from
+/// `ServerboundInteractPacket` and hands to `Player.interactOn`, which passes
+/// the point on to `interactAt`.
+#[derive(Clone, Copy)]
+struct InteractTarget {
+ id: i32,
+ location: [f64; 3],
}
/// The client world as seen by the vanilla block-behaviour library.
@@ -1662,6 +1694,17 @@ impl Resources {
}
}
+/// The equipment slot of `armor[index]`: head, chest, legs, feet.
+pub(super) fn armor_slot(index: usize) -> horizon_world::player_inventory::EquipmentSlot {
+ use horizon_world::player_inventory::EquipmentSlot;
+ match index {
+ 0 => EquipmentSlot::Head,
+ 1 => EquipmentSlot::Chest,
+ 2 => EquipmentSlot::Legs,
+ _ => EquipmentSlot::Feet,
+ }
+}
+
/// Everything the title and menus draw: the GUI atlas with its font and the
/// title panorama. Kept apart from the world bundle so the title can appear
/// before the block sprites, models and gameplay tables are ready.
@@ -1825,8 +1868,10 @@ fn follow_keymap(event: &mut WindowEvent) {
}
}
-fn drop_whole_stack(physical_keys: &HashSet) -> bool {
- physical_keys.contains(&KeyCode::ControlLeft) || physical_keys.contains(&KeyCode::ControlRight)
+/// Q throws the whole stack with Control down: the same modifier word the
+/// screens read (`has_control_down`), never a latched sprint toggle.
+fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
+ modifiers.control_key()
}
fn select_creative_tab(
@@ -1891,6 +1936,9 @@ struct Game<'a> {
weather_parameters: horizon_client::weather::ParameterCache,
panorama_rotation: horizon_client::gfx::panorama::Rotation,
world: World,
+ /// The biome table the world plays with: the game's own with the world's
+ /// packs over it (`Biomes::with_packs`), `None` before a world opens.
+ world_biomes: Option>,
streaming: Streaming,
lod: Option,
lod_source_attempted: bool,
@@ -2082,6 +2130,9 @@ struct Game<'a> {
lectern_pulses: Vec<((i32, i32, i32), i32)>,
/// LevelLoadingScreen.smoothedProgress while the player is not placed yet.
loading_progress: f32,
+ /// `CreateWorldScreen.openCreateWorldScreen`'s data read, on a worker,
+ /// while the title says `createWorld.preparing`.
+ creation_job: Option>,
/// When `loading_progress` last eased (the server thread's load, 50 ms a step).
loading_progress_at: Option,
/// The world spawn search's origin chunk and fallback height, once known.
@@ -2390,6 +2441,10 @@ struct Game<'a> {
events: events::Events,
/// The overworld's last `sky_darken` and its inputs (time, rain, thunder).
sky_darken_memo: std::cell::Cell>,
+ /// The last water plane `water_plane` found, kept while none is in reach.
+ water_plane_memo: std::cell::Cell >,
+ /// The camera's block and the second `water_plane` last scanned at.
+ water_plane_scan: std::cell::Cell >,
persist: persist::EntityMemory,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option,
@@ -2429,8 +2484,20 @@ struct Game<'a> {
debug_entries: horizon_client::debug_entries::DebugEntries,
/// `DebugOptionsScreen` while it is open (game/debug_options.rs).
debug_options: Option,
+ /// `DebugEntryMemory`'s readings (game/debug_lines.rs).
+ memory_meter: std::cell::RefCell,
+ /// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
+ /// CPU time, in percent.
+ gpu_utilization: f64,
+ /// The mobs `mob_geometry` drew last frame (`lastEntityRenderStateCount`).
+ rendered_mobs: std::cell::Cell,
+ /// `ServerChunkCache.lastSpawnState` of each level: its spawnable chunks
+ /// and each `MobCategory`'s count.
+ spawn_counts: HashMap,
/// PauseScreen.showPauseMenu (false after F3+Escape).
pause_menu: bool,
+ pause_focus: Option,
+ death_focus: Option,
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
previous_mode: Option