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, gamemode_switcher: Option, @@ -2510,6 +2577,9 @@ struct Game<'a> { /// The acting guest's own entity events (`ServerPlayer.connection.send`), /// sent with their entity id, which the guest's turn does not hold. guest_entity_events: Vec, + /// The acting guest's equipment breaks (`onEquippedItemBroken`: event and + /// position), broadcast with their entity id once their turn ends. + guest_equipment_breaks: Vec<(i8, [f64; 3])>, /// `Player.playSound` of the acting guest (event, position, volume, /// pitch): `level.playSound(player, ...)` leaves the guest out, whose /// own client plays it. @@ -3109,7 +3179,7 @@ impl Game<'_> { state, pos, ®ion, - self.res.biomes(), + world_biomes!(self), self.streaming.biome_zoom, self.streaming.biome_blend, ) @@ -3198,6 +3268,18 @@ impl Game<'_> { self.spawn_item(stack, at, v, 40); } } + /// Item entity `i` leaves the level (`Entity.discard`); mods hear it. + pub(super) fn remove_dropped(&mut self, i: usize) -> ItemEntity { + let e = self.dropped.remove(i); + self.native_entities_left("minecraft:item", &[e.id]); + e + } + /// Experience orb `i` leaves the level (`Entity.discard`); mods hear it. + pub(super) fn remove_orb(&mut self, i: usize) -> ExperienceOrb { + let o = self.orbs.remove(i); + self.native_entities_left("minecraft:experience_orb", &[o.id]); + o + } /// The item entity `spawn_item` just added, unless a native mod kept it /// out of the level (`native_entity_join_level`). pub(super) fn just_spawned_item(&mut self) -> Option<&mut ItemEntity> { @@ -3230,6 +3312,7 @@ impl Game<'_> { pickup_delay, bob_offset: bob, ticks: 0, + needs_sync: false, }); // ServerLevel.addFreshEntity. if !self.native_entity_joins(self.next_entity, "minecraft:item", position, false) { @@ -3629,7 +3712,7 @@ impl Game<'_> { player_box, guest_boxes, player_collision, - biomes: self.res.biomes(), + biomes: world_biomes!(self), seed: self.level.seed, behaviours: self.res.behaviours(), items: world_items!(self), @@ -5160,8 +5243,10 @@ impl Game<'_> { // A guest's use packets name one hand and touch no entity: the client // sends one per hand and part of its click, and Interact for entities. let guest = self.guest_use; - if let Some(GuestUse::Entity(hand)) = guest { - return Ok(self.with_hand(hand, Game::interact_entity)?.then_some(hand)); + if let Some(GuestUse::Entity(hand, target)) = guest { + return Ok(self + .with_hand(hand, |g| g.interact_entity(Some(target)))? + .then_some(hand)); } let on_block = matches!(guest, Some(GuestUse::OnBlock(_))); if !on_block @@ -5171,7 +5256,7 @@ impl Game<'_> { { return Ok(Some(Hand::Main)); } - if guest.is_none() && self.interact_entity()? { + if guest.is_none() && self.interact_entity(None)? { return Ok(Some(Hand::Main)); } // An EntityHitResult may fall through to Item.use, never to useItemOn. @@ -5181,7 +5266,7 @@ impl Game<'_> { self.pick() }; let hands = match guest { - Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand)) => { + Some(GuestUse::OnBlock(hand) | GuestUse::Item(hand) | GuestUse::Entity(hand, _)) => { vec![hand] } None => Hand::BOTH.to_vec(), @@ -5189,7 +5274,7 @@ impl Game<'_> { let entity = guest.is_none() && self.entity_targeted(); for hand in hands { // The off hand's turn starts on the entity too (Minecraft.startUseItem). - if entity && hand == Hand::Off && self.with_hand(hand, Game::interact_entity)? { + if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? { return Ok(Some(hand)); } // A client of a server never hits a fence knot with its crosshair: @@ -5204,15 +5289,28 @@ impl Game<'_> { } Ok(None) } - /// `Player.interactOn` for the entity under the crosshair with the - /// acting hand: whether it took the click. - fn interact_entity(&mut self) -> Result { + /// The index in `list` of the entity an interact packet's `target` names, + /// by its server id (`Level.getEntity(id)`), or None for an id this level + /// does not hold: `handleInteract` finds nothing then either. + fn named_entity(list: &[T], target: i32, id: impl Fn(&T) -> u32) -> Option { + list.iter().position(|e| id(e) as i32 == target) + } + /// `Player.interactOn` with the acting hand: whether it took the click. A + /// guest's interact packet names its entity (`target`), which the helpers + /// use as it is; the local player picks from the crosshair instead, the + /// way `Minecraft.hitResult` does. + fn interact_entity(&mut self, target: Option) -> Result { // Native mods, where Player.interactOn starts: before the entity's - // own interact and the item's interactLivingEntity. - if let Some(Some(id)) = self.pick_entity_hit() - && let Some(target) = self.level_entity(id) + // own interact and the item's interactLivingEntity. The entity is the + // one the packet named, or the one the crosshair picked. + let named = match target { + Some(t) => Some(t.id), + None => self.pick_entity_hit().flatten(), + }; + if let Some(id) = named + && let Some(entity) = self.level_entity(id) { - let kind = self.target_type(target); + let kind = self.target_type(entity); let stack = self.held().cloned(); let mut claimed = false; self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed); @@ -5221,13 +5319,15 @@ impl Game<'_> { return Ok(true); } } - Ok(self.interact_frame() - || self.interact_armor_stand() - || self.interact_cushion() - || self.interact_boat() - || self.interact_cart() - || self.interact_mob()? - || self.interact_knot()) + Ok(self.interact_frame(target) + || self.interact_armor_stand(target) + || self.interact_cushion(target) + || self.interact_boat(target) + || self.interact_cart(target) + || self.interact_mob(target)? + // Knots are reached only through the crosshair pick for now: a + // guest's Interact naming a knot's id is a known gap. + || target.is_none() && self.interact_knot()) } /// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a /// `use_cooldown` component starts its entry (`UseCooldown.apply`). A @@ -6028,7 +6128,7 @@ impl Game<'_> { if self.destroy_delay > 0 { self.destroy_delay -= 1; } - if !self.left || self.screen != Screen::Playing { + if !self.left || self.screen != Screen::Playing || self.dialog_open() { self.mining = None; return Ok(()); } @@ -6276,6 +6376,7 @@ impl Game<'_> { // A saved death already dropped inventory/XP and counted the death. if player.health.dead_or_dying() { self.screen = Screen::Dead; + self.death_focus = None; self.ui.died_at = Some(Instant::now()); self.ui.death_text = None; self.ui.death_confirm = None; @@ -6345,6 +6446,12 @@ impl Game<'_> { None => 0.0, }; self.ticks += 1; + // An in-frame LAN host's player has the entity id its guests see it by. + if !self.serving() + && let (Some(host), Some(p)) = (&self.lan, self.player.as_mut()) + { + p.entity_id = host.host_entity; + } self.track_experience_display(); self.tick_enchanting_book(); // `/tick freeze` stops the world but not the player. @@ -6399,7 +6506,7 @@ impl Game<'_> { let camera_eye = self.camera_eye_height(); let stick = self.pad_move(); if self.riding.is_some() && self.player.is_some() { - let playing = self.screen == Screen::Playing; + let playing = self.screen == Screen::Playing && !self.dialog_open(); let held = |k| playing && self.keys.contains(&k); let keys = Keys { forward: held(KeyCode::KeyW), @@ -6419,6 +6526,8 @@ impl Game<'_> { let spin_targets = self.spin_targets(); let sprint_window = self.settings.sprint_window; self.sync_player_body(); + // Read before `player` borrows `self` mutably. + let dialog = self.dialog_open(); let Some(player) = self.player.as_mut() else { // A dedicated server has no local player: the world still runs. if self.options.dedicated.is_some() { @@ -6434,7 +6543,7 @@ impl Game<'_> { if !self.streaming.is_lit(cx, cz) { return Ok(()); } - let playing = self.screen == Screen::Playing && self.sleeping.is_none(); + let playing = self.screen == Screen::Playing && self.sleeping.is_none() && !dialog; let held = |k| playing && self.keys.contains(&k); let keys = Keys { forward: held(KeyCode::KeyW), @@ -6728,6 +6837,7 @@ impl Game<'_> { self.respawn_player()?; } else { self.screen = Screen::Dead; + self.death_focus = None; self.capture(false); self.ui.death_time = 0; } @@ -6749,6 +6859,7 @@ impl Game<'_> { /// world's entities and blocks, and the network hub. fn tick_world(&mut self, running: bool) -> Result<()> { self.share_spawn_conditions(); + horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes")); self.update_chunk_tickets(); horizon_client::spans::time("tick_effects", || self.tick_effects(running)); self.tick_sign_editor(); @@ -6795,7 +6906,10 @@ impl Game<'_> { self.handle_entity_portals(); self.handle_end_gateway_portals(); }); + let before = self.dropped_ids(); horizon_client::spans::time("tick_items", || self.tick_items()); + // Merged, burnt, despawned or picked up. + self.native_items_left_since(&before); horizon_client::spans::time("touch_arrows", || self.touch_arrows()); } horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities()); @@ -6804,7 +6918,10 @@ impl Game<'_> { if running { horizon_client::spans::time("tick_mobs", || self.tick_mobs())?; horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers()); + let before = self.orb_ids(); horizon_client::spans::time("tick_experience", || self.tick_experience()); + // Merged, despawned or picked up. + self.native_orbs_left_since(&before); horizon_client::spans::time("entity_portals", || { self.find_entity_portals(); self.resolve_arrivals(); @@ -6855,6 +6972,7 @@ impl Game<'_> { && self.use_delay == 0 && self.using.is_none() && self.screen == Screen::Playing + && !self.dialog_open() && self.sleeping.is_none() { self.use_with_hand()?; @@ -6934,7 +7052,8 @@ impl Game<'_> { (_, false) => KeyAction::Press, (_, true) => KeyAction::Repeat, }; - let screen_open = self.screen != Screen::Playing || self.title.is_some(); + let screen_open = + !gameplay_has_keyboard(self.screen, self.title.is_some(), self.dialog_open()); self.toggles.key(slot, action, screen_open, option); self.sync_toggle(slot); if !pressed { @@ -6957,7 +7076,9 @@ impl Game<'_> { /// `KeyMapping.releaseAll` when a screen opens and /// `restoreToggleStatesOnScreenClosed` when it closes. fn track_toggle_screens(&mut self) { - let playing = self.screen == Screen::Playing && self.title.is_none(); + let dialog = self.dialog_open(); + drop_held_behind_dialog(dialog, &mut self.keys, &mut self.left, &mut self.right); + let playing = gameplay_has_keyboard(self.screen, self.title.is_some(), dialog); if playing == self.was_playing { return; } @@ -7055,6 +7176,7 @@ impl Game<'_> { && self.server_packs.prompt.is_none() && !self.dialog_open() { + self.pause_focus = None; self.screen = Screen::Paused; } } @@ -7395,6 +7517,10 @@ impl Game<'_> { return batch; } if self.screen == Screen::Options { + if self.title.is_none() { + self.flush_skin_slots(); + self.update_remote_skins(); + } self.draw_options(&mut batch); return batch; } @@ -8912,7 +9038,7 @@ impl Game<'_> { (Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap), (None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap), (None, KeyCode::KeyQ) => { - let all = drop_whole_stack(&self.physical_keys); + let all = drop_whole_stack(self.modifiers); self.menu_click(Some(slot), i32::from(all), Input::Throw) } _ => false, @@ -8937,6 +9063,13 @@ impl Game<'_> { self.inv_clicks.skip_release(); return; } + // AbstractRecipeBookScreen lets the book handle its buttons before + // AbstractContainerScreen starts a drag or chooses an outside slot. + // Toggling it can move the inventory under the pointer before release. + if self.recipe_book_click(right) { + self.inv_clicks.skip_release(); + return; + } let (slot, outside) = self.inventory_target(); let shift = self.has_shift_down(); let target = Target::of(slot, outside); @@ -8948,13 +9081,6 @@ impl Game<'_> { false, ); if outside { - // AbstractRecipeBookScreen.mouseClicked: the book takes the click - // before the outside test, and its tabs sit left of the strip - // hasClickedOutside counts as the book. - if self.recipe_book_click(right) { - self.inv_clicks.skip_release(); - return; - } // slotId -999: an empty hand throws nothing; a carried stack // starts a drag whose release drops it. match input { @@ -9013,13 +9139,13 @@ impl Game<'_> { } } if let Some(slot) = slot - && !shift && slot != 200 { // An item tab's display slots take no drags or PICKUP_ALL. let display = slot >= Creative::DISPLAY; let now = std::time::Instant::now(); - let double = !right + let double = !shift + && !right && !display && self.carried.is_some() && self.last_inv_click.is_some_and(|(at, last, was_right)| { @@ -9034,12 +9160,16 @@ impl Game<'_> { } if self.carried.is_some() { let ty = i32::from(right); - let first = if !display && self.quick_craft_accepts(slot, ty, 0) { - vec![slot] + // `mouseClicked` starts a quick craft drag whenever the hand holds a stack, + // Shift or not: `mouseDragged` fills the slots the cursor crosses, so a + // Shift held over the drag spreads the same way (#4166). The drag starts + // empty on a Shift press: a release with nothing spread is `mouseReleased`'s + // click on the hovered slot (Pickup, or QuickMove under Shift). + if !shift && !display && self.quick_craft_accepts(slot, ty, 0) { + self.inv_drag = Some((ty, vec![slot])); } else { - Vec::new() - }; - self.inv_drag = Some((ty, first)); + self.inv_drag = Some((ty, Vec::new())); + } return; } } @@ -9107,17 +9237,31 @@ impl Game<'_> { use horizon_client::container_screen::Button; if right { Button::Right } else { Button::Left } } - /// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: the - /// physical Shift keys, whatever the sneak key is bound or toggled to. + /// `Screen.hasShiftDown` / `InputWithModifiers.hasShiftDown`: 26.3 keeps + /// no key cache — every event answers with its own modifiers bit + /// (`InputWithModifiers.java:54-56`, `keys = 3`), and the pointer and + /// keyboard events carry the OS's live word, refreshed at the focus. The + /// window's `ModifiersChanged` is that word here; key releases can die + /// on the way from the OS (a stuck Shift then kept every screen's Shift + /// and a sneak the key no longer held: #4166's in-game report), so a + /// cached key set is not vanilla's answer. pub(super) fn has_shift_down(&self) -> bool { - self.physical_keys.contains(&KeyCode::ShiftLeft) - || self.physical_keys.contains(&KeyCode::ShiftRight) + self.modifiers.shift_key() } - /// `Minecraft.hasControlDown`: the physical Control keys, whatever the - /// sprint key is bound or toggled to. + /// The same live word for Control (`InputWithModifiers.hasControlDown`). pub(super) fn has_control_down(&self) -> bool { - self.physical_keys.contains(&KeyCode::ControlLeft) - || self.physical_keys.contains(&KeyCode::ControlRight) + self.modifiers.control_key() + } + /// Real events word the modifiers by their own `ModifiersChanged`; a + /// control socket's `key` is synthesized, so it words the bit directly + /// and every reader can ask the one live state. + fn press_modifier_key(&mut self, key: KeyCode, down: bool) { + horizon_client_input::modifiers::set_key( + &mut self.modifiers, + &mut self.physical_keys, + key, + down, + ); } /// `hasClickedOutside` of the open screen at the cursor. fn clicked_outside(&self, batch: &HudBatch) -> bool { @@ -9642,6 +9786,7 @@ impl Game<'_> { see_through.clear(); self.name_tag_geometry(camera, alpha, &mut out, &mut see_through); self.test_instance_marker_geometry(camera, &mut out, &mut see_through); + self.debug_renderer_geometry(camera, &mut out); self.text_display_geometry(camera, alpha, &mut out, &mut see_through); self.see_through_vertices = see_through; // Entity shadows (EntityRenderer.extractShadow). @@ -10568,7 +10713,7 @@ impl Game<'_> { (out, stars) } /// What the title screen asked for, from a click or Enter. - fn title_action(&mut self, event_loop: &ActiveEventLoop, action: Action) -> Result<()> { + fn title_action(&mut self, event_loop: &dyn EventLoopCtl, action: Action) -> Result<()> { match action { Action::Quit => event_loop.exit(), Action::Options => self.open_options(true), @@ -10646,6 +10791,28 @@ impl Game<'_> { } let _ = std::fs::remove_dir_all(dir); } + // The world's pack selection (`DataPackConfig`), for the pack stack + // to read: the picked packs on, the feature packs left out off. + // Only when `install` did not run (no Data Packs screen opened): it + // writes the file itself, and its `disabled` must not be lost. + if let Some(o) = create.filter(|o| o.pack_dir.is_none()) { + let world = std::env::current_dir()?.join("saves").join(folder); + if let Err(e) = std::fs::create_dir_all(&world) { + log::warn!("Horizon: data packs for {folder}: {}: {e}", world.display()); + } else { + let disabled: Vec = + horizon_client::datapack::feature_packs(&self.vanilla_root()) + .into_iter() + .map(|p| p.id) + .filter(|id| !o.data_packs.contains(id)) + .collect(); + let json = serde_json::json!({"enabled": o.data_packs, "disabled": disabled}); + let file = world.join("datapacks.json"); + if let Err(e) = std::fs::write(&file, json.to_string()) { + log::warn!("Horizon: data packs for {folder}: {}: {e}", file.display()); + } + } + } if self .options .execution_role @@ -10710,6 +10877,9 @@ impl Game<'_> { let dim = Dim::from_id(&level.dimension); self.level_shapes = shapes; self.world_root = save_root.clone(); + // The world's packs over the game's biomes (`Biomes::with_packs`), + // the table its chunks were built with. + self.world_biomes = self.packed_biomes(); horizon_client::crash::set_detail( "World", format!("{} ({})", level.name, save_root.display()), @@ -10880,6 +11050,7 @@ impl Game<'_> { dim, self.level_shapes.get(dim.id()), seed, + &self.level.enabled_features, )?; self.end.set_locator(locator); self.share_spawn_conditions(); @@ -10909,7 +11080,7 @@ impl Game<'_> { let keys: Vec<_> = self.world.modified.drain().collect(); let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k))); self.save - .save(&chunks, self.pack.assets.registry, self.res.biomes())?; + .save(&chunks, self.pack.assets.registry, world_biomes!(self))?; self.save_dimension_entities()?; // Coordinates scale by 8 between the Overworld and the Nether. let scale = match (self.dim, target) { @@ -11311,31 +11482,23 @@ impl Game<'_> { _ => 0, }) .collect(); - for (slot, amount) in self.armor.iter_mut().zip(losses) { + for (index, (slot, amount)) in self.armor.iter_mut().zip(losses).enumerate() { if let Some(stack) = slot.as_mut() && let Some(info) = world_items!(self).get(&stack.item) && info.max_damage > 0 { stack.damage += amount; if stack.damage >= info.max_damage { - broken_items.push(stack.clone()); + broken_items.push((stack.clone(), index)); *slot = None; broke = true; } } } if broke { - if let Some(eye) = self.eye_position() { - self.audio.play( - "minecraft:entity.item.break", - Some(eye), - 0.8, - 0.8 + self.world.random.next_float() * 0.4, - ); - } - // LivingEntity.breakItem for each armour piece that broke. - for stack in &broken_items { - self.eat_particles(stack, 5); + // LivingEntity.onEquippedItemBroken for each armour piece that broke. + for (stack, index) in &broken_items { + self.player_equipment_broke(armor_slot(*index), stack); } self.apply_equipment(); } @@ -11700,16 +11863,11 @@ impl Game<'_> { let gone = stack.clone(); *cell = None; self.stats.add("broken", &broken, 1); - if let Some(eye) = self.eye_position() { - self.audio.play( - "minecraft:entity.item.break", - Some(eye), - 0.8, - 0.8 + self.world.random.next_float() * 0.4, - ); - } - // LivingEntity.breakItem: the broken item's five crumbs. - self.eat_particles(&gone, 5); + // LivingEntity.onEquippedItemBroken: the sound and the crumbs. + self.player_equipment_broke( + horizon_world::player_inventory::EquipmentSlot::MainHand, + &gone, + ); } } } @@ -12004,9 +12162,22 @@ impl Game<'_> { self.add_orb(at, value); } } + /// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs on an + /// orb whose box is in a block: the free spot nearest its box's centre. An + /// orb in open air keeps its position. + fn unstuck_orb(&self, at: [f64; 3]) -> [f64; 3] { + let b = self.res.behaviours(); + let border = self.border_bounds(); + ExperienceOrb::unstuck(Vec3::new(at[0], at[1], at[2]), &border, |p| { + b.collision_shape(self.world.block(p.x, p.y, p.z)) + .map(|s| s.moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z)))) + }) + .map_or(at, |p| [p.x, p.y, p.z]) + } /// `new ExperienceOrb(level, pos, Vec3.ZERO, value)`: yaw and motion from /// the orb's own random. fn add_orb(&mut self, at: [f64; 3], value: u32) { + let at = self.unstuck_orb(at); let mut r = dispenser::entity_random(); let _yaw = r.next_float(); let p = Vec3::new(at[0], at[1], at[2]); @@ -13543,6 +13714,19 @@ impl Game<'_> { &self.world_root, self.options.execution_role, )?; + // PlayerList.saveAll: every placed player saves with the server, to + // their own file (`PlayerDataStorage.save`); the level itself carries + // no player. + if self.options.dedicated.is_some() + && self.options.execution_role == execution_role::ExecutionRole::IntegratedServer + && let Some(host) = self.lan.take() + { + for g in host.hub.guests.iter().filter(|g| g.placed) { + let vehicle = self.root_vehicle_uuid(g.vehicle); + horizon_client::lan::playerdata::save(g, &self.world_root, vehicle); + } + self.lan = Some(host); + } self.save_world_state(); self.message = Some(("Saved the game".into(), Instant::now())); Ok(()) @@ -13554,7 +13738,7 @@ impl Game<'_> { let keys: Vec<_> = self.world.modified.drain().collect(); let chunks = self.with_entities(keys.iter().filter_map(|k| self.world.chunks.get(k))); self.save - .save_later(&chunks, self.pack.assets.registry, self.res.biomes())?; + .save_later(&chunks, self.pack.assets.registry, world_biomes!(self))?; // Prepare entities while the chunk writer runs, then use one barrier. self.save_entities(false)?; self.save_raids(); @@ -13774,7 +13958,7 @@ impl Game<'_> { // EnvironmentAttributeProbe: the spatially interpolated biome colours, // Gaussian-weighted over the noise biomes around the camera (plains // where no chunk is loaded, as ClientLevel.getUncachedNoiseBiome). - let biomes = self.res.biomes(); + let biomes = world_biomes!(self); let weights = { let chunks = &self.world.chunks; let plains = biomes.id("minecraft:plains"); @@ -13821,6 +14005,23 @@ impl Game<'_> { let g = self.debug_gizmos(alpha as f32, camera.to_array()); lines.extend(g.lines); let on_top = g.on_top; + let colored_lights = if self.title.is_none() { + self.native_frame_visuals(camera, &mut lines) + } else { + Vec::new() + }; + let emissive_layers = if self.title.is_none() { + let mut requests = Vec::new(); + self.native_emissive(&mut requests); + horizon_client::native_emissive::mappings( + &self.pack.render.sprite_layers, + requests + .into_iter() + .take(horizon_client::native_emissive::MAX), + ) + } else { + Vec::new() + }; let view = horizon_client::camera::view_matrix(view_yaw, view_pitch); let at = camera.to_array(); // GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown. @@ -14034,6 +14235,17 @@ impl Game<'_> { detail_distance: (self.streaming.radius * 16) as f32, depth_far, underwater, + water_plane: if title + || underwater + || !self + .renderer + .as_ref() + .is_some_and(|r| r.wants_water_plane()) + { + None + } else { + self.water_plane(camera) + }, title, sky_blocked: self.potions.effects.get("blindness").is_some() || self.potions.effects.get("darkness").is_some() @@ -14087,6 +14299,8 @@ impl Game<'_> { entity_spans: &entity_spans, glowing: &glowing, dynamic_lights: &self.dynamic_lights, + colored_lights: &colored_lights, + emissive_layers: &emissive_layers, lightning: horizon_client::gfx::lightning::Input { bolts: &bolts, uv: horizon_client::gfx::lightning::white_uv(&self.gui.atlas.font.white()), @@ -14317,6 +14531,7 @@ impl Game<'_> { KeyCode::Escape => match self.screen { Screen::Options => self.options_key(KeyCode::Escape, None)?, Screen::Playing => { + self.pause_focus = None; self.screen = Screen::Paused; self.pause_menu = true; self.capture(false); @@ -14328,12 +14543,17 @@ impl Game<'_> { } Screen::Paused | Screen::Inventory => { self.close_container(); + self.pause_focus = None; self.screen = Screen::Playing; self.pause_menu = true; self.capture(true); } Screen::GameModeSwitcher => self.close_gamemode_switcher(false), - Screen::Dead => {} + Screen::Dead => { + if self.ui.death_confirm.is_some() { + self.death_confirm_answer(false)?; + } + } Screen::Stats | Screen::Friends => self.screen = Screen::Paused, Screen::ServerLinks => self.screen = Screen::Paused, // ConfirmScreen.keyPressed: Escape answers no; both return to @@ -14433,7 +14653,7 @@ impl Game<'_> { self.using = None; } KeyCode::KeyQ if self.screen == Screen::Playing => { - let all = drop_whole_stack(&self.physical_keys); + let all = drop_whole_stack(self.modifiers); self.drop_held(all); } KeyCode::Digit1 @@ -14473,6 +14693,12 @@ impl Game<'_> { self.selected = slot; } } + _ if self.screen == Screen::Paused && self.pause_menu => { + self.pause_key(key)?; + } + _ if self.screen == Screen::Dead => { + self.death_key(key)?; + } _ => {} } Ok(()) @@ -14638,6 +14864,8 @@ impl Game<'_> { horizon_client::spans::time("streaming", || self.streaming.update(&mut self.world)); self.streaming.extra.truncate(areas); self.spawn_fresh_chunks(); + // Before the unloaded chunks' block entities are written and dropped. + self.native_chunks_changed(&unloaded); if let Some(lod) = &mut self.lod { self.lod_observe .extend(uploads.iter().map(|(key, _)| (key.0, key.2))); @@ -14681,7 +14909,7 @@ impl Game<'_> { if !modified.is_empty() && let Err(e) = horizon_client::spans::time("save_chunks", || { self.save - .save_later(&modified, self.pack.assets.registry, self.res.biomes()) + .save_later(&modified, self.pack.assets.registry, world_biomes!(self)) }) { log::warn!("Horizon: failed to save unloaded chunks: {e}"); @@ -14847,7 +15075,49 @@ impl ApplicationHandler for Game<'_> { self.apply_settings(); self.previous = Instant::now(); } - fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, mut event: WindowEvent) { + fn window_event(&mut self, event_loop: &ActiveEventLoop, id: WindowId, event: WindowEvent) { + self.handle_window_event(event_loop, id, event); + } + fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) { + // The real mouse never steers an offscreen game: its pointer is not grabbed. + if self.pending_save.is_none() + && self.captured + && self.focused + && !self.options.offscreen + && self.screen == Screen::Playing + && let DeviceEvent::MouseMotion { delta: (x, y) } = event + { + // MouseHandler.onMove with the pointer grabbed; applied once per frame. + self.mouse.on_move(0.0, 0.0, x, y, true, true); + self.last_input = Instant::now(); + } + } + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + self.idle(event_loop); + } + /// The application is ending. On macOS, Quit (Cmd+Q) ends it through + /// applicationWillTerminate without a CloseRequested; vanilla's GLFW + /// window turns it into a window close, so Minecraft.stop saves the world. + fn exiting(&mut self, _event_loop: &ActiveEventLoop) { + self.pad_close_output(); + if let Err(e) = self.save_for_exit() { + log::warn!("Horizon: failed to save the world on exit: {e}"); + } + self.finish_perf(); + } +} + +/// winit's events for a window's game, with any loop: a headless client (#4951) has no +/// winit loop and calls these from its own. +impl Game<'_> { + /// winit's `window_event`. The control socket and the pad menu call it too, with the + /// input they make up. + pub(super) fn handle_window_event( + &mut self, + event_loop: &dyn EventLoopCtl, + id: WindowId, + mut event: WindowEvent, + ) { if self.window.as_ref().is_none_or(|w| w.id() != id) { return; } @@ -14886,6 +15156,27 @@ impl ApplicationHandler for Game<'_> { } if let WindowEvent::ModifiersChanged(m) = &event { self.modifiers = m.state(); + horizon_client_input::modifiers::reconcile( + self.modifiers, + &mut self.physical_keys, + &self.settings.keys, + &mut self.toggles, + [self.settings.toggle_crouch, self.settings.toggle_sprint], + [ + self.pad + .holds_action(&self.settings.controller, "key.sneak"), + self.pad + .holds_action(&self.settings.controller, "key.sprint"), + ], + ); + self.sync_toggle(SNEAK); + self.sync_toggle(SPRINT); + } + // `PackSelectionScreen.onFilesDrop` on the Create New World screen. + if let WindowEvent::DroppedFile(path) = &event + && let Some(title) = &mut self.title + { + title.drop_file(path.clone()); } // Fullscreen is a window shortcut, before screens consume keyboard input. if let WindowEvent::KeyboardInput { event, .. } = &event @@ -15117,6 +15408,9 @@ impl ApplicationHandler for Game<'_> { if self.screen == Screen::Stats { self.stats_drag(); } + if self.screen == Screen::DebugOptions { + self.debug_options_drag(); + } if self.screen == Screen::WorldOptions { self.world_options_drag(); } @@ -15438,6 +15732,9 @@ impl ApplicationHandler for Game<'_> { Screen::DebugOptions if pressed && button == MouseButton::Left => { self.debug_options_click() } + Screen::DebugOptions if button == MouseButton::Left => { + self.debug_options_release() + } Screen::ConfirmLink if pressed && button == MouseButton::Left => { self.confirm_link_click() } @@ -15628,25 +15925,51 @@ impl ApplicationHandler for Game<'_> { self.options_key(key, event.text.as_deref())?; } } + WindowEvent::KeyboardInput { event, .. } + if self.screen == Screen::Paused + && event.state == ElementState::Pressed + && self.pause_menu + && !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) => + { + if let PhysicalKey::Code(key) = event.physical_key { + self.native_key_event(key, true, event.repeat); + } + if let PhysicalKey::Code(key) = event.physical_key { + self.pause_key(key)?; + } + } + WindowEvent::KeyboardInput { event, .. } + if self.screen == Screen::Dead && event.state == ElementState::Pressed => + { + if let PhysicalKey::Code(key) = event.physical_key { + self.native_key_event(key, true, event.repeat); + } + if let PhysicalKey::Code(key) = event.physical_key { + self.death_key(key)?; + } + } WindowEvent::KeyboardInput { event, .. } if self.title.is_some() => { if event.state == ElementState::Pressed { if let Some(shortcut) = self.edit_shortcut(&event) { self.title_shortcut(shortcut); return Ok(()); } + let (w, h) = self.gui_size(); match event.physical_key { // `WorldListEntry.keyPressed`: Space joins too, with the button click sound. PhysicalKey::Code( code @ (KeyCode::Enter | KeyCode::NumpadEnter | KeyCode::Space), ) if code != KeyCode::Space - || self.title.as_ref().is_some_and(|t| t.list_focused()) => + || self.title.as_ref().is_some_and(|t| { + t.list_focused() || t.focused_button.is_some() + }) => { - let action = self + let (action, sound) = self .title .as_mut() - .map_or(Action::None, |t| t.confirm_key()); + .map_or((Action::None, false), |t| t.confirm_key_size((w, h))); self.save_title_options(); - if matches!(action, Action::Play { .. }) { + if sound { self.audio.play_button_click(); } self.title_action(event_loop, action)?; @@ -15665,10 +15988,21 @@ impl ApplicationHandler for Game<'_> { t.field_key(key, ctrl, shift); } } + PhysicalKey::Code( + key @ (KeyCode::ArrowLeft + | KeyCode::ArrowRight + | KeyCode::ArrowUp + | KeyCode::ArrowDown), + ) => { + if let Some(t) = self.title.as_mut() { + t.arrow_key(key, (w, h)); + } + } PhysicalKey::Code(KeyCode::Backspace) => self.title_edit(None, true), PhysicalKey::Code(KeyCode::Tab) => { + let shift = self.has_shift_down(); if let Some(t) = self.title.as_mut() { - t.tab(); + t.tab_dir(shift, (w, h)); } } PhysicalKey::Code(KeyCode::Escape) => { @@ -15724,9 +16058,14 @@ impl ApplicationHandler for Game<'_> { (_, false) => KeyAction::Press, (_, true) => KeyAction::Repeat, }; - if let Some(pressed) = - keyboard_button(self.screen == Screen::Playing, action) - { + if let Some(pressed) = keyboard_button( + gameplay_has_keyboard( + self.screen, + self.title.is_some(), + self.dialog_open(), + ), + action, + ) { self.play_mouse(button, pressed)?; } } @@ -15750,6 +16089,7 @@ impl ApplicationHandler for Game<'_> { && self.title.is_none() && self.sign_edit.is_none() && self.book.is_none() + && !self.dialog_open() { let turn = |k| f32::from(u8::from(self.keys.contains(&k))); let secs = elapsed.as_secs_f32().min(0.1); @@ -15845,6 +16185,10 @@ impl ApplicationHandler for Game<'_> { ms(cpu), r.gpu_times.last().copied(), ); + if let Some(&gpu) = r.gpu_times.last() { + self.gpu_utilization = + f64::from(gpu) * 100.0 / f64::from(ms(cpu).max(0.001)); + } self.frame_stats.record_cpu(ms(cpu)); let render = r.last_encode; self.frame_stats.record_phases([ @@ -15943,21 +16287,9 @@ impl ApplicationHandler for Game<'_> { event_loop.exit(); } } - fn device_event(&mut self, _: &ActiveEventLoop, _: DeviceId, event: DeviceEvent) { - // The real mouse never steers an offscreen game: its pointer is not grabbed. - if self.pending_save.is_none() - && self.captured - && self.focused - && !self.options.offscreen - && self.screen == Screen::Playing - && let DeviceEvent::MouseMotion { delta: (x, y) } = event - { - // MouseHandler.onMove with the pointer grabbed; applied once per frame. - self.mouse.on_move(0.0, 0.0, x, y, true, true); - self.last_input = Instant::now(); - } - } - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + /// winit's `about_to_wait`: what happens between frames (stop requests, the closing + /// save, world entry, the control socket, pads), then the next frame. + pub(super) fn idle(&mut self, event_loop: &dyn EventLoopCtl) { if horizon_threads::stop_requests() > 0 && self.pending_save.as_ref().is_none_or(|save| !save.quit) { @@ -16017,23 +16349,13 @@ impl ApplicationHandler for Game<'_> { // Offscreen frames don't wait for the window to be drawn: an // occluded window (a locked macOS session) gets no redraws. let id = w.id(); - self.window_event(event_loop, id, WindowEvent::RedrawRequested); + self.handle_window_event(event_loop, id, WindowEvent::RedrawRequested); } else { w.request_redraw(); } } event_loop.set_control_flow(ControlFlow::Poll); } - /// The application is ending. On macOS, Quit (Cmd+Q) ends it through - /// applicationWillTerminate without a CloseRequested; vanilla's GLFW - /// window turns it into a window close, so Minecraft.stop saves the world. - fn exiting(&mut self, _event_loop: &ActiveEventLoop) { - self.pad_close_output(); - if let Err(e) = self.save_for_exit() { - log::warn!("Horizon: failed to save the world on exit: {e}"); - } - self.finish_perf(); - } } /// clouds.png as filled cells (`CloudRenderer.prepare`: alpha 10 or more). fn load_clouds(root: &std::path::Path) -> (u32, u32, Vec) { @@ -16099,6 +16421,7 @@ impl<'a> Game<'a> { &gui.atlas.translucent, ); Ok(Game { + world_biomes: None, res: res.clone(), rest_loader: None, pending_entry: None, @@ -16250,6 +16573,7 @@ impl<'a> Game<'a> { book_last_click: None, lectern_pulses: Vec::new(), loading_progress: 0.0, + creation_job: None, loading_progress_at: None, spawn_origin: None, spawn_spiral: Default::default(), @@ -16432,6 +16756,8 @@ impl<'a> Game<'a> { native_lifecycle: Default::default(), native_net: Default::default(), sky_darken_memo: Default::default(), + water_plane_memo: Default::default(), + water_plane_scan: Default::default(), persist: Default::default(), remote: None, lan: None, @@ -16454,7 +16780,13 @@ impl<'a> Game<'a> { &std::env::current_dir().unwrap_or_default(), ), debug_options: None, + memory_meter: Default::default(), + gpu_utilization: 0.0, + rendered_mobs: Default::default(), + spawn_counts: HashMap::new(), pause_menu: true, + pause_focus: None, + death_focus: None, previous_mode: None, gamemode_switcher: None, clipboard: Default::default(), @@ -16485,6 +16817,7 @@ impl<'a> Game<'a> { guest_packets: Vec::new(), guest_cooldowns: Vec::new(), guest_entity_events: Vec::new(), + guest_equipment_breaks: Vec::new(), guest_player_sounds: Vec::new(), lan_acting_name: None, lan_acting_uuid: None, @@ -16652,6 +16985,7 @@ impl Game<'_> { fn resume_world_entry(&mut self) -> Result<()> { self.poll_integrated_start()?; self.poll_world_rest()?; + self.poll_world_creation(); if let Some(loader) = &self.world_loader { if !loader.ready() { return Ok(()); @@ -16708,6 +17042,44 @@ impl Game<'_> { Ok(()) } + /// `CreateWorldScreen.openCreateWorldScreen`: read the world generation + /// data the create screen describes, on a worker, then open the screen + /// (`title.preparing`). + fn poll_world_creation(&mut self) { + let Some(title) = self.title.as_ref() else { + return; + }; + if !title.preparing() { + return; + } + if self.creation_job.is_none() { + let dir = self.options.resources.join("worldgen"); + let registry = self.pack.assets.registry; + self.creation_job = std::thread::Builder::new() + .name("world-creation".into()) + .spawn(move || { + if let Err(e) = horizon_client::vanillagen::shared_data(&dir, registry) { + log::warn!("Horizon: preparing world creation failed: {e}"); + } + }) + .ok(); + return; + } + if !self + .creation_job + .as_ref() + .is_none_or(std::thread::JoinHandle::is_finished) + { + return; + } + if let Some(job) = self.creation_job.take() { + let _ = job.join(); + } + if let Some(title) = self.title.as_mut() { + title.finish_preparing(); + } + } + fn poll_world_rest(&mut self) -> Result<()> { if let Some(loader) = &self.rest_loader && loader.ready()? @@ -16743,6 +17115,16 @@ impl Game<'_> { } } /// The world's packs' content files, none without packs. + /// The biome table the world plays with, `None` before a world opens. + fn packed_biomes(&self) -> Option> { + let table = world_loading::packed_biomes( + &self.world_root, + &self.options.resources, + self.res.biomes(), + &self.level.enabled_features, + ); + (!Arc::ptr_eq(&table, self.res.biomes())).then_some(table) + } fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> { pack_files(&self.world_tables) } @@ -16754,6 +17136,7 @@ impl Game<'_> { &self.world_root, &self.options.resources, self.cmds.vanilla_disabled, + &self.level.enabled_features, ); if files.is_empty() { return None; @@ -17599,6 +17982,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { detail_distance: 12.0, depth_far: 1000.0, underwater: false, + water_plane: None, cave_culling: false, title: true, sky_blocked: false, @@ -17623,6 +18007,8 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> { entity_spans: &[], glowing: &[], dynamic_lights: &[], + colored_lights: &[], + emissive_layers: &[], clouds: None, lightning: Default::default(), additive: &[], @@ -18294,6 +18680,7 @@ pub fn run() -> Result<()> { { return Ok(()); } + let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server); // Before any worker starts: on Linux they go behind the server thread // and the render thread. horizon_threads::put_helpers_behind(); @@ -18429,6 +18816,26 @@ fn keyboard_button(playing: bool, action: KeyAction) -> Option { playing.then_some(action != KeyAction::Release) } +/// Gameplay owns the keyboard: `Screen::Playing`, no title, no server dialog. +/// Vanilla's `DialogScreen` is a screen, so `KeyMapping.releaseAll` runs. +fn gameplay_has_keyboard(screen: Screen, title: bool, dialog: bool) -> bool { + screen == Screen::Playing && !title && !dialog +} + +/// A dialog drops held keys and mouse buttons it does not see (#4974). +fn drop_held_behind_dialog( + dialog: bool, + keys: &mut HashSet, + left: &mut bool, + right: &mut bool, +) { + if dialog { + keys.clear(); + *left = false; + *right = false; + } +} + /// `VoxelShape.clip` of a fluid source's shape for a ray that starts in the /// source's own block `cell`: the face and point it hits, in `pick_fluid`'s /// `Hit` terms. `FlowingFluid.getShape` is the whole block under the same @@ -18673,7 +19080,40 @@ mod tests { assert!(!equipment_modifier_applies("mainhand", "offhand")); assert!(!equipment_modifier_applies("offhand", "mainhand")); } - use horizon_client::keymapping::{KeyAction, SPRINT, ToggleKeys}; + use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys}; + + /// A guest's interact packet names its entity and the server keeps it: + /// `ServerGamePacketListenerImpl.handleInteract` resolves + /// `packet.entityId()` once and `Player.interactOn` clicks that entity, + /// while a fresh pick would take the nearest one. Two cows stand one + /// behind the other; the packet names the far one. + #[test] + fn an_interact_packet_clicks_the_entity_it_names_not_the_nearest() { + // (server id, distance from the guest) in the level's own order. + let cows = [(17u32, 1.5f64), (23, 2.5)]; + assert_eq!(Game::named_entity(&cows, 23, |c| c.0), Some(1)); + // The near cow is reachable too, when that is the one named. + assert_eq!(Game::named_entity(&cows, 17, |c| c.0), Some(0)); + // An id the level does not hold clicks nothing (Level.getEntity). + assert_eq!(Game::named_entity(&cows, 31, |c| c.0), None); + } + + /// The id path keeps the crosshair pick's filters, so a packet cannot name + /// a mob the pick would refuse: one in its death animation, the one the + /// player rides, or the camera entity. Without them a guest could shear, + /// feed or leash a dying mob that `mob_hit` never returns. + #[test] + fn an_interact_packet_cannot_name_a_mob_the_pick_would_refuse() { + use super::creatures::named_mob_pickable; + assert!(named_mob_pickable(true, 17, None, None)); + // Dead: `Mob.alive()` is health > 0. + assert!(!named_mob_pickable(false, 17, None, None)); + // Ridden, and the camera entity (`remote_camera_id`). + assert!(!named_mob_pickable(true, 17, Some(17), None)); + assert!(!named_mob_pickable(true, 17, None, Some(17))); + // Another mob being ridden or watched does not block this one. + assert!(named_mob_pickable(true, 17, Some(23), Some(29))); + } /// A mod screen on a hopper or dispenser menu owns its slots: the stock /// grid stands back so `slot_position` reaches the mod's own slots @@ -18748,6 +19188,34 @@ mod tests { assert_eq!(keyboard_button(false, action), None); } } + + /// A server dialog is a screen: held keys and the use button are released + /// (`KeyMapping.releaseAll`), and toggled sneak comes back when it closes (#4974). + #[test] + fn a_dialog_releases_held_keys_like_a_screen() { + assert!(gameplay_has_keyboard(Screen::Playing, false, false)); + assert!(!gameplay_has_keyboard(Screen::Playing, false, true)); + assert!(!gameplay_has_keyboard(Screen::Playing, true, false)); + assert!(!gameplay_has_keyboard(Screen::Chat, false, false)); + + let mut keys = HashSet::from([KeyCode::KeyW]); + let mut left = true; + let mut right = true; + drop_held_behind_dialog(false, &mut keys, &mut left, &mut right); + assert!(keys.contains(&KeyCode::KeyW) && left && right); + drop_held_behind_dialog(true, &mut keys, &mut left, &mut right); + assert!(keys.is_empty() && !left && !right); + + let options = [true, false, false, false]; + let mut toggles = ToggleKeys::default(); + toggles.key(SNEAK, KeyAction::Press, false, true); + toggles.key(SNEAK, KeyAction::Release, false, true); + assert!(toggles.is_down(SNEAK)); + toggles.screen_opened(options); + assert!(!toggles.is_down(SNEAK)); + toggles.screen_closed(options); + assert!(toggles.is_down(SNEAK)); + } #[test] fn sleep_fade_lasts_ten_ticks_after_waking() { let alpha = |timer| match hud_overlays::sleep_overlay(320, 240, timer).first() { @@ -18919,7 +19387,7 @@ mod tests { } #[test] - fn stack_drop_requires_physical_ctrl_even_with_sprint_latched() { + fn stack_drop_requires_ctrl_even_with_sprint_latched() { for sprint in [false, true] { let mut toggles = ToggleKeys::default(); if sprint { @@ -18929,17 +19397,29 @@ mod tests { assert_eq!(toggles.is_down(SPRINT), sprint); for held in 0..4 { + let mut modifiers = winit::keyboard::ModifiersState::empty(); let mut physical_keys = HashSet::from([KeyCode::ShiftLeft, KeyCode::KeyQ]); - if held & 1 != 0 { - physical_keys.insert(KeyCode::ControlLeft); + let sides = [KeyCode::ControlLeft, KeyCode::ControlRight]; + for (bit, key) in sides.into_iter().enumerate() { + if held & (1 << bit) != 0 { + horizon_client_input::modifiers::set_key( + &mut modifiers, + &mut physical_keys, + key, + true, + ); + } } - if held & 2 != 0 { - physical_keys.insert(KeyCode::ControlRight); + assert_eq!(drop_whole_stack(modifiers), held != 0); + for key in sides { + horizon_client_input::modifiers::set_key( + &mut modifiers, + &mut physical_keys, + key, + false, + ); } - assert_eq!(drop_whole_stack(&physical_keys), held != 0); - physical_keys.remove(&KeyCode::ControlLeft); - physical_keys.remove(&KeyCode::ControlRight); - assert!(!drop_whole_stack(&physical_keys)); + assert!(!drop_whole_stack(modifiers)); assert_eq!(toggles.is_down(SPRINT), sprint); } } diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index a8d9af99b2..9884083d50 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -331,6 +331,7 @@ impl Game<'_> { &self.world_root, &self.options.resources, self.cmds.vanilla_disabled, + &self.level.enabled_features, ); self.adv.pending_tree = std::thread::Builder::new() .name("horizon-advancements".into()) diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 37b41eda46..cd8f5ae7fe 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -3,6 +3,7 @@ //! like a player (walk, look, break, place, use, click buttons). Actions that //! take time run as a task advanced every frame and reply when they finish or //! time out, so `horizon-ctl break 10 64 3` returns what the block dropped. +use super::event_loop::EventLoopCtl; use super::{Game, Screen}; use horizon_client::{ Result, @@ -23,10 +24,8 @@ use std::{ time::{Duration, Instant}, }; use winit::{ - application::ApplicationHandler, dpi::PhysicalPosition, event::{DeviceId, ElementState, MouseButton, MouseScrollDelta, TouchPhase, WindowEvent}, - event_loop::ActiveEventLoop, keyboard::KeyCode, }; @@ -957,7 +956,7 @@ impl Game<'_> { /// Synthesize the pointer moving to GUI pixel (x, y) and a click there. pub(super) fn synth_click( &mut self, - event_loop: &ActiveEventLoop, + event_loop: &dyn EventLoopCtl, x: f32, y: f32, button: MouseButton, @@ -968,7 +967,7 @@ impl Game<'_> { /// the others and a release at the last (one point is a click). fn synth_drag( &mut self, - event_loop: &ActiveEventLoop, + event_loop: &dyn EventLoopCtl, points: &[(f32, f32)], button: MouseButton, ) -> Result<()> { @@ -981,7 +980,7 @@ impl Game<'_> { let device_id = DeviceId::dummy(); for (i, &(x, y)) in points.iter().enumerate() { let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale)); - self.window_event( + self.handle_window_event( event_loop, id, WindowEvent::CursorMoved { @@ -990,7 +989,7 @@ impl Game<'_> { }, ); if i == 0 { - self.window_event( + self.handle_window_event( event_loop, id, WindowEvent::MouseInput { @@ -1001,7 +1000,7 @@ impl Game<'_> { ); } } - self.window_event( + self.handle_window_event( event_loop, id, WindowEvent::MouseInput { @@ -1035,7 +1034,7 @@ impl Game<'_> { words: &[&str], line: &str, reply: &Sender, - event_loop: &ActiveEventLoop, + event_loop: &dyn EventLoopCtl, ) -> Result { let name = words[0]; let a = parse_args(&words[1..]); @@ -1561,7 +1560,7 @@ impl Game<'_> { .ok_or("no renderer yet")?; let device_id = DeviceId::dummy(); let position = PhysicalPosition::new(f64::from(x * scale), f64::from(y * scale)); - self.window_event( + self.handle_window_event( event_loop, id, WindowEvent::CursorMoved { @@ -1570,7 +1569,7 @@ impl Game<'_> { }, ); for _ in 0..steps.abs() as u32 { - self.window_event( + self.handle_window_event( event_loop, id, WindowEvent::MouseWheel { diff --git a/crates/horizon-client/src/game/allays.rs b/crates/horizon-client/src/game/allays.rs index 66aa530bc8..3af85e13b9 100644 --- a/crates/horizon-client/src/game/allays.rs +++ b/crates/horizon-client/src/game/allays.rs @@ -122,7 +122,7 @@ impl Game<'_> { let e = &mut self.dropped[j]; e.stack.count -= take as u32; if e.stack.count == 0 { - self.dropped.remove(j); + self.remove_dropped(j); } let at = [pos.x, pos.y, pos.z]; let pitch = diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 6a2e203d26..ee08de5612 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -2,7 +2,7 @@ //! falling, `interactAt` equipment swaps by clicked height, the two-hit break //! of `hurtServer` (creative breaks at once), and `ArmorStandRenderer` with //! the default pose, the hit wobble and humanoid armor layers. -use super::Game; +use super::{Game, InteractTarget}; use horizon_client::{ entities::ArmorStand, items::ItemStack, @@ -485,12 +485,30 @@ impl Game<'_> { _ => 0, } } - /// `ArmorStand.interactAt`. - pub(super) fn interact_armor_stand(&mut self) -> bool { - let Some((i, d, point)) = self.armor_stand_hit() else { + /// `ArmorStand.interactAt` for the stand under the crosshair, or for the + /// stand a guest's interact packet named (its client's click point). + pub(super) fn interact_armor_stand(&mut self, target: Option) -> bool { + let Some((i, d, point)) = (match target { + // interactAt gets the packet's location, measured from the stand's + // position, so getClickedSlot reads the height the client clicked. + Some(t) => Self::named_entity(&self.armor_stands, t.id, |a| a.id).map(|i| { + let p = self.armor_stands[i].position; + ( + i, + 0.0, + [ + p.x + t.location[0], + p.y + t.location[1], + p.z + t.location[2], + ], + ) + }), + None => self.armor_stand_hit(), + }) else { return false; }; - if !self.before_block(d) { + // Only a client's own pick is stopped by a block: the range check passed. + if target.is_none() && !self.before_block(d) { return false; } let held = self.held().cloned(); @@ -1130,7 +1148,8 @@ impl Game<'_> { && self.remote.is_none() && let Some(eye) = self.eye_position() { - self.spawn_item(rest, eye, [0.0; 3], 40); + // Player.drop(stack, false): thrown from the eyes, no thrower. + self.throw_from_eyes(rest, eye, false); } // The attribute modifiers of a worn piece come from the server's // ClientboundUpdateAttributesPacket on a client. diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index a56471c90f..2a556eea17 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -315,13 +315,18 @@ impl Game<'_> { .tick(pos, &players, &mut anims.book_random); } Ticker::Shulker { facing } => { - // The lid's push of entities in its way (moveCollidedEntities) - // and the neighbour updates are left to the level. - self.be_anims - .shulkers - .entry(pos) - .or_default() - .tick(pos, facing); + // A window that simulates the world ticks its boxes on the + // server pass (`tick_block_entities`, which also applies + // `moveCollidedEntities`); a server's client only animates + // what it is sent. Either way the collision queries read + // the lids from `World::shulkers` (`sync_shulkers`). + if self.remote.is_some() { + self.be_anims + .shulkers + .entry(pos) + .or_default() + .tick(pos, facing); + } } Ticker::Skull { powered } => { self.be_anims.skulls.entry(pos).or_default().tick(powered); diff --git a/crates/horizon-client/src/game/block_entity_ticks.rs b/crates/horizon-client/src/game/block_entity_ticks.rs index 7e40ca9e3f..57e6acb037 100644 --- a/crates/horizon-client/src/game/block_entity_ticks.rs +++ b/crates/horizon-client/src/game/block_entity_ticks.rs @@ -8,6 +8,7 @@ use super::Game; use horizon_client::{ Result, block_entities::{Pos, Ticking}, + block_entity_anim::Ticker, containers::{BlockEntity, FurnaceKind, HOPPER_SLOTS}, furnace::FurnaceCooking, }; @@ -25,6 +26,8 @@ impl Game<'_> { self.world.block_entities.set_states(states); let containers = self.native_containers(); self.world.block_entities.set_mod_containers(containers); + let fluids = self.native_fluid_tables(); + self.world.block_entities.set_mod_fluids(fluids); } // Only lingering cooldowns are dropped; a hopper that ticks checks // its own block. Once a second is soon enough for the rest. @@ -90,8 +93,60 @@ impl Game<'_> { self.client_tick_trial_spawner(key); } } + // `ShulkerBoxBlockEntity.tick`: the lids of the boxes this level + // animates. Their grown collision shape reaches the collision queries + // through `World::shulkers`, and an opening lid pushes what it runs + // into (`moveCollidedEntities`), as a moving piston does. + let pushes = self.tick_shulkers(); + self.sync_shulkers(); + self.push_shulker_entities(pushes); Ok(()) } + /// `ShulkerBoxBlockEntity.tick` for the boxes in block-ticking chunks, in + /// position order: the lid's new progress, and the swept box of an opening + /// lid for `moveCollidedEntities`. A box whose block is gone is dropped. + /// The boxes are those the level animates: a box opens (`start_open` and + /// block event 1) before it has one, exactly as vanilla's block entity is + /// there from the start. + fn tick_shulkers(&mut self) -> Vec { + let ticking = self.block_ticking_area(); + let mut pushes = Vec::new(); + let mut gone = Vec::new(); + let positions: Vec<[i32; 3]> = self.be_anims.shulkers.keys().copied().collect(); + for pos in positions { + let state = self.world.block(pos[0], pos[1], pos[2]); + let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state.0) else { + gone.push(pos); + continue; + }; + if !ticking(pos[0], pos[2]) + || !self.border.contains(f64::from(pos[0]), f64::from(pos[2])) + { + continue; + } + let tick = self + .be_anims + .shulkers + .get_mut(&pos) + .map(|shulker| shulker.tick(pos, facing)); + if let Some(push) = tick.and_then(|tick| tick.push) { + pushes.push((pos, facing, push)); + } + } + for pos in gone { + self.be_anims.shulkers.remove(&pos); + } + pushes + } + /// Copies the shulker box lids into the world, where the collision queries + /// read them (`SceneCollisions::shulker_collision_state`), as + /// `sync_moving_pistons` does for pistons. + pub(super) fn sync_shulkers(&mut self) { + self.world.shulkers.clear(); + self.world + .shulkers + .extend(self.be_anims.shulkers.iter().map(|(p, s)| (*p, s.clone()))); + } /// `BlockState.getTicker` of every state, for the block entities ticked /// here. A creaking heart keeps its slot while uprooted (see /// `tick_creaking_heart`). diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index 2396746805..b5f243d51b 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -2,7 +2,7 @@ //! paddling controls, collisions, riding (`positionRider`, `clampRotation`, //! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking, //! saving and `AbstractBoatRenderer` with animated paddles. -use super::{Game, Keys, entity_culling::at, view_vector}; +use super::{Game, InteractTarget, Keys, entity_culling::at, view_vector}; use horizon_client::{ blocks::Fluid, collisions::{EntityCollisions, SceneCollisions}, @@ -261,9 +261,72 @@ impl Game<'_> { fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> { self.fluid_height_of(x, y, z, Some(Fluid::Water)) } + /// The horizontal water plane around the camera for the water mirror: the median + /// standing-water surface of a grid of columns 48 blocks around, each scanned down from + /// the camera to its ground. A lake or the sea has one plane; the mirror shows only the + /// water on it. With no water in reach the last plane found stays, so the mirror never + /// switches off as the camera moves. + pub(super) fn water_plane(&self, camera: glam::DVec3) -> Option { + const RADIUS: i32 = 48; + const STEP: usize = 4; + const DEPTH: i32 = 96; + let (cx, cz) = (camera.x.floor() as i32, camera.z.floor() as i32); + let top = camera.y.floor() as i32 + 2; + // Scanned again once the camera moves 8 blocks, or every 5 seconds: a scan reads up + // to 60 000 blocks on the game thread, and the plane rarely changes. + let scan = ( + [cx >> 3, top >> 3, cz >> 3], + self.world.game_time.div_euclid(100), + ); + if self.water_plane_scan.replace(Some(scan)) == Some(scan) { + return self.water_plane_memo.get(); + } + let mut heights = Vec::with_capacity(625); + for x in (cx - RADIUS..=cx + RADIUS).step_by(STEP) { + for z in (cz - RADIUS..=cz + RADIUS).step_by(STEP) { + // The top of the column's water: the highest standing water under the + // camera (a full block's water continues above it). + for y in (top - DEPTH..=top).rev() { + if let Some((h, source)) = self.fluid_height(x, y, z) { + if source && h < 1.0 { + heights.push(y as f32 + h); + } + break; + } + if self.res.facts().get(self.world.block(x, y, z)).solid_render { + break; + } + } + } + } + if heights.is_empty() { + return self.water_plane_memo.get(); + } + heights.sort_by(f32::total_cmp); + let found = heights[heights.len() / 2]; + // The plane in use stays while a quarter of the columns are still on it, so two + // lakes at different heights do not make the mirror flicker between them. + let plane = match self.water_plane_memo.get() { + Some(last) + if 4 * heights.iter().filter(|h| (*h - last).abs() < 0.05).count() + >= heights.len() => + { + last + } + _ => found, + }; + self.water_plane_memo.set(Some(plane)); + Some(plane) + } /// `FluidState.getHeight` and whether it is a source, for `fluid` or, /// with `None`, any fluid (`ClipContext.Fluid.ANY`). - fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option) -> Option<(f32, bool)> { + pub(super) fn fluid_height_of( + &self, + x: i32, + y: i32, + z: i32, + fluid: Option, + ) -> Option<(f32, bool)> { let f = self.res.facts().get(self.world.block(x, y, z)); if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) { return None; @@ -875,13 +938,18 @@ impl Game<'_> { } best } - /// `AbstractBoat.interact`: board the boat under the crosshair. - pub(super) fn interact_boat(&mut self) -> bool { - let Some((i, d)) = self.boat_hit() else { + /// `AbstractBoat.interact`: board the boat under the crosshair, or the one + /// a guest's interact packet named. + pub(super) fn interact_boat(&mut self, target: Option) -> bool { + let Some((i, d)) = (match target { + Some(t) => Self::named_entity(&self.boats, t.id, |b| b.id).map(|i| (i, 0.0)), + None => self.boat_hit(), + }) else { return false; }; let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft); - if !self.before_block(d) { + // Only a client's own pick is stopped by a block: the range check passed. + if target.is_none() && !self.before_block(d) { return false; } // AbstractBoat.interact starts with Entity.interact: the lead. diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 1a2078161e..d00527f662 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -4,7 +4,7 @@ use super::{Game, Screen}; use horizon_client::{ chat::{ Chat, ChatAbilities, ChatAction, ChatScreen, ChatSource, ChatVisibility, Commands, - SYSTEM_INDICATOR, + SYSTEM_INDICATOR, normalize_chat_message, }, command::Registries, gamerules::GameRules, @@ -857,14 +857,17 @@ impl Game<'_> { screen.type_text(&m, &c, text); } } - /// ChatScreen.handleChatInput: history, then a command or a chat line. - /// The Chat option's restrictions stop the message here too, so a - /// control-socket `chat` cannot send what the player's setting hides. + /// ChatScreen.handleChatInput: the message comes in normalized + /// (`normalizeChatMessage`: trimmed, whitespace runs collapsed, cut to the + /// 256 UTF-16 units the packet holds), into history, then as a command or + /// a chat line. The Chat option's restrictions stop the message here too, + /// so a control-socket `chat` cannot send what the player's setting hides. pub(super) fn handle_chat_input(&mut self, message: &str) { - if !self.chat_abilities().allows(message) { + let message = normalize_chat_message(message); + if message.is_empty() || !self.chat_abilities().allows(&message) { return; } - self.ui.chat.add_recent(message); + self.ui.chat.add_recent(&message); // On a server (single player's own included) the server runs it, // unless a client mod owns the command. if self.remote.is_some() { @@ -873,7 +876,7 @@ impl Game<'_> { { return; } - self.send_chat(message); + self.send_chat(&message); return; } if let Some(command) = message.strip_prefix('/') { @@ -884,7 +887,7 @@ impl Game<'_> { } else { let line = Text::translate( "chat.type.text", - &[self.player_display_name(), Text::literal(message)], + &[self.player_display_name(), Text::literal(message.as_str())], ); let focused = self.screen == Screen::Chat; self.ui.chat.add( @@ -895,7 +898,7 @@ impl Game<'_> { self.ticks, focused, ); - self.lan_host_chat(message); + self.lan_host_chat(&message); } } pub(super) fn chat_scroll(&mut self, amount: f32) { @@ -1066,7 +1069,7 @@ impl Game<'_> { return; } if self.remote.is_some() { - self.send_chat(&format!("/{command}")); + self.send_unattended_command(command); } else { self.run_command(command); } @@ -1260,6 +1263,7 @@ impl Game<'_> { self.world.health_rules.fall_damage = self.ui.rules.bool("fall_damage"); self.world.health_rules.fire_damage = self.ui.rules.bool("fire_damage"); self.world.health_rules.freeze_damage = self.ui.rules.bool("freeze_damage"); + horizon_client::mobs::sulfur_cube::set_tnt_explodes(self.ui.rules.bool("tnt_explodes")); use horizon_world::food::Difficulty; self.world.health_rules.difficulty = match self.ui.difficulty { 0 => Difficulty::Peaceful, diff --git a/crates/horizon-client/src/game/combat.rs b/crates/horizon-client/src/game/combat.rs index a25ad6227f..662737f583 100644 --- a/crates/horizon-client/src/game/combat.rs +++ b/crates/horizon-client/src/game/combat.rs @@ -20,6 +20,9 @@ struct Entry { fall_distance: f32, /// The causing entity: its id (for comparisons) and display name. attacker: Option<(u32, Text)>, + /// The causing player's main-hand stack when it has a custom name, as + /// held when the hit landed (a mob's is read when the message is built). + item: Option, /// The direct entity's display name when no entity caused the damage /// (`DamageSource.directEntity`: a potion with no thrower, a cloud). direct: Option, @@ -74,7 +77,7 @@ pub(super) struct CombatTracker { in_combat: bool, /// Attackers of damage dealt this tick, claimed by the matching /// `RecordedDamage` event. - pending: Vec<(String, (u32, Text))>, + pending: Vec<(String, (u32, Text), Option)>, /// `pending` for a direct entity with no causing entity: only its name. pending_direct: Vec<(String, Text)>, /// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit). @@ -92,6 +95,9 @@ pub(super) struct CombatTracker { /// The projectile hitting the player now is another player's that /// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally). pub(super) refuse_player_hit: bool, + /// The hit on the player now runs its post-attack effects (thorns) only + /// if the player lives through it (`WitherSkull.onHitEntity`). + pub(super) post_attack_if_alive: bool, /// A firework rocket's burst is hurting the player now: the rocket is /// the direct entity (`rocket_hurt_player`). pub(super) rocket_hit: bool, @@ -187,12 +193,35 @@ impl Game<'_> { self.note_named_attacker(damage_type, mob, name); } } + /// How many attackers wait for the damage they caused, for + /// [`Game::drop_pending_since`]. + pub(super) fn pending_marks(&self) -> (usize, usize) { + (self.combat.pending.len(), self.combat.pending_direct.len()) + } + /// Forgets the attackers noted since `marks`: their hit didn't land (a + /// native mod canceled it), so no combat entry takes them, and the next + /// hit of the same damage type must not be credited to them. + pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) { + self.combat.pending.truncate(marks.0); + self.combat.pending_direct.truncate(marks.1); + } /// `note_attacker` for an entity that is not one of `self.mobs` (the /// ender dragon). pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) { + self.note_player_attacker(damage_type, id, name, None); + } + /// `note_named_attacker` for a player, with their custom-named main-hand + /// item for the death message's `.item` form. + pub(super) fn note_player_attacker( + &mut self, + damage_type: &str, + id: u32, + name: Text, + item: Option, + ) { self.combat .pending - .push((damage_type.to_owned(), (id, name))); + .push((damage_type.to_owned(), (id, name), item)); } /// The `DamageSource.directEntity` of the next `damage_type` damage when /// nothing caused it (a splash potion nobody threw): the death message @@ -234,12 +263,16 @@ impl Game<'_> { } _ => continue, }; - let attacker = self + let (attacker, item) = self .combat .pending .iter() - .position(|(t, _)| *t == name) - .map(|i| self.combat.pending.remove(i).1); + .position(|(t, _, _)| *t == name) + .map(|i| { + let (_, attacker, item) = self.combat.pending.remove(i); + (Some(attacker), item) + }) + .unwrap_or_default(); let direct = match attacker { Some(_) => None, None => self @@ -266,6 +299,7 @@ impl Game<'_> { fall_location, fall_distance, attacker, + item, direct, }); self.combat.last_damage = now; @@ -408,12 +442,38 @@ impl Game<'_> { fn mob_main_hand_name(&self, id: u32) -> Option { let m = self.mobs.iter().find(|m| m.id == id)?; let mut stack = m.extra.main_hand_stack()?.clone(); - stack.custom_name()?; + if !has_custom_name(&stack) { + return None; + } if let Some(g) = horizon_client::mobs::gear::gear(m) { stack.data_mut().enchantments = g.slots[horizon_client::mobs::gear::MAIN].clone(); } Some(self.stack_display_name(&stack)) } + /// `getMainHandItem().getDisplayName()` of the player in place, when the + /// stack has a custom name. + pub(super) fn main_hand_custom_name(&self) -> Option { + let stack = self.inventory.get(self.selected)?.as_ref()?; + has_custom_name(stack).then(|| self.stack_display_name(stack)) + } + /// `main_hand_custom_name` of a player who is not in place: the guest + /// `conn` between turns, or none for the host while a guest acts (their + /// hotbar slot is kept by the swap). + fn player_main_hand_custom_name(&self, player: Option) -> Option { + if player == self.lan_acting { + return self.main_hand_custom_name(); + } + let g = self.lan.as_ref()?.hub.guest(player?)?; + let stack = g.inventory.get(g.selected)?.as_ref()?; + has_custom_name(stack).then(|| self.stack_display_name(stack)) + } + /// The killer's item for `getLocalizedDeathMessage` and + /// `getMessageForAssistedFall`: a player's as recorded, a mob's now. + fn entry_item(&self, e: &Entry) -> Option { + e.item + .clone() + .or_else(|| self.mob_main_hand_name(e.attacker.as_ref()?.0)) + } /// `CombatTracker.getDeathMessage`. pub(super) fn combat_death_message(&mut self) -> Text { self.combat_death_message_for(Text::literal(PLAYER_NAME)) @@ -450,13 +510,26 @@ impl Game<'_> { let at = knock.fall_location.unwrap_or("generic"); return Text::translate(&format!("death.fell.accident.{at}"), &[me]); } - return match (&knock.attacker, &killer) { + // CombatTracker.getMessageForAssistedFall: the attacker's item + // with a custom name gets the `.item` message. + let (key, args) = match (&knock.attacker, &killer) { (Some(a), k) if k.as_ref().is_none_or(|k| k.0 != a.0) => { - Text::translate("death.fell.assist", &[me, a.1.clone()]) + horizon_client::scoreboard::death_message_args( + "death.fell.assist", + &me, + &a.1, + self.entry_item(knock).as_ref(), + ) } - (_, Some(k)) => Text::translate("death.fell.finish", &[me, k.1.clone()]), - _ => Text::translate("death.fell.killer", &[me]), + (_, Some(k)) => horizon_client::scoreboard::death_message_args( + "death.fell.finish", + &me, + &k.1, + self.entry_item(killing).as_ref(), + ), + _ => ("death.fell.killer".into(), vec![me]), }; + return Text::translate(&key, &args); } let key = format!("death.attack.{}", def.0); if def.1 == "intentional_game_design" { @@ -467,14 +540,14 @@ impl Game<'_> { } // `getLocalizedDeathMessage`: the causing entity's name, or the // direct entity's when nothing caused the damage. - if let Some((id, name)) = killer { + if let Some((_, name)) = killer { // DamageSource.getLocalizedDeathMessage: the cause's item with a // custom name gets the `.item` message. let (key, args) = horizon_client::scoreboard::death_message_args( &key, &me, &name, - self.mob_main_hand_name(id).as_ref(), + self.entry_item(killing).as_ref(), ); return Text::translate(&key, &args); } @@ -549,6 +622,12 @@ impl Game<'_> { } None => None, }; + // DamageSource.getLocalizedDeathMessage reads that player's main hand + // as the message is built. + let player_item = match killer { + Some(conn) => self.player_main_hand_custom_name(conn), + None => player.as_ref().and_then(|_| self.main_hand_custom_name()), + }; // The cause's display name: a mob that is gone (a creeper that blew // up) is still named by its blast. let name = |g: &Self, who: Who| match who { @@ -570,6 +649,9 @@ impl Game<'_> { .iter() .map(|e| { let attacker = e.source.attacker.and_then(|w| name(self, w)); + let item = (e.source.attacker == Some(Who::Player)) + .then(|| player_item.clone()) + .flatten(); // AbstractArrow.onHitEntity: an arrow nobody shot is its own // cause; a rocket nobody fired still names itself. let direct = match e.source.kind { @@ -586,6 +668,7 @@ impl Game<'_> { fall_location: e.in_water.then_some("water"), fall_distance: e.fall_distance, attacker, + item, direct, } }) @@ -614,6 +697,14 @@ impl Game<'_> { } } +/// `!stack.isEmpty() && stack.has(DataComponents.CUSTOM_NAME)`: the held +/// item that turns a death message into its `.item` form. +fn has_custom_name(stack: &horizon_client::items::ItemStack) -> bool { + &*stack.item != "minecraft:air" + && stack.count > 0 + && stack.data().is_some_and(|d| d.custom_name.is_some()) +} + /// `Entity.getDisplayName` of a mob: its custom name or its type's name. fn mob_name(m: &Mob) -> Text { match (&m.custom_name_text, &m.custom_name) { @@ -623,6 +714,30 @@ fn mob_name(m: &Mob) -> Text { } } +#[cfg(test)] +mod held_item_tests { + use super::has_custom_name; + use horizon_client::items::ItemStack; + use horizon_world::nbt::Tag; + + /// `DamageSource.getLocalizedDeathMessage`: only a non-empty stack with + /// a `custom_name` component names the item. + #[test] + fn only_a_renamed_stack_names_the_item() { + let mut sword = ItemStack::new("minecraft:diamond_sword", 1); + assert!(!has_custom_name(&sword)); + // Other components (here a repair cost) are not a custom name. + sword.data_mut().repair_cost = 1; + assert!(!has_custom_name(&sword)); + sword.data_mut().custom_name = Some(Tag::String("Peacemaker".into())); + assert!(has_custom_name(&sword)); + // An empty stack never does, even with the component. + let mut gone = sword.clone(); + gone.count = 0; + assert!(!has_custom_name(&gone)); + } +} + #[cfg(test)] mod stuck_tests { use super::Stuck; diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index a5a6b3c68d..f326ee4225 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1926,7 +1926,11 @@ impl Game<'_> { } } } - Target::Item(id) => self.dropped.retain(|i| i.id != id), + Target::Item(id) => { + if let Some(i) = self.dropped.iter().position(|i| i.id == id) { + self.remove_dropped(i); + } + } Target::Orb(id) => { if let Some(o) = self.orbs.iter_mut().find(|o| o.id == id) { o.age = u32::MAX / 2; @@ -1957,7 +1961,9 @@ impl Game<'_> { Target::Dragon(id) => self.kill_dragon(id), } // The orbs marked for removal are gone (Entity.kill removes them). + let before = self.orb_ids(); self.orbs.retain(|o| o.age < u32::MAX / 2); + self.native_orbs_left_since(&before); } fn cmd_kill(&mut self, ctx: &Context) -> R { let targets = if ctx.get("targets").is_some() { @@ -2050,11 +2056,14 @@ impl Game<'_> { return Err(err("commands.summon.failed", &[])); } "experience_orb" => { + // `new ExperienceOrb(level, pos, Vec3.ZERO, 0)`, unstuck + // like any other orb spawn. + let at = self.unstuck_orb(pos); self.next_entity += 1; self.orbs.push(horizon_client::entities::ExperienceOrb { id: self.next_entity, - position: Vec3::new(pos[0], pos[1], pos[2]), - previous: Vec3::new(pos[0], pos[1], pos[2]), + position: Vec3::new(at[0], at[1], at[2]), + previous: Vec3::new(at[0], at[1], at[2]), velocity: Vec3::new(0.0, 0.0, 0.0), value: 0, age: 0, @@ -3343,13 +3352,30 @@ impl Game<'_> { .options .resources .join(format!("data/{ns}/tags/{registry}/{path}.json")); - let Ok(v) = std::fs::read(file) - .map_err(|_| ()) - .and_then(|b| serde_json::from_slice::(&b).map_err(|_| ())) - else { - continue; - }; - for e in v["values"].as_array().into_iter().flatten() { + // The game's file and the world's packs' both count, entries + // joined in resource-stack order; a pack file with `replace` + // drops what the lower sources put in (`TagLoader.load`). + let packed = self + .fns + .store + .files + .get(&format!("tags/{ns}:{registry}/{path}")) + .into_iter() + .flatten() + .filter_map(|b| serde_json::from_slice::(b).ok()); + let parsed = std::fs::read(file) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .into_iter() + .chain(packed); + let mut entries = Vec::new(); + for v in parsed { + if v["replace"].as_bool() == Some(true) { + entries.clear(); + } + entries.extend(v["values"].as_array().into_iter().flatten().cloned()); + } + for e in &entries { let id = e.as_str().or_else(|| e["id"].as_str()).unwrap_or_default(); match id.strip_prefix('#') { Some(nested) => pending.push(nested.to_owned()), diff --git a/crates/horizon-client/src/game/commands/effects.rs b/crates/horizon-client/src/game/commands/effects.rs index 2e3b819abb..a1b7fab7b7 100644 --- a/crates/horizon-client/src/game/commands/effects.rs +++ b/crates/horizon-client/src/game/commands/effects.rs @@ -73,8 +73,10 @@ impl Game<'_> { Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) { Some(m) => { let name = namespaced(effect.id); + let new = !m.has_effect(name); // `new MobEffectInstance(effect, duration, amplifier, false, particles)`. m.add_effect_with(name, duration, amplifier, false, particles); + self.mod_effect_added(name, Some(id as i32), amplifier, new); true } None => false, diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index e24a53c051..60f15ae64e 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -1102,9 +1102,11 @@ impl Game<'_> { /// The biome name at a block position. fn biome_name_at_block(&self, p: [i32; 3]) -> Option { let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?; + // The world's table (`world_biomes!`): a packed biome's id is past + // the built-in list, and `Biomes::get` would clamp it to the last + // vanilla one. Some( - self.res - .biomes() + world_biomes!(self) .get(chunk.biome(p[0], p[1], p[2])) .name .clone(), @@ -1113,30 +1115,16 @@ impl Game<'_> { /// A biome id or `#tag` matched against a biome name. pub(super) fn biome_matches(&self, wanted: &str, biome: &str) -> bool { match wanted.strip_prefix('#') { - Some(tag) => self.biome_tag_has(tag, biome, 0), + Some(tag) => self.biome_tag_has(tag, biome), None => wanted == biome, } } - fn biome_tag_has(&self, tag: &str, biome: &str, depth: u32) -> bool { - if depth > 8 { - return false; - } - let (ns, path) = tag.split_once(':').unwrap_or(("minecraft", tag)); - let file = self - .options - .resources - .join(format!("data/{ns}/tags/worldgen/biome/{path}.json")); - let Ok(bytes) = std::fs::read(file) else { - return self.res.mob_data().biome_has(tag, biome); - }; - let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default(); - v["values"].as_array().is_some_and(|a| { - a.iter() - .filter_map(|e| e.as_str()) - .any(|e| match e.strip_prefix('#') { - Some(inner) => self.biome_tag_has(inner, biome, depth + 1), - None => e == biome, - }) - }) + /// `#tag`: the biome tags the game's files and the world's packs define, + /// nested tags expanded; an unknown tag falls back to the game's built-in + /// table. + fn biome_tag_has(&self, tag: &str, biome: &str) -> bool { + let entries = self.tag_entries("worldgen/biome", tag); + entries.iter().any(|id| id == biome) + || (entries.is_empty() && self.res.mob_data().biome_has(tag, biome)) } } diff --git a/crates/horizon-client/src/game/commands/terrain_cmds.rs b/crates/horizon-client/src/game/commands/terrain_cmds.rs index d690838c9f..4154e7ad91 100644 --- a/crates/horizon-client/src/game/commands/terrain_cmds.rs +++ b/crates/horizon-client/src/game/commands/terrain_cmds.rs @@ -176,7 +176,11 @@ impl Game<'_> { if volume > i64::from(limit) { return Err(err("commands.fillbiome.toobig", &[&limit, &volume])); } - let Some(target) = self.res.biomes().list.iter().position(|b| b.name == biome) else { + let Some(target) = world_biomes!(self) + .list + .iter() + .position(|b| b.name == biome) + else { return Err(err( "argument.resource.not_found", &[&biome, &"minecraft:worldgen/biome"], @@ -203,7 +207,7 @@ impl Game<'_> { continue; }; let current = chunk.biome(x, y, z); - let current_name = &self.res.biomes().list[usize::from(current)].name; + let current_name = &world_biomes!(self).list[usize::from(current)].name; if let Some(f) = &filter && !self.biome_matches(f, current_name) { @@ -512,9 +516,7 @@ impl Game<'_> { let drops = match world_items!(self).loot_sequence(&table) { Some(seq) => { let random = self.rand_sequences.get(&seq, self.level.seed); - self.res - .items() - .loot_table(&table, false, held.as_ref(), random) + world_items!(self).loot_table(&table, false, held.as_ref(), random) } None => world_items!(self).loot_table( &table, @@ -547,9 +549,7 @@ impl Game<'_> { let drops = match world_items!(self).loot_sequence(&table) { Some(seq) => { let random = self.rand_sequences.get(&seq, self.level.seed); - self.res - .items() - .entity_drops(&ty, by_player, held.as_ref(), random) + world_items!(self).entity_drops(&ty, by_player, held.as_ref(), random) } None => world_items!(self).entity_drops( &ty, diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 25e3a3fbee..ce9f87a5e7 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -109,7 +109,8 @@ impl Game<'_> { "mob_attack" | "mob_attack_no_aggro" => DamageKind::MobAttack, "arrow" => DamageKind::Arrow, "thrown" | "trident" | "mob_projectile" => DamageKind::Thrown, - "fireball" | "unattributed_fireball" | "wither_skull" => DamageKind::Fireball, + "fireball" | "unattributed_fireball" => DamageKind::Fireball, + "wither_skull" => DamageKind::WitherSkull, "explosion" | "player_explosion" | "bad_respawn_point" => DamageKind::Explosion, "fall" | "ender_pearl" | "fly_into_wall" => DamageKind::Fall, "on_fire" => DamageKind::OnFire, @@ -1169,7 +1170,7 @@ impl Game<'_> { }; let vehicle_name = self.target_name(v); match target { - Target::Player => self.dismount_mount(), + Target::Player => self.stop_riding(), Target::Mob(rider) => self.dismount_mob(rider), _ => {} } @@ -1202,24 +1203,67 @@ impl Game<'_> { } up = self.ride_vehicle(v); } - // Players and mobs can ride mobs. - let Target::Mob(mob_id) = vehicle else { - return Err(err( - "commands.ride.mount.failure.generic", - &[&name.plain(), &vehicle_name.plain()], - )); - }; let rider = match target { Target::Mob(id) => Some(id), _ => None, }; - if !(target == Target::Player || rider.is_some()) - || !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) - { - return Err(err( + let generic = || { + err( "commands.ride.mount.failure.generic", &[&name.plain(), &vehicle_name.plain()], - )); + ) + }; + // Only players and mobs ride. + if !(target == Target::Player || rider.is_some()) { + return Err(generic()); + } + // `RideCommand.mount` forces the ride (`startRiding(vehicle, true, true)`), + // which skips `canRide` and `canAddPassenger`: a boat under water still + // takes the rider. Horizon's boats seat at most `max_passengers`, so a + // full boat still refuses. Minecarts carry only players here: Horizon's + // carts have no mob passengers yet. + match vehicle { + Target::Boat(id) => { + let Some(i) = self.boats.iter().position(|b| b.id == id) else { + return Err(generic()); + }; + if self.boat_passengers(id) >= self.boats[i].max_passengers() { + return Err(generic()); + } + if let Some(rider) = rider { + self.mount_mob(rider, id); + } else { + self.riding = Some(id); + self.yaw = self.boats[i].yaw; + // sendEventAndTriggers: START_RIDING_TRIGGER. + self.trigger_player("minecraft:started_riding"); + } + self.feedback(Text::translate( + "commands.ride.mount.success", + &[name, vehicle_name], + )); + return Ok(1); + } + Target::Cart(id) => { + if target != Target::Player || self.ridden_by_guest(id) { + return Err(generic()); + } + self.riding = Some(id); + self.trigger_player("minecraft:started_riding"); + self.feedback(Text::translate( + "commands.ride.mount.success", + &[name, vehicle_name], + )); + return Ok(1); + } + _ => {} + } + // Mobs carry players and mobs. + let Target::Mob(mob_id) = vehicle else { + return Err(generic()); + }; + if !self.mobs.iter().any(|m| m.id == mob_id && m.alive()) { + return Err(generic()); } if let Some(rider) = rider { // Entity.startRiding. @@ -1248,12 +1292,14 @@ impl Game<'_> { /// Entity.getVehicle: the player's mount, or a mob's. pub(super) fn ride_vehicle(&self, t: Target) -> Option { let id = match t { - Target::Player => return self.riding.map(Target::Mob), + Target::Player => self.riding?, Target::Mob(id) => self.mobs.iter().find(|m| m.id == id)?.extra.mount?, _ => return None, }; Some(if self.boats.iter().any(|b| b.id == id) { Target::Boat(id) + } else if self.carts.iter().any(|c| c.id == id) { + Target::Cart(id) } else { Target::Mob(id) }) diff --git a/crates/horizon-client/src/game/control.rs b/crates/horizon-client/src/game/control.rs index 5450cfb71b..5c77b360c0 100644 --- a/crates/horizon-client/src/game/control.rs +++ b/crates/horizon-client/src/game/control.rs @@ -1,4 +1,5 @@ //! Commands from the control socket (`horizon-ctl`), run on the game thread. +use super::event_loop::EventLoopCtl; use super::{Game, Screen}; use horizon_client::{Result, items::ItemStack}; use horizon_core::geometry::Vec3; @@ -7,7 +8,7 @@ use std::{ sync::mpsc::Sender, time::{Duration, Instant}, }; -use winit::{event::MouseButton, event_loop::ActiveEventLoop, keyboard::KeyCode}; +use winit::{event::MouseButton, keyboard::KeyCode}; const HELP: &[&str] = &[ "status game, player, streaming and renderer state", @@ -107,7 +108,7 @@ impl Game<'_> { let _ = flight.reply.send(summary.to_string()); } } - pub(super) fn poll_control(&mut self, event_loop: &ActiveEventLoop) { + pub(super) fn poll_control(&mut self, event_loop: &dyn EventLoopCtl) { if let Some((deadline, reply)) = self.pending_bench.take() { if Instant::now() >= deadline { let _ = reply.send(self.frame_stats.summary().to_string()); @@ -137,7 +138,7 @@ impl Game<'_> { } } - fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) { + fn answer(&mut self, request: horizon_client::control::Request, event_loop: &dyn EventLoopCtl) { let reply = match self.control_command(&request.line, &request.reply, event_loop) { Ok(Some(value)) => value, // Deferred: the command keeps its own reply channel. @@ -151,7 +152,24 @@ impl Game<'_> { &mut self, line: &str, reply: &Sender, - event_loop: &ActiveEventLoop, + event_loop: &dyn EventLoopCtl, + ) -> Result> { + // A tap's or an up's release must run even when a dialog, the sign + // editor or a screen consumes the key, there is no world, or a + // command handler returns an error: a lost release keeps Shift down. + let released = released_modifier(line); + let result = self.control_command_inner(line, reply, event_loop); + if let Some(key) = released { + self.press_modifier_key(key, false); + } + result + } + + fn control_command_inner( + &mut self, + line: &str, + reply: &Sender, + event_loop: &dyn EventLoopCtl, ) -> Result> { let words: Vec<&str> = line.split_whitespace().collect(); if self.pending_save.is_some() && words.first() != Some(&"quit") { @@ -285,6 +303,13 @@ impl Game<'_> { "pad" => Ok(Some(self.pad_command(&words)?)), "key" => { let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?; + let action = arg(2)?; + if !matches!(action, "down" | "up" | "tap" | "repeat") { + return Err(format!( + "key action must be down, up, tap or repeat, not {action}" + ) + .into()); + } // A server's dialog takes the keys, in configuration too. if self.dialog_open() { if arg(2)? != "up" { @@ -324,6 +349,7 @@ impl Game<'_> { { use horizon_client::debug_keys::KeyAction; let action = arg(2)?; + self.press_modifier_key(key, action != "up"); if action != "up" { self.physical_keys.insert(key); if self.debug_key(key, KeyAction::Press) { @@ -396,18 +422,30 @@ impl Game<'_> { self.physical_keys.remove(&physical); self.debug_key(physical, horizon_client::debug_keys::KeyAction::Release); } - other => { - return Err(format!( - "key action must be down, up, tap or repeat, not {other}" - ) - .into()); - } + _ => unreachable!("checked above"), } ok() } "release" => { self.keys.clear(); self.physical_keys.clear(); + self.modifiers = winit::keyboard::ModifiersState::empty(); + // Sneak and sprint pressed through `key` live in the toggle + // mappings: release them as their key's release would (a + // toggled one stays on), or the next sync puts them back. A + // controller still holding one keeps it. + let options = [self.settings.toggle_crouch, self.settings.toggle_sprint]; + for (slot, name) in [(0, "key.sneak"), (1, "key.sprint")] { + if !self.pad.holds_action(&self.settings.controller, name) { + self.toggles.key( + slot, + horizon_client::keymapping::KeyAction::Release, + false, + options[slot], + ); + } + self.sync_toggle(slot); + } self.left = false; self.right = false; ok() @@ -1157,6 +1195,15 @@ fn vendor_name(id: u32) -> String { } } +/// The key a `key tap|up` command releases, whatever its handler does. +fn released_modifier(line: &str) -> Option { + let mut words = line.split_whitespace(); + match (words.next(), words.next(), words.next()) { + (Some("key"), Some(key), Some("tap" | "up")) => key_code(key), + _ => None, + } +} + fn key_code(name: &str) -> Option { let name = name.to_ascii_lowercase(); let letters = [ @@ -1233,3 +1280,24 @@ fn key_code(name: &str) -> Option { _ => return None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn taps_and_ups_always_release_their_key() { + for line in ["key shift tap", "key shift up", "key Shift up"] { + assert_eq!(released_modifier(line), Some(KeyCode::ShiftLeft), "{line}"); + } + for line in [ + "key shift down", + "key shift repeat", + "key shift", + "keys shift up", + "key nope up", + ] { + assert_eq!(released_modifier(line), None, "{line}"); + } + } +} diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 14410268f1..ca8f084a44 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -2,7 +2,7 @@ //! chunk-generation spawning (`NaturalSpawner`), mob events (attacks, //! breeding, conversions, drops), projectiles, player interactions //! (feeding, shearing, milking, spawn eggs, throwing) and rendering. -use super::{Dim, Game, dispenser, view_vector}; +use super::{Dim, Game, InteractTarget, dispenser, view_vector}; use horizon_assets::item_model::DisplayContext; use horizon_client::{ Result, @@ -227,6 +227,20 @@ fn flame_geometry( pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen { m.seen(player) } +/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in +/// its death animation, the one the player rides (it shares the player's root +/// vehicle) and the camera entity the pick starts from, so a packet that names +/// one of those does nothing rather than reaching the feed, shear, leash or +/// name paths the pick path refuses. +pub(super) fn named_mob_pickable( + alive: bool, + id: u32, + riding: Option, + camera: Option, +) -> bool { + alive && Some(id) != riding && Some(id) != camera +} + impl Game<'_> { pub(super) fn player_view(&self) -> Option { let p = self.player.as_ref()?; @@ -303,7 +317,7 @@ impl Game<'_> { let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else { return BiomeFacts::default(); }; - let b = self.res.biomes().get(chunk.biome(x, y, z)); + let b = world_biomes!(self).get(chunk.biome(x, y, z)); let tags = self.res.mob_data(); let has = |t: &str| tags.biome_has(t, &b.name); let climate = if has("minecraft:spawns_warm_variant_farm_animals") { @@ -459,6 +473,107 @@ impl Game<'_> { self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at); } } + /// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit + /// `MobEvent::ArmorHurt` reported: every worn piece that wears when its + /// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking + /// applied, and at its maximum the slot empties and the equipment-break + /// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52 + /// for the armour, 65 for the body). A fire hit leaves a fire-resistant + /// piece alone (`ItemStack.canBeHurtBy`). + fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) { + use horizon_client::mobs::drops; + use horizon_world::player_inventory::EquipmentSlot; + if damage <= 0.0 { + return; + } + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + let body_armour = drops::head_is_body(m.species()); + let amount = ((damage / 4.0) as u32).max(1); + let name = damage_type(kind, false); + let fire = self + .motion + .health + .damage + .id(name) + .is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire")); + // A piece's maximum, or none when it may not lose durability at all. + fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option { + let info = me.res.items().get(&stack.item)?; + if !info.equip_damage_on_hurt + || info.max_damage == 0 + || !horizon_client::stations::is_damageable(me.res.items(), stack) + || (fire && me.res.items().resists_fire(stack, name)) + { + return None; + } + Some(info.max_damage) + } + let mut broke: Vec = Vec::new(); + // The four armour slots, then the body slot (`extra.head`). + let event_of = horizon_client::mobs::item_break::equipment_break_event; + for (index, slot) in [ + (0usize, EquipmentSlot::Head), + (1, EquipmentSlot::Chest), + (2, EquipmentSlot::Legs), + (3, EquipmentSlot::Feet), + ] { + let Some(m) = self.mobs.iter().find(|m| m.id == id) else { + return; + }; + let Some(stack) = m.extra.armor[index].clone() else { + continue; + }; + let Some(max) = wearable(self, &stack, fire, name) else { + continue; + }; + let loss = self.durability_loss(&stack, amount); + if loss == 0 { + continue; + } + let lost = stack.damage + loss; + let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { + return; + }; + if lost >= max { + m.extra.armor[index] = None; + broke.push(event_of(slot)); + } else if let Some(worn) = m.extra.armor[index].as_mut() { + worn.damage = lost; + } + } + if body_armour { + let body = self + .mobs + .iter() + .find(|m| m.id == id) + .and_then(|m| m.item_by_slot(EquipmentSlot::Body)); + if let Some(body) = body + && let Some(max) = wearable(self, &body, fire, name) + { + let loss = self.durability_loss(&body, amount); + if loss > 0 { + let lost = body.damage + loss; + let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else { + return; + }; + if lost >= max { + m.extra.head = None; + m.extra.body_damage = 0; + broke.push(event_of(EquipmentSlot::Body)); + } else { + m.extra.body_damage = lost as i32; + } + } + } + } + for event in broke { + // LivingEntity.onEquippedItemBroken through the level's broadcast. + self.lan_entity_event(id as i32, event, at); + } + } /// What a converted frog gets once its tadpole has grown up: the spawn /// finalization of a conversion, then persistence. fn finalize_converted(&mut self, id: u32) { @@ -650,7 +765,7 @@ impl Game<'_> { thundering: weather && thunder > 0.9, nether: self.dim == Dim::Nether, block_tags: &self.items().block_tags, - biomes: self.res.biomes(), + biomes: world_biomes!(self), mob_griefing: self.ui.rules.bool("mob_griefing"), universal_anger: self.ui.rules.bool("universal_anger"), mob_drops: self.ui.rules.bool("mob_drops"), @@ -661,6 +776,11 @@ impl Game<'_> { teams: std::sync::Arc::new(self.mob_teams()), items: Some(self.items()), lid_openers: &self.lid_openers, + mod_effects: if self.remote.is_none() { + Some(self) + } else { + None + }, // The first player in the level: a server has no player of its own. difficulty: self.local_difficulty( self.level_players() @@ -1141,6 +1261,7 @@ impl Game<'_> { let projectile = match d.kind { DamageKind::Arrow => Some("minecraft:arrow"), DamageKind::Fireball => Some("minecraft:fireball"), + DamageKind::WitherSkull => Some("minecraft:wither_skull"), DamageKind::Fireworks => Some("minecraft:firework_rocket"), _ => None, }; @@ -1243,6 +1364,10 @@ impl Game<'_> { kind: "minecraft:fireball".into(), ..LootEntity::default() }), + DamageKind::WitherSkull => Some(LootEntity { + kind: "minecraft:wither_skull".into(), + ..LootEntity::default() + }), DamageKind::Fireworks => Some(LootEntity { kind: "minecraft:firework_rocket".into(), ..LootEntity::default() @@ -1491,6 +1616,7 @@ impl Game<'_> { self.drown_particles(at, v); } MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob), + MobEvent::ArmorHurt { mob, amount, kind } => self.hurt_mob_armor(mob, amount, kind), MobEvent::EntityEvent { mob, event, at } => { self.lan_entity_event(mob as i32, event, at) } @@ -2829,6 +2955,7 @@ impl Game<'_> { damage_type: &str, known: Option>, ) -> bool { + let post_attack_if_alive = std::mem::take(&mut self.combat.post_attack_if_alive); let rocket = std::mem::take(&mut self.combat.rocket_hit); // ServerPlayer.canHarmPlayer refused another player's projectile. if self.creative || self.combat.refuse_player_hit { @@ -2865,6 +2992,7 @@ impl Game<'_> { }), }; // The causing entity for the death message. + let marks = self.pending_marks(); let owner = self.combat.projectile_owner.take(); if let Some(a) = attacker.or(owner) { self.note_attacker(damage_type, a); @@ -2879,6 +3007,11 @@ impl Game<'_> { // DamageSource.scalesWithDifficulty: a mob (or its projectile's // owner) caused the hit, not a player. let living_cause = attacker.or(owner).is_some(); + // The native damage hook's causing entity: the one given, or the + // owner of the projectile that hit (`projectile_owner` outlives a + // projectile's other hits), not the guessed melee attacker. + let owner = owner.filter(|_| projectile); + let causing = known.flatten().or(owner).map_or(0, |id| id as i32); let mut outcome = horizon_client::health::BlockOutcome::default(); if let Some(player) = self.player.as_mut() && let Some(id) = self.motion.health.damage.id(damage_type) @@ -2889,6 +3022,7 @@ impl Game<'_> { amount, blocking, living_cause, + causing, ) && { outcome = o; @@ -2914,12 +3048,21 @@ impl Game<'_> { } } // The thorns hurt the entity that caused the hit, if there is one. - if let Some(a) = attacker.or(owner) { + let killed = self.player.as_ref().is_some_and(|p| { + super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used) + }); + if let Some(a) = attacker.or(owner) + && !(post_attack_if_alive && killed) + { self.thorns(a); } } // LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned // early (invulnerable, or inside the hurt cooldown). + // A hit a native mod canceled records no damage (hurtServer never ran). + if outcome.canceled { + self.drop_pending_since(marks); + } if outcome.hurt || outcome.sound { let source = attacker .or(owner) @@ -3674,10 +3817,13 @@ impl Game<'_> { ) { return self.wind_charge_hit(p, hit); } + // A wither skull's hit on a part or crystal goes through + // `wither_skull_hit` below, as on any entity, and then explodes. if let Hit::Entity { who: Who::Mob(id), .. } = hit && id >= super::dragon::SPECIAL + && !matches!(p.kind, ProjectileKind::WitherSkull { .. }) { let by_player = p.owner == Some(Who::Player); if p.arrow.is_some() { @@ -3792,6 +3938,7 @@ impl Game<'_> { } else { EntityHit::Refused }; + self.burning_arrow_primes(p, who); self.finish_arrow_hit(trident, outcome); } // AbstractArrow.onHitBlock plays its sound itself (ArrowEvent). @@ -3866,6 +4013,7 @@ impl Game<'_> { hurt } }; + self.burning_arrow_primes(p, who); // doKnockback: Punch pushes along the flight, less the target's // knockback resistance. let flight = (p.velocity.x * p.velocity.x + p.velocity.z * p.velocity.z).sqrt(); @@ -4371,12 +4519,28 @@ impl Game<'_> { }; self.audio.play(sound, Some(at), 1.0, 1.0); } - /// Right click on a mob (Mob.interact / mobInteract). Returns true when consumed. - pub(super) fn interact_mob(&mut self) -> Result { - let Some((index, distance)) = self.mob_hit() else { + /// Right click on a mob (Mob.interact / mobInteract), on the mob under the + /// crosshair or on the one a guest's interact packet named: returns true + /// when consumed. + pub(super) fn interact_mob(&mut self, target: Option) -> Result { + let Some((index, distance)) = (match target { + // The id the packet named, under the filters the crosshair pick + // applies (`mob_hit`): a mob in its death animation is not clicked + // by either path, so naming one in a packet must not reach the + // feed, shear, leash or name paths either. + Some(t) => Self::named_entity(&self.mobs, t.id, |m| m.id) + .filter(|&i| { + let m = &self.mobs[i]; + named_mob_pickable(m.alive(), m.id, self.riding, self.remote_camera_id()) + }) + .map(|i| (i, 0.0)), + None => self.mob_hit(), + }) else { return Ok(false); }; - if let Some(hit) = self.pick_unfiltered() + // Only a client's own pick is stopped by a block: the range check passed. + if target.is_none() + && let Some(hit) = self.pick_unfiltered() && let Some(eye) = self.eye_position() { let d = ((hit.point[0] - eye[0]).powi(2) @@ -5469,6 +5633,7 @@ impl Game<'_> { let atlas = &self.gui.atlas; let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]); let scale = self.view_scale(); + self.rendered_mobs.set(0); for mob in &self.mobs { let entity_start = out.len(); let outline = self.mob_outline(mob); @@ -5515,6 +5680,7 @@ impl Game<'_> { } } self.mark_entity(entity_start, &mob.kind.id); + self.rendered_mobs.set(self.rendered_mobs.get() + 1); let light = self.world.light( mob.position.x.floor() as i32, (mob.position.y + mob.eye_height()).floor() as i32, @@ -5961,6 +6127,22 @@ fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { pub(super) fn source_damage_type(d: Damage) -> &'static str { damage_type(d.kind, d.attacker.is_some()) } +/// Whether the causing entity deals `kind` itself, so it is the source's +/// direct entity too: a melee hit (`DamageSources.mobAttack`, `playerAttack`, +/// a mace's or a spear's), a sting, a sonic boom, thorns. +pub(super) fn melee(kind: horizon_client::mobs::DamageKind) -> bool { + use horizon_client::mobs::DamageKind as K; + matches!( + kind, + K::PlayerAttack + | K::MaceSmash + | K::Spear + | K::MobAttack + | K::Sting + | K::SonicBoom + | K::Thorns + ) +} /// The damage type id of a Horizon damage kind. `caused`: the source has a /// causing entity (only a blast asks). pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str { @@ -6008,6 +6190,7 @@ pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) K::SulfurCubeHot => "minecraft:sulfur_cube_hot", K::WindCharge => "minecraft:wind_charge", K::Wither => "minecraft:wither", + K::WitherSkull => "minecraft:wither_skull", } } /// A Horizon item stack as a loot stack (id, count, damage, enchantments). @@ -6323,6 +6506,7 @@ mod pearl_tests { sea_level: 63, items: Some(&items), lid_openers: &lid_openers, + mod_effects: None, }; let pw = ProjWorld { w: &w, diff --git a/crates/horizon-client/src/game/cushions.rs b/crates/horizon-client/src/game/cushions.rs index 01943e0d1b..2095cf4268 100644 --- a/crates/horizon-client/src/game/cushions.rs +++ b/crates/horizon-client/src/game/cushions.rs @@ -6,7 +6,7 @@ //! A cushion is a `BlockAttachedEntity`, not a block: it has no block state, //! only an entity whose `Pos` is the click point's height. Placement uses //! outline bounds, collision shapes and suffocation predicates. -use super::{Game, Hit}; +use super::{Game, Hit, InteractTarget}; use horizon_client::entities::{Cushion, dye_index}; use horizon_client::items::ItemStack; use horizon_client::mobs::anim::Poses; @@ -307,12 +307,19 @@ impl Game<'_> { true } - /// `Cushion.interact`: sit down unless sneaking or already occupied. - pub(super) fn interact_cushion(&mut self) -> bool { - let Some((i, d, _)) = self.cushion_hit() else { + /// `Cushion.interact`: sit down unless sneaking or already occupied. The + /// cushion under the crosshair, or the one a guest's interact packet named. + pub(super) fn interact_cushion(&mut self, target: Option) -> bool { + let Some((i, d, _)) = (match target { + Some(t) => { + Self::named_entity(&self.cushions, t.id, |c| c.id).map(|i| (i, 0.0, [0.0; 3])) + } + None => self.cushion_hit(), + }) else { return false; }; - if !self.before_block(d) { + // Only a client's own pick is stopped by a block: the range check passed. + if target.is_none() && !self.before_block(d) { return false; } // isSecondaryUseActive. diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index d47a5497a0..ff280e96fc 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -10,7 +10,6 @@ use horizon_client::{ gizmos::Gizmos, hud::HudBatch, hud_layout, keybinds, - save::VERSION_NAME, text::Text, }; use horizon_core::geometry::{Axis, Vec3}; @@ -41,6 +40,7 @@ impl DebugHost for Host<'_, '_> { let g = &mut *self.game; if without_menu && g.screen == Screen::Playing { g.pause_menu = !g.has_singleplayer_server() || g.is_published(); + g.pause_focus = None; g.screen = Screen::Paused; g.capture(false); g.mining = None; @@ -297,7 +297,6 @@ impl Game<'_> { { return; } - let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced); let framebuffer = size.map(|v| v as i32); let (scale, [width, height]) = debug_overlay::scale( framebuffer, @@ -307,73 +306,7 @@ impl Game<'_> { [batch.width as i32, batch.height as i32], ); let mut d = debug_overlay::Displayer::default(); - // fps: DebugEntryFps. - if on("fps") { - self.debug_fps_line(&mut d); - } - // game_version: DebugEntryVersion (the launched version and brand). - if on("game_version") { - d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)")); - } - // player_position, player_section_position. - if let Some(p) = self.player.as_ref() { - let mut lines = debug_overlay::position( - [p.position.x, p.position.y, p.position.z], - self.yaw, - self.pitch, - self.dim.id(), - self.streaming.forced.len(), - ); - let section = lines.pop().unwrap_or_default(); - if on("player_position") { - d.group("position", lines); - } - if on("player_section_position") { - d.group("position", [section]); - } - // chunk_generation_stats: DebugEntryChunkGeneration, where the world is generated here. - // The window's server thread publishes its generators; the deprecated in-frame path - // (`end.locator`) is not extended. - if on("chunk_generation_stats") { - let terrain = self - .integrated - .as_ref() - .and_then(|server| server.terrain(self.dim.id())); - // `DebugEntryChunkGeneration.display`: the feet block decides whether the line is - // sampled again; otherwise the last result is drawn. - let feet = [ - p.position.x.floor() as i32, - p.position.y.floor() as i32, - p.position.z.floor() as i32, - ]; - let dim = self.dim.id(); - let mut cache = self.debug_density.borrow_mut(); - let line = cache.line( - dim, - feet, - terrain.map(|t| move || t.density_debug_line(feet)), - ); - if let Some(line) = line { - d.group("chunk_generation", [line.to_owned()]); - } - } - } - if on("simple_performance_impactors") { - self.debug_performance_lines(&mut d); - } - if on("system_specs") { - self.debug_system_lines(&mut d, framebuffer); - } - // Horizon's own: the shader pack's GPU time per pass (an empty group would move others). - let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| { - debug_overlay::shader_pass_lines(&r.shader_pass_times()) - }); - if self.debug && !pass_lines.is_empty() { - d.group("shader pack", pass_lines); - } - if on("tps") { - self.debug_tps_line(&mut d); - } + self.debug_entry_lines(&mut d, framebuffer); // The chart keys and the charts only while the overlay is open. let footer = if self.debug { self.debug_chart_lines().to_vec() @@ -410,7 +343,7 @@ impl Game<'_> { hud_layout::paint(batch, atlas, &draws); } /// `DebugEntryFps`. - fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) { + pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) { let limit = self.framerate_limit(); let present = self .renderer @@ -441,7 +374,7 @@ impl Game<'_> { )); } /// `DebugEntrySimplePerformanceImpactors`. - fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) { + pub(super) fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) { let s = &self.settings; d.line(format!( "{}{}B: {}", @@ -468,7 +401,11 @@ impl Game<'_> { )); } /// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM). - fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) { + pub(super) fn debug_system_lines( + &self, + d: &mut debug_overlay::Displayer, + framebuffer: [i32; 2], + ) { if let Some(r) = self.renderer.as_ref() { static CPU: std::sync::OnceLock = std::sync::OnceLock::new(); d.group( @@ -536,7 +473,7 @@ impl Game<'_> { } } /// `DebugEntryTps` for local and remote connections. - fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) { + pub(super) fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) { if self.player.is_some() { let [tx, rx] = self.debug_charts.packet_rates.map(|n| n.round() as i64); let (target, status) = if self.clock.sprinting() { @@ -940,6 +877,7 @@ impl Game<'_> { pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos { let mut g = Gizmos::default(); self.bounding_box_gizmos(camera, &mut g); + self.debug_renderer_gizmos(&mut g); if self.debug_entry("chunk_borders") && let Some(p) = &self.player { @@ -1249,7 +1187,7 @@ impl DebugQuery { pub(super) struct DensityCache(Option<(&'static str, [i32; 3], Option)>); impl DensityCache { /// The line to draw; `sample` is `None` without a generator. - fn line( + pub(super) fn line( &mut self, dim: &'static str, feet: [i32; 3], diff --git a/crates/horizon-client/src/game/debug_lines.rs b/crates/horizon-client/src/game/debug_lines.rs new file mode 100644 index 0000000000..9ffa6530ed --- /dev/null +++ b/crates/horizon-client/src/game/debug_lines.rs @@ -0,0 +1,574 @@ +//! The F3 overlay's text entries (26.3 `DebugEntry*`): what each enabled +//! entry of `debug-profile.json` reports to the `DebugScreenDisplayer`, in +//! `DebugScreenEntryList.rebuildCurrentList`'s order (by identifier). The +//! entries that read the server's level ask the integrated server for their +//! lines (`ServerDebug`); on another server they show what vanilla shows +//! there, nothing or `??`. +use super::Game; +use horizon_client::{debug_entries::ENTRIES, debug_overlay as overlay, debug_overlay::Displayer}; +use horizon_world::{ + heightmap::{HeightmapState, HeightmapType}, + worldgen::terrain::TerrainBlockStates, +}; +use std::collections::{HashMap, HashSet}; + +/// `DebugEntryLookingAt.RANGE`. +const LOOKING_AT_RANGE: f64 = 20.0; + +/// The entries whose lines come from the server thread. +const SERVER_ENTRIES: [&str; 4] = [ + "chunk_source_stats", + "entity_spawn_counts", + "heightmap", + "local_difficulty", +]; + +/// What the server thread reports for the window's player +/// (`DebugScreenOverlay.getServerChunk` and its `ServerLevel`). +#[derive(Clone, Debug, Default)] +pub(super) struct ServerDebug { + /// The server chunk's heightmaps at the feet (`keepAfterWorldgen`). + pub heights: Option<[i32; 4]>, + pub difficulty: Option, + pub spawn_counts: Option, + pub chunks: Option, +} + +impl Game<'_> { + /// Every enabled entry's lines, by identifier. + pub(super) fn debug_entry_lines(&self, d: &mut Displayer, framebuffer: [i32; 2]) { + let reduced = self.reduced_debug_info(); + let mut paths: Vec<&str> = ENTRIES + .iter() + .map(|e| e.path) + .filter(|path| self.debug_entries.enabled(path, self.debug, reduced)) + .collect(); + paths.sort_unstable(); + let server = self.server_debug(paths.iter().any(|p| SERVER_ENTRIES.contains(p))); + for path in paths { + match path { + "biome" => self.debug_biome_line(d), + "chunk_render_stats" => self.debug_chunk_render_line(d), + "chunk_source_stats" => self.debug_chunk_source_lines(d, server.as_ref()), + "day_count" => { + if self.player.is_some() { + d.line(format!("Day #{}", self.world.day_time.div_euclid(24000))); + } + } + "entity_render_stats" => self.debug_entity_render_line(d), + "entity_spawn_counts" => { + if let Some(line) = server.as_ref().and_then(|s| s.spawn_counts.clone()) { + d.line(line); + } + } + "fps" => self.debug_fps_line(d), + "game_version" => d.priority(format!( + "Minecraft {v} ({v}/horizon)", + v = horizon_client::save::VERSION_NAME + )), + "gpu_utilization" => d.line(overlay::gpu_line(self.gpu_utilization)), + "heightmap" => self.debug_heightmap_lines(d, server.as_ref()), + "light_levels" => { + if let Some(feet) = self.debug_feet() { + let light = self.world.light(feet[0], feet[1], feet[2]); + d.group("light", [overlay::light_line(light >> 4, light & 15)]); + } + } + "local_difficulty" => { + if let Some(line) = server.as_ref().and_then(|s| s.difficulty.clone()) { + d.line(line); + } + } + "looking_at_block_state" | "looking_at_block_tags" => { + let lines = self.debug_looking_at_block(path == "looking_at_block_state"); + d.group("looking_at_block", lines); + } + "looking_at_entity" | "looking_at_entity_tags" => { + let lines = self.crosshair_entity_type().map_or_else(Vec::new, |kind| { + if path == "looking_at_entity" { + vec![format!("{}Targeted Entity", overlay::UNDERLINE), kind] + } else { + overlay::tag_lines(&self.res.mob_data().entity_tags, &kind) + } + }); + d.group("looking_at_entity", lines); + } + "looking_at_fluid_state" | "looking_at_fluid_tags" => { + let lines = self.debug_looking_at_fluid(path == "looking_at_fluid_state"); + d.group("looking_at_fluid", lines); + } + "memory" => { + if let Some(lines) = self.memory_meter.borrow_mut().lines() { + d.group("memory", lines); + } + } + "particle_render_stats" => d.line(format!("P: {}", self.particles.len())), + "player_position" | "player_section_position" => { + if let Some(p) = self.player.as_ref() { + let mut lines = overlay::position( + [p.position.x, p.position.y, p.position.z], + self.yaw, + self.pitch, + self.dim.id(), + self.streaming.forced.len(), + ); + let section = lines.pop().unwrap_or_default(); + if path == "player_position" { + d.group("position", lines); + } else { + d.group("position", [section]); + } + } + } + "player_speed" => { + if self.player.is_some() { + let speed = self.known_player_movement(); + d.group( + "position", + [format!("Speed: {:.3} blocks/tick", speed.length())], + ); + } + } + "post_effects" => { + let effects = self.requested_post_effects(); + if !effects.is_empty() { + d.line(format!("Post: {}", effects.join(", "))); + } + } + "simple_performance_impactors" => self.debug_performance_lines(d), + "sound_cache" => { + let (buffers, bytes) = self.audio.cache_stats(); + d.line(overlay::sound_cache_line(buffers, bytes)); + } + "sound_mood" => { + if self.player.is_some() { + d.line(overlay::sound_mood_line( + self.audio.engine.used_channels(), + [ + horizon_client::sound_engine::STATIC_CHANNELS, + horizon_client::sound_engine::STREAMING_CHANNELS, + ], + self.ambience.moodiness, + )); + } + } + "system_specs" => { + self.debug_system_lines(d, framebuffer); + // Horizon's own: the shader pack's GPU time per pass (an + // empty group would move others). + let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| { + overlay::shader_pass_lines(&r.shader_pass_times()) + }); + if self.debug && !pass_lines.is_empty() { + d.group("shader pack", pass_lines); + } + } + "tps" => self.debug_tps_line(d), + // DebugEntryChunkGeneration, where the world is generated + // here: the window's server thread publishes its generators; + // the deprecated in-frame path is not extended. + "chunk_generation_stats" => { + if let Some(p) = self.player.as_ref() { + let terrain = self + .integrated + .as_ref() + .and_then(|server| server.terrain(self.dim.id())); + // `DebugEntryChunkGeneration.display`: the feet block + // decides whether the line is sampled again; otherwise + // the last result is drawn. + let feet = [ + p.position.x.floor() as i32, + p.position.y.floor() as i32, + p.position.z.floor() as i32, + ]; + let dim = self.dim.id(); + let mut cache = self.debug_density.borrow_mut(); + let line = cache.line( + dim, + feet, + terrain.map(|t| move || t.density_debug_line(feet)), + ); + if let Some(line) = line { + d.group("chunk_generation", [line.to_owned()]); + } + } + } + // detailed_memory: the JVM's memory pools, which Horizon + // has none of. The rest are renderers (debug_renderers.rs). + _ => {} + } + } + } + + /// The camera's feet (`Entity.blockPosition`). + fn debug_feet(&self) -> Option<[i32; 3]> { + let p = self.player.as_ref()?; + Some([p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32)) + } + + /// The integrated server's lines when an entry wants them; the request + /// (the dimension and the feet) goes with it, for the next server tick. + fn server_debug(&self, wanted: bool) -> Option { + let server = self.integrated.as_ref()?; + let at = self + .debug_feet() + .filter(|_| wanted) + .map(|feet| (self.dim, feet)); + server.request_debug(at); + at.map(|_| server.debug()) + } + + /// `DebugEntryBiome`: the client level's biome at the feet, inside the + /// build height. + fn debug_biome_line(&self, d: &mut Displayer) { + let Some([x, y, z]) = self.debug_feet() else { + return; + }; + if y < self.world.bottom_y() || y >= self.world.top_y() { + return; + } + if let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) + && let Some(biome) = self.res.biomes().list.get(chunk.biome(x, y, z) as usize) + { + d.line(format!("Biome: {}", biome.name)); + } + } + + /// `LevelExtractor.sectionStatistics`: the sections drawn of the view + /// area's (smart culling is always on), the render distance, and + /// `SectionRenderDispatcher.getStats` (the meshes queued; Horizon has + /// no buffer pool, so no buffer is ever free). + fn debug_chunk_render_line(&self, d: &mut Displayer) { + let Some(r) = self.renderer.as_ref() else { + return; + }; + let distance = self.settings.render_distance; + let side = (distance * 2 + 1) as usize; + let total = side * side * (self.world.layout().height() / 16) as usize; + d.line(format!( + "C: {}/{total} (s) D: {distance}, pC: {:03}, aB: {:02}", + r.drawn_sections, + self.streaming.pending(), + 0 + )); + } + + /// `LevelExtractor.entityStatistics`: the mobs drawn last frame of the + /// level's entities, and the server's simulation distance. + fn debug_entity_render_line(&self, d: &mut Displayer) { + if self.player.is_none() { + return; + } + let simulation = self + .remote + .as_ref() + .map_or(self.settings.simulation_distance, |r| r.simulation_distance); + d.line(format!( + "E: {}/{}, SD: {simulation}", + self.rendered_mobs.get(), + self.debug_entity_count() + )); + } + + /// `Level.getEntityCount` over the entities Horizon keeps. + fn debug_entity_count(&self) -> usize { + self.mobs.iter().filter(|m| !m.removed).count() + + self.dropped.len() + + self.orbs.len() + + self.paintings.len() + + self.item_frames.len() + + self.armor_stands.len() + + self.boats.len() + + self.carts.len() + + self.crystals.len() + + self.creatures.projectiles.len() + } + + /// The sections holding a mob, an item or an orb + /// (`EntitySectionStorage.count`). + fn debug_entity_sections(&self) -> usize { + let sections: HashSet<[i32; 3]> = self + .mobs + .iter() + .filter(|m| !m.removed) + .map(|m| [m.position.x, m.position.y, m.position.z]) + .chain( + self.dropped + .iter() + .map(|i| [i.position.x, i.position.y, i.position.z]), + ) + .chain( + self.orbs + .iter() + .map(|o| [o.position.x, o.position.y, o.position.z]), + ) + .map(|p| p.map(|v| (v.floor() as i32) >> 4)) + .collect(); + sections.len() + } + + /// `DebugEntryChunkSourceStats`: `ClientLevel.gatherChunkSourceStats` + /// (the chunk storage's size and its loaded chunks, then the entities, + /// the sections holding them and the chunks that tick them), and the + /// server level's when this game runs it. + fn debug_chunk_source_lines(&self, d: &mut Displayer, server: Option<&ServerDebug>) { + if self.player.is_none() { + return; + } + let range = self + .remote + .as_ref() + .map_or(self.settings.render_distance, |r| r.view_distance); + let side = (2 * (range.max(2) + 3) + 1) as usize; + let loaded = self.world.chunks.len(); + d.line(format!( + "Chunks[C] W: {}, {loaded} E: {},{},{loaded}", + side * side, + self.debug_entity_count(), + self.debug_entity_sections() + )); + if let Some(line) = server.and_then(|s| s.chunks.clone()) { + d.line(line); + } + } + + /// `DebugEntryHeightmap`: the client chunk's heightmaps at the feet, + /// and the server chunk's. + fn debug_heightmap_lines(&self, d: &mut Displayer, server: Option<&ServerDebug>) { + let Some([x, _, z]) = self.debug_feet() else { + return; + }; + if !self.world.loaded(x, z) { + return; + } + let heights = server.and_then(|s| s.heights); + let server_height = |kind: HeightmapType| { + let i = [ + HeightmapType::WorldSurface, + HeightmapType::OceanFloor, + HeightmapType::MotionBlocking, + HeightmapType::MotionBlockingNoLeaves, + ] + .iter() + .position(|k| *k == kind) + .unwrap_or(0); + heights.map_or(0, |h| h[i]) + }; + let lines = overlay::heightmap_lines( + |kind| self.column_height(kind, x, z), + heights + .is_some() + .then_some(&server_height as &dyn Fn(_) -> i32), + ); + d.group("heightmaps", lines); + } + + /// `Heightmap.getFirstAvailable`: one above the highest block the + /// heightmap counts. The chunk keeps WORLD_SURFACE; every other type is + /// at or under it, scanned down from there. + pub(super) fn column_height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 { + let properties = &self.pack.assets.properties; + let surface = self + .world + .world_surface(x, z, |s| properties.heightmap_state(s).is_air()); + if kind == HeightmapType::WorldSurface { + return surface; + } + (self.world.bottom_y()..surface) + .rev() + .find(|&y| kind.is_opaque(properties.heightmap_state(self.world.block(x, y, z)))) + .map_or(self.world.bottom_y(), |y| y + 1) + } + + /// `Entity.pick(20, 0, false)` from the camera: the block outline hit. + fn debug_block_hit(&self) -> Option<[i32; 3]> { + let (origin, dir) = self.aim()?; + Some(self.clip_ray(origin, dir, LOOKING_AT_RANGE)?.pos) + } + + /// `DebugEntryLookingAt.BlockStateInfo` or `BlockTagInfo`. + fn debug_looking_at_block(&self, state: bool) -> Vec { + let Some(pos) = self.debug_block_hit() else { + return Vec::new(); + }; + let id = self.world.block(pos[0], pos[1], pos[2]); + let Some(s) = self.pack.assets.registry.state(id) else { + return Vec::new(); + }; + if state { + overlay::state_lines( + "Targeted Block", + pos, + &s.name, + s.properties.iter().map(|(k, v)| (k.as_str(), v.as_str())), + ) + } else { + overlay::tag_lines(&self.items().block_tags, &s.name) + } + } + + /// `Entity.pick(20, 0, true)` (`ClipContext.Fluid.ANY`): the first fluid + /// along the view, under its surface, before the block outline hit. + fn debug_fluid_hit(&self) -> Option<[i32; 3]> { + let (origin, dir) = self.aim()?; + let block = self.clip_ray(origin, dir, LOOKING_AT_RANGE); + let reach = block.map_or(LOOKING_AT_RANGE, |h| { + (0..3) + .map(|i| (h.point[i] - origin[i]).powi(2)) + .sum::() + .sqrt() + }); + let mut t = 0.0; + while t <= reach { + let p: [f64; 3] = std::array::from_fn(|i| origin[i] + dir[i] * t); + let b = p.map(|v| v.floor() as i32); + if let Some((height, _)) = self.fluid_height_of(b[0], b[1], b[2], None) + && p[1] - f64::from(b[1]) <= f64::from(height) + { + return Some(b); + } + t += 0.05; + } + block.map(|h| h.pos) + } + + /// `FluidState`'s registered name and properties at `pos`: a source is + /// `water`/`lava` with `falling`, a flowing one `flowing_*` with `level`. + fn fluid_state(&self, pos: [i32; 3]) -> (String, Vec<(&'static str, String)>) { + use horizon_client::blocks::Fluid; + let f = self + .res + .facts() + .get(self.world.block(pos[0], pos[1], pos[2])); + let kind = match f.fluid { + Fluid::None => return ("minecraft:empty".into(), Vec::new()), + Fluid::Water => "water", + Fluid::Lava => "lava", + }; + let falling = ("falling", f.fluid_falling.to_string()); + if f.fluid_level == 8 && !f.fluid_falling { + (format!("minecraft:{kind}"), vec![falling]) + } else { + ( + format!("minecraft:flowing_{kind}"), + vec![falling, ("level", f.fluid_level.to_string())], + ) + } + } + + /// `DebugEntryLookingAt.FluidStateInfo` or `FluidTagInfo`. + fn debug_looking_at_fluid(&self, state: bool) -> Vec { + let Some(pos) = self.debug_fluid_hit() else { + return Vec::new(); + }; + let (name, properties) = self.fluid_state(pos); + if state { + overlay::state_lines( + "Targeted Fluid", + pos, + &name, + properties.iter().map(|(k, v)| (*k, v.as_str())), + ) + } else { + overlay::tag_lines(fluid_tags(&self.options.resources), &name) + } + } + + /// `Minecraft.crosshairPickEntity`'s type: the nearest entity under the + /// crosshair before any block. + fn crosshair_entity_type(&self) -> Option { + let mut best: Option<(f64, String)> = None; + let mut offer = |hit: Option<(f64, String)>| { + if let Some((distance, kind)) = hit + && self.before_block(distance) + && best.as_ref().is_none_or(|b| distance < b.0) + { + best = Some((distance, kind)); + } + }; + offer( + self.mob_hit() + .map(|(i, d)| (d, self.mobs[i].kind.id.to_string())), + ); + offer( + self.boat_hit() + .map(|(i, d)| (d, self.boats[i].entity_type())), + ); + offer( + self.cart_hit() + .map(|(i, d)| (d, self.carts[i].kind.id().to_owned())), + ); + offer( + self.armor_stand_hit() + .map(|(_, d, _)| (d, "minecraft:armor_stand".to_owned())), + ); + offer( + self.frame_hit() + .map(|(i, d)| (d, self.item_frames[i].kind().to_owned())), + ); + offer( + self.painting_hit() + .map(|(_, d)| (d, "minecraft:painting".to_owned())), + ); + offer( + self.dragon_hit() + .map(|(_, d)| (d, "minecraft:ender_dragon".to_owned())), + ); + offer( + self.guest_hit() + .map(|(_, d)| (d, "minecraft:player".to_owned())), + ); + best.map(|b| b.1) + } + + /// The server thread's half of the debug entries, in the level of + /// `dim` while it is in place: the server chunk's heightmaps, the local + /// difficulty, the last spawn state and the chunk source's counts. + pub(super) fn server_debug_lines(&self, feet: [i32; 3]) -> ServerDebug { + let [x, y, z] = feet; + let key = (x >> 4, z >> 4); + let loaded = self.world.chunks.contains_key(&key); + let inside = y >= self.world.bottom_y() && y < self.world.top_y(); + let heights = loaded.then(|| { + [ + HeightmapType::WorldSurface, + HeightmapType::OceanFloor, + HeightmapType::MotionBlocking, + HeightmapType::MotionBlockingNoLeaves, + ] + .map(|kind| self.column_height(kind, x, z)) + }); + let difficulty = (loaded && inside).then(|| { + let at = [f64::from(x), f64::from(y), f64::from(z)]; + overlay::local_difficulty_line(self.local_difficulty(at).effective) + }); + let spawn_counts = self + .spawn_counts + .get(&self.dim) + .map(|(chunks, counts)| overlay::spawn_counts_line(*chunks, counts)); + // ServerLevel.gatherChunkSourceStats: the chunk map's size, then + // PersistentEntitySectionManager's known and visible entities, its + // sections, the chunks it loaded and sees, and its two queues + // (Horizon loads a chunk's entities with it: both stay empty). + let (entities, loaded) = (self.debug_entity_count(), self.world.chunks.len()); + let chunks = Some(format!( + "Chunks[S] W: {loaded} E: {entities},{entities},{},{loaded},{loaded},0,0", + self.debug_entity_sections() + )); + ServerDebug { + heights, + difficulty, + spawn_counts, + chunks, + } + } +} + +/// The fluid tags (`data/*/tags/fluid`), loaded once. +fn fluid_tags(root: &std::path::Path) -> &'static HashMap> { + static TAGS: std::sync::OnceLock>> = std::sync::OnceLock::new(); + TAGS.get_or_init(|| { + horizon_client::items::load_tags(root, "fluid", &horizon_world::loot::PackOverlay::NONE) + .unwrap_or_default() + }) +} diff --git a/crates/horizon-client/src/game/debug_options.rs b/crates/horizon-client/src/game/debug_options.rs index 061514ac14..37564b92d7 100644 --- a/crates/horizon-client/src/game/debug_options.rs +++ b/crates/horizon-client/src/game/debug_options.rs @@ -317,6 +317,19 @@ impl Game<'_> { }; let (w, h) = self.gui_size(); let (top, bottom) = (HEADER, h - FOOTER); + let rows = rows(&s.search.value); + let left = row_left(w); + // A press on the scrollbar holds it and clicks nothing else. + if s.scroll.press( + self.cursor, + left + ROW_WIDTH + 8.0, + top, + bottom, + content_height(rows.len()), + ) { + self.debug_options = Some(s); + return; + } let shift = self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight); s.search.focused = ui::hovered( @@ -330,8 +343,6 @@ impl Game<'_> { s.search.click(&self.gui.atlas, self.cursor.0, shift, false); } let inside = self.cursor.1 >= top && self.cursor.1 < bottom; - let rows = rows(&s.search.value); - let left = row_left(w); let mut clicked = None; if inside { for (i, row) in rows.iter().enumerate() { @@ -390,6 +401,23 @@ impl Game<'_> { .wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content); } } + /// The pointer moved while the list's scrollbar is held. + pub(super) fn debug_options_drag(&mut self) { + let (_, h) = self.gui_size(); + let y = self.cursor.1; + if let Some(s) = &mut self.debug_options + && s.scroll.held() + { + let content = content_height(rows(&s.search.value).len()); + s.scroll.drag(y, HEADER, h - FOOTER, content); + } + } + /// The button came up: the scrollbar lets go. + pub(super) fn debug_options_release(&mut self) { + if let Some(s) = &mut self.debug_options { + s.scroll.release(); + } + } /// A key the debug keys left: Escape closes, the rest edits the search. pub(super) fn debug_options_key(&mut self, event: &KeyEvent) { if event.state != ElementState::Pressed { diff --git a/crates/horizon-client/src/game/debug_renderers.rs b/crates/horizon-client/src/game/debug_renderers.rs new file mode 100644 index 0000000000..9deb93fb3b --- /dev/null +++ b/crates/horizon-client/src/game/debug_renderers.rs @@ -0,0 +1,292 @@ +//! The debug options screen's renderer entries (26.3 `DebugRenderer`): the +//! water levels, heightmaps, collision boxes, light levels and sturdy faces +//! around the camera. Lines join the debug gizmos; fills and billboard texts +//! go with the entities' translucent geometry, as the test markers do. +//! +//! No-ops, for want of the data: `chunk_section_paths`, +//! `chunk_section_octree` and `chunk_section_visibility` (Horizon culls on +//! the GPU, with no section occlusion graph or octree), +//! `visualize_chunks_on_server` (the server's chunk statuses), +//! `visualize_sky_light_sections` (the light engine's section data) and +//! `visualize_entity_supporting_blocks` (`Entity.mainSupportingBlockPos`). +use super::Game; +use horizon_client::{ + block_entity_render::vertex_light, + debug_chart::srgb_lerp, + gizmos::{self as g, Gizmos}, + hud::HudBatch, + hud_layout::abgr, + mesher::Vertex, + text::Text, +}; +use horizon_core::{geometry::Vec3, position::BlockPos}; +use horizon_world::{collision_context::CollisionContext, heightmap::HeightmapType}; + +/// `WaterDebugRenderer`'s fill: `ARGB.colorFromFloat(0.15F, 0, 1, 0)`. +const WATER_FILL: u32 = 0x2600_FF00; +/// `SolidFaceRenderer`'s fill (-2130771968). +const SOLID_FACE_FILL: u32 = 0x80FF_0000; +/// `TextGizmo.Style.DEFAULT_SCALE`. +const TEXT_SCALE: f32 = 0.32; +/// `HeightMapRenderer.BOX_HEIGHT`. +const BOX_HEIGHT: f64 = 0.09375; + +/// `HeightMapRenderer.getColor`, for the client chunk's heightmaps. +fn heightmap_color(kind: HeightmapType) -> u32 { + match kind { + HeightmapType::WorldSurfaceWg => 0xFFFF_FF00, + HeightmapType::OceanFloorWg => 0xFFFF_00FF, + HeightmapType::WorldSurface => 0xFF00_B200, + HeightmapType::OceanFloor => 0xFF00_007F, + HeightmapType::MotionBlocking => 0xFF00_4C4C, + HeightmapType::MotionBlockingNoLeaves => 0xFF00_7F7F, + } +} + +/// `BlockPos.betweenClosed` of the cube `radius` around `center`. +fn cube(center: [i32; 3], radius: i32) -> impl Iterator { + let r = -radius..=radius; + r.clone().flat_map(move |dy| { + let r = r.clone(); + r.clone().flat_map(move |dz| { + r.clone() + .map(move |dx| [center[0] + dx, center[1] + dy, center[2] + dz]) + }) + }) +} + +impl Game<'_> { + /// `CollisionBoxRenderer`: the block collisions within 6 of the camera + /// entity's box, white. + pub(super) fn debug_renderer_gizmos(&self, g: &mut Gizmos) { + if !self.debug_entry("visualize_collision_boxes") { + return; + } + let Some(p) = &self.player else { + return; + }; + let bounds = p.bounds().inflate(Vec3::new(6.0, 6.0, 6.0)); + let (lo, hi) = (bounds.min_position(), bounds.max_position()); + let mut scene = horizon_client::collisions::SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }; + for y in lo.y.floor() as i32..=hi.y.floor() as i32 { + for z in lo.z.floor() as i32..=hi.z.floor() as i32 { + for x in lo.x.floor() as i32..=hi.x.floor() as i32 { + let Ok(shape) = self.collision.collision_shape_with_context( + &mut scene, + self.world.block(x, y, z), + BlockPos::new(x, y, z), + &CollisionContext::empty(false), + ) else { + continue; + }; + let at = [f64::from(x), f64::from(y), f64::from(z)]; + for b in shape.boxes() { + let (min, max) = (b.min_position(), b.max_position()); + g.cuboid( + [at[0] + min.x, at[1] + min.y, at[2] + min.z], + [at[0] + max.x, at[1] + max.y, at[2] + max.z], + g::WHITE, + g::STROKE_WIDTH, + ); + } + } + } + } + } + + /// The fill and text renderers' geometry for this frame. + pub(super) fn debug_renderer_geometry(&mut self, camera: glam::DVec3, out: &mut Vec) { + let water = self.debug_entry("visualize_water_levels"); + let heightmap = self.debug_entry("visualize_heightmap"); + let block_light = self.debug_entry("visualize_block_light_levels"); + let sky_light = self.debug_entry("visualize_sky_light_levels"); + let solid = self.debug_entry("visualize_solid_faces"); + let Some(p) = &self.player else { + return; + }; + if !(water || heightmap || block_light || sky_light || solid) { + return; + } + let feet = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32); + let eye = camera.to_array().map(|v| v.floor() as i32); + let mut fills: Vec<([[f64; 3]; 4], u32)> = Vec::new(); + let mut texts: Vec<([f64; 3], String, u32)> = Vec::new(); + if water { + self.water_level_shapes(feet, &mut fills, &mut texts); + } + if heightmap { + self.heightmap_shapes(eye, &mut fills); + } + if block_light || sky_light { + light_level_texts(self, eye, block_light, sky_light, &mut texts); + } + if solid { + self.solid_face_shapes(eye, &mut fills); + } + let start = out.len(); + self.mark_translucent_entity(start, ""); + let light = vertex_light(0xFF); + let mut white = HudBatch::new([1, 1]); + for (quad, color) in fills { + // A plain colour: the atlas's white pixel at the fill's colour + // (the batch takes ABGR). + white.vertices.clear(); + white.fill(&self.gui.atlas, 0.0, 0.0, 1.0, 1.0, abgr(color)); + let Some(v) = white.vertices.first().copied() else { + continue; + }; + for corner in quad { + out.push(Vertex { + normal: [0; 4], + pos: (glam::DVec3::from_array(corner) - camera) + .as_vec3() + .to_array(), + uv: v.uv, + layer: 0, + color: v.color, + light, + glint_uv: [0.0, 0.0], + }); + } + } + // `Gizmos.billboardText`: centred, without a background. + let mut patches = Vec::new(); + for (at, text, color) in texts { + patches.extend(self.name_tag_quads( + glam::DVec3::from_array(at) - camera, + &[(Text::literal(text).with_color(color & 0xFF_FFFF), 0.0)], + color >> 24, + false, + 2, + 0, + g::billboard_text_unit(TEXT_SCALE), + out, + )); + } + self.map_patches.extend(patches); + } + + /// `WaterDebugRenderer`: each water block within 10 of the feet filled + /// to its height, with its amount over it. + fn water_level_shapes( + &self, + feet: [i32; 3], + fills: &mut Vec<([[f64; 3]; 4], u32)>, + texts: &mut Vec<([f64; 3], String, u32)>, + ) { + use horizon_client::blocks::Fluid; + for pos in cube(feet, 10) { + let f = self + .res + .facts() + .get(self.world.block(pos[0], pos[1], pos[2])); + if f.fluid != Fluid::Water { + continue; + } + let Some((height, _)) = self.fluid_height_of(pos[0], pos[1], pos[2], None) else { + continue; + }; + let [x, y, z] = pos.map(f64::from); + let top = y + f64::from(height); + let (lo, hi) = (f64::from(0.01f32), f64::from(0.99f32)); + fills.extend( + g::cuboid_fill_quads([x + lo, y + lo, z + lo], [x + hi, top, z + hi]) + .map(|q| (q, WATER_FILL)), + ); + // FluidState.getAmount: a source holds 8, a flowing one its level. + texts.push(( + [x + 0.5, top, z + 0.5], + f.fluid_level.to_string(), + 0xFF00_0000, + )); + } + } + + /// `HeightMapRenderer`: the client chunk's heightmaps of the 5x5 chunks + /// around the camera, a small box per column, stacked by type. + fn heightmap_shapes(&self, eye: [i32; 3], fills: &mut Vec<([[f64; 3]; 4], u32)>) { + let (cx, cz) = (eye[0] >> 4, eye[2] >> 4); + for chunk_x in cx - 2..=cx + 2 { + for chunk_z in cz - 2..=cz + 2 { + if !self.world.chunks.contains_key(&(chunk_x, chunk_z)) { + continue; + } + for kind in HeightmapType::ALL + .into_iter() + .filter(|k| k.send_to_client()) + { + let color = heightmap_color(kind); + for x in chunk_x * 16..chunk_x * 16 + 16 { + for z in chunk_z * 16..chunk_z * 16 + 16 { + let height = f64::from(self.column_height(kind, x, z)) + + f64::from(kind as u8) * BOX_HEIGHT; + let (x, z) = (f64::from(x), f64::from(z)); + fills.extend( + g::cuboid_fill_quads( + [x + 0.25, height, z + 0.25], + [x + 0.75, height + BOX_HEIGHT, z + 0.75], + ) + .map(|q| (q, color)), + ); + } + } + } + } + } + } + + /// `SolidFaceRenderer`: each outline box of the blocks within 6 of the + /// camera, grown by 0.002, with its sides that are sturdy filled. + fn solid_face_shapes(&self, eye: [i32; 3], fills: &mut Vec<([[f64; 3]; 4], u32)>) { + // In SolidFaceRenderer's order (WEST, SOUTH, EAST, NORTH, DOWN, UP): + // the Direction ordinal and the side's quad in `cuboid_fill_quads`. + const SIDES: [(u8, usize); 6] = [(4, 1), (3, 3), (5, 0), (2, 2), (0, 5), (1, 4)]; + for pos in cube(eye, 6) { + let state = self.world.block(pos[0], pos[1], pos[2]); + let f = self.res.facts().get(state); + if &*f.name == "minecraft:air" || f.sturdy_full == 0 { + continue; + } + let at = pos.map(f64::from); + for b in f.outline.iter() { + let min = [at[0] + b[0], at[1] + b[1], at[2] + b[2]].map(|v| v - 0.002); + let max = [at[0] + b[3], at[1] + b[4], at[2] + b[5]].map(|v| v + 0.002); + let quads = g::cuboid_fill_quads(min, max); + for (direction, side) in SIDES { + if f.sturdy_full & (1 << direction) != 0 { + fills.push((quads[side], SOLID_FACE_FILL)); + } + } + } + } + } +} + +/// `LightDebugRenderer`: within 10 of the camera, the sky light where it +/// isn't full and the block light where there is some (the light engine's +/// section data, drawn with them in vanilla, Horizon doesn't keep). +fn light_level_texts( + game: &Game<'_>, + eye: [i32; 3], + block: bool, + sky: bool, + texts: &mut Vec<([f64; 3], String, u32)>, +) { + for pos in cube(eye, 10) { + let light = game.world.light(pos[0], pos[1], pos[2]); + let (sky_light, block_light) = (light >> 4, light & 15); + let [x, y, z] = pos.map(f64::from); + if sky_light != 15 && sky { + let color = srgb_lerp(f32::from(sky_light) / 15.0, 0xFF00_00FF, 0xFF00_FFFF); + texts.push(([x + 0.5, y + 0.25, z + 0.5], sky_light.to_string(), color)); + } + if block && block_light != 0 { + let color = srgb_lerp(f32::from(block_light) / 15.0, 0xFFAA_0000, 0xFFFF_FF00); + texts.push(([x + 0.5, y + 0.5, z + 0.5], block_light.to_string(), color)); + } + } +} diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index b85e92c66a..73390d70cc 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -509,7 +509,7 @@ impl Game<'_> { }); let flat = preset == horizon_client::save::WorldPreset::Flat; let settings = if flat { - let biomes = self.res.biomes(); + let biomes = world_biomes!(self); let (settings, errors) = horizon_client::flatgen::settings_from_json( &props.generator_settings, |b| self.pack.assets.registry.default_state(b).is_some(), @@ -610,6 +610,7 @@ impl Game<'_> { pub(super) fn run_dedicated(&mut self) -> Result<()> { let result = self.run_dedicated_inner(); if let Err(error) = &result { + self.native_players_leaving(); self.native_lifecycle_world_failed(&error.to_string()); self.native_lifecycle_close_failed(&error.to_string()); } @@ -1716,6 +1717,7 @@ impl Game<'_> { // MinecraftServer.stopServer's start: everyone is still here. self.native_server_stopping(true); log("INFO", "Stopping server"); + self.native_players_leaving(); log("INFO", "Saving players"); if let Some(mut host) = self.lan.take() { self.flush_native_net(&mut host); diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index a512a81e3b..ed3119864c 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -45,23 +45,41 @@ pub(super) fn aim_deflect( p.acceleration = 0.1; } -/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge -/// 0.3125, from its feet up) grown by the pick radius. +/// `Entity.getPickRadius`: only `AbstractHurtingProjectile` has one (1); a shulker +/// bullet's is 0. +fn pick_radius(p: &Projectile) -> f64 { + if matches!(p.kind, ProjectileKind::ShulkerBullet { .. }) { + 0.0 + } else { + PICK_RADIUS + } +} + +/// A projectile a player's blow reaches: a redirectable one, or a shulker bullet +/// (`isPickable` and `isAttackable`), which `ShulkerBullet.hurtServer` pops. +pub(super) fn attackable(p: &Projectile) -> bool { + redirectable(p) || (!p.stuck && matches!(p.kind, ProjectileKind::ShulkerBullet { .. })) +} + +/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge or a +/// shulker bullet 0.3125, from its feet up) grown by the pick radius, which only +/// `AbstractHurtingProjectile` has (`getPickRadius`: 1; a shulker bullet's is 0). fn pick_box(p: &Projectile) -> Aabb { let size = match p.kind { ProjectileKind::LargeFireball { .. } => 1.0, _ => 0.3125, }; - let half = size / 2.0 + PICK_RADIUS; + let radius = pick_radius(p); + let half = size / 2.0 + radius; Aabb::new( Vec3::new( p.position.x - half, - p.position.y - PICK_RADIUS, + p.position.y - radius, p.position.z - half, ), Vec3::new( p.position.x + half, - p.position.y + size + PICK_RADIUS, + p.position.y + size + radius, p.position.z + half, ), ) @@ -77,7 +95,7 @@ impl Game<'_> { .projectiles .iter() .enumerate() - .filter(|(_, p)| redirectable(p)) + .filter(|(_, p)| attackable(p)) .filter_map(|(i, p)| ray_box(eye, dir, reach, pick_box(p)).map(|t| (i, t))) .min_by(|a, b| a.1.total_cmp(&b.1)) } @@ -103,10 +121,37 @@ impl Game<'_> { return false; } } - self.deflect(i); + if matches!( + self.creatures.projectiles[i].kind, + ProjectileKind::ShulkerBullet { .. } + ) { + self.hit_shulker_bullet(i); + } else { + self.deflect(i); + } true } + /// `ShulkerBullet.hurtServer` from a player's blow: the hurt sound, fifteen crit + /// particles (`sendParticles`, spread 0.2) and `destroy`. The blow is + /// `Player.attack` on a target that is not a mob: its sounds, then the swing. + pub(super) fn hit_shulker_bullet(&mut self, i: usize) { + let p = self.creatures.projectiles.remove(i); + let at = [p.position.x, p.position.y, p.position.z]; + self.audio + .play("minecraft:entity.shulker_bullet.hurt", Some(at), 1.0, 1.0); + if let Some(ty) = horizon_client::particles::ParticleType::from_name("crit") { + let options = horizon_client::particles::ParticleOptions::simple(ty); + self.particles + .send_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15); + self.lan_level_particles(&options, at, [0.2, 0.2, 0.2], 0.0, 15, None); + } + let (_, _, _, magic) = self.melee_damage("minecraft:shulker_bullet", false); + self.vehicle_attack_sounds(p.aabb(), magic.then_some(p.id as i32)); + self.reset_attack_strength(); + self.swing_arm(); + } + /// `Player.canInteractWithEntity(box, 3.0)` as the server checks an /// attack packet: the projectile's box within the reach plus 3 of the eyes. pub(super) fn within_interaction_range(&self, i: usize) -> bool { @@ -115,9 +160,10 @@ impl Game<'_> { }; let b = pick_box(p); let (lo, hi) = (b.min_position(), b.max_position()); + let r = pick_radius(&self.creatures.projectiles[i]); let (lo, hi) = ( - [lo.x + PICK_RADIUS, lo.y + PICK_RADIUS, lo.z + PICK_RADIUS], - [hi.x - PICK_RADIUS, hi.y - PICK_RADIUS, hi.z - PICK_RADIUS], + [lo.x + r, lo.y + r, lo.z + r], + [hi.x - r, hi.y - r, hi.z - r], ); let d2: f64 = (0..3) .map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2)) @@ -192,6 +238,22 @@ mod tests { let small = Projectile::fireball(ProjectileKind::SmallFireball, at, None, [1.0, 0.0, 0.0]); assert!(redirectable(&large)); assert!(!redirectable(&small)); + // A shulker bullet takes a blow, with no pick radius round its box. + let bullet = Projectile::new( + ProjectileKind::ShulkerBullet { + target: Who::Player, + dir: None, + steps: 0, + delta: [0.0; 3], + seed: 0, + }, + at, + None, + ); + assert!(attackable(&bullet) && !redirectable(&bullet)); + assert!(!attackable(&small)); + let b = pick_box(&bullet); + assert_eq!(b.max_position().y, 64.3125); // The pick box: a block round the fireball's 1 x 1 box. let b = pick_box(&large); assert_eq!(b.min_position().x, -1.5); diff --git a/crates/horizon-client/src/game/dialogs.rs b/crates/horizon-client/src/game/dialogs.rs index 5b0e52f496..5ddf716560 100644 --- a/crates/horizon-client/src/game/dialogs.rs +++ b/crates/horizon-client/src/game/dialogs.rs @@ -199,6 +199,9 @@ impl Game<'_> { selecting: None, }); self.dialogs.waiting = None; + self.keys.clear(); + self.left = false; + self.right = false; self.capture(false); } @@ -261,8 +264,7 @@ impl Game<'_> { Some(Click::RunCommand(command)) => { // In configuration there is nothing to run it (vanilla warns). if self.player.is_some() { - let command = command.strip_prefix('/').unwrap_or(&command); - self.send_chat(&format!("/{command}")); + self.send_unattended_command(&command); } else { log::warn!( "Horizon: commands are not supported in configuration, trying to run '{command}'" @@ -696,6 +698,7 @@ impl Game<'_> { input, rect: [x, y, w, h], } if ui::hovered(cursor, x, y, w, h) => { + open.focus = Some(input); open.dragging = Some(input); if let Some(v) = open.values.get_mut(input) { *v = InputValue::Slider(ui::slider_value(x, w, cursor.0)); @@ -768,16 +771,17 @@ impl Game<'_> { if let Some(open) = self.dialogs.open.as_mut() { let [_, ay, _, ah] = layout.area; if delta != 0.0 && cursor.1 >= ay && cursor.1 < ay + ah { - let hovered = layout - .placed(open.scroll.amount) - .find_map(|(part, _)| match part { - Part::Cycle { - input, - rect: [x, y, w, h], - } if ui::hovered(cursor, x, y, w, h) => Some(input), - _ => None, - }); - if let Some(input) = hovered { + let hovered_cycle = + layout + .placed(open.scroll.amount) + .find_map(|(part, _)| match part { + Part::Cycle { + input, + rect: [x, y, w, h], + } if ui::hovered(cursor, x, y, w, h) => Some(input), + _ => None, + }); + if let Some(input) = hovered_cycle { // CycleButton.mouseScrolled: up goes back. cycle(open, input, if delta > 0.0 { -1 } else { 1 }); return; @@ -912,6 +916,32 @@ impl Game<'_> { } return Ok(()); } + if let Some(focus) = open.focus + && let Some(input) = open.dialog.inputs.get(focus) + && let Control::NumberRange { + start, + end, + step, + width, + .. + } = &input.control + && let Some(InputValue::Slider(val)) = open.values.get_mut(focus) + && matches!(key, KeyCode::ArrowLeft | KeyCode::ArrowRight) + { + let delta = if let Some(step) = step { + let range = (end - start).abs(); + if range > 0.0 { *step / range } else { 0.01 } + } else { + 1.0 / (*width as f32 - 8.0).max(1.0) + }; + let step_sign = if key == KeyCode::ArrowRight { + delta + } else { + -delta + }; + *val = (*val + step_sign).clamp(0.0, 1.0); + return Ok(()); + } let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else { return Ok(()); }; diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index b8564c0d02..3613c6b6b9 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -123,7 +123,7 @@ impl Game<'_> { fn fixture_reset(&mut self, seed: i64) { self.world = World::new(self.res.facts().air, seed); // The oracle level is plains everywhere (bone meal's flowers, freezing). - let plains = u32::from(self.res.biomes().id("minecraft:plains")); + let plains = u32::from(world_biomes!(self).id("minecraft:plains")); for cx in -2..=2 { for cz in -2..=2 { let mut chunk = Chunk::empty(cx, cz); diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index a36c84aa72..47bdf8c7dc 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -3949,6 +3949,28 @@ mod tests { assert_eq!(d.health, MAX_HEALTH - 1.0); } + #[test] + fn only_a_player_owned_wither_skull_takes_the_dragons_health() { + // WitherSkull.onHitEntity: EnderDragon.hurt takes the wither's skull + // (not a player, not an explosion) but takes no health, even perched. + let skull = ProjectileKind::WitherSkull { dangerous: false }; + let wither = super::super::wither::skull_source(Some(Who::Mob(7)), [0.0; 3]); + assert_eq!((wither.amount, wither.kind), (8.0, DamageKind::WitherSkull)); + let mut d = dragon(Phase::Scanning { time: 0 }); + let hurt = d.hurt_part(2, wither.amount, false, wither.kind, Some(&skull)); + assert_eq!(hurt, Hurt::Landed { full: false }); + assert_eq!(d.health, MAX_HEALTH); + // A player's skull: 8 on the head, 8 / 4 + 1 on the body. + let player = super::super::wither::skull_source(Some(Who::Player), [0.0; 3]); + let mut d = dragon(Phase::Scanning { time: 0 }); + let hurt = d.hurt_part(0, player.amount, true, player.kind, Some(&skull)); + assert_eq!(hurt, Hurt::Landed { full: true }); + assert_eq!(d.health, MAX_HEALTH - 8.0); + let mut d = dragon(flying()); + d.hurt_part(2, player.amount, true, player.kind, Some(&skull)); + assert_eq!(d.health, MAX_HEALTH - 3.0); + } + #[test] fn the_cooldown_lets_only_the_excess_through() { // LivingEntity.hurtServer: damageCooldownTime > 10 keeps lastHurt. diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index 630da827a3..b2e5d52acb 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -364,7 +364,9 @@ impl Game<'_> { let ticks = (between("min_duration", "max_duration") * 20.0).round() as i32; let amplifier = (between("min_amplifier", "max_amplifier").round() as i32).max(0); if let Some(id) = super::potions::mob_effect_id(ids[pick]) { + let (new, entity) = (!m.has_effect(id), m.id as i32); m.add_effect(id, ticks, amplifier); + self.mod_effect_added(id, Some(entity), amplifier, new); } } /// `minecraft:post_attack` explode effects on the attacker (wind burst): @@ -488,14 +490,7 @@ impl Game<'_> { .is_some_and(|i| s.damage >= i.max_damage) { self.armor[slot] = None; - if let Some(eye) = self.eye_position() { - self.audio.play( - "minecraft:entity.item.break", - Some(eye), - 0.8, - 0.8 + self.world.random.next_float() * 0.4, - ); - } + self.player_equipment_broke(super::armor_slot(slot), &stack); self.apply_equipment(); } } @@ -760,16 +755,11 @@ impl Game<'_> { boots.damage += loss; if boots.damage >= max { self.armor[3] = None; - if let Some(eye) = self.eye_position() { - self.audio.play( - "minecraft:entity.item.break", - Some(eye), - 0.8, - 0.8 + self.world.random.next_float() * 0.4, - ); - } - // LivingEntity.breakItem: the broken boots' five crumbs. - self.eat_particles(&stack, 5); + // LivingEntity.onEquippedItemBroken: the broken boots. + self.player_equipment_broke( + horizon_world::player_inventory::EquipmentSlot::Feet, + &stack, + ); self.apply_equipment(); } } diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 7e9d4487c4..bd7b88c9cb 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -6,7 +6,7 @@ use super::{Dim, Game}; use horizon_client::nether_portal::ENTITY_DELAY; use horizon_client::{ Result, - containers::{BlockEntity, EndGateway}, + containers::BlockEntity, environment::Environment, items::ItemStack, mesher::Vertex, @@ -39,8 +39,6 @@ pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0]; /// EnderDragonFight gateway ring radius and height. const GATEWAY_RADIUS: f64 = 96.0; const GATEWAY_Y: i32 = 75; -/// File (in the End region folder) keeping End gateway exits. -const GATEWAY_FILE: &str = "horizon-end-gateways.json"; /// A gateway teleport waiting for its destination chunks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -74,17 +72,21 @@ enum Arriving { } impl GatewayJob { - /// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks. - fn for_gateway( - gateway: [i32; 3], - entity: Option<&BlockEntity>, - legacy: Option<([i32; 3], bool)>, - ) -> Self { + /// The source of a new exit must stay loaded until its block entity is updated. + pub(super) fn source_chunk(self) -> Option<(i32, i32)> { + match self { + Self::Find { gateway, .. } => Some((gateway[0] >> 4, gateway[2] >> 4)), + Self::Known { .. } => None, + } + } + + /// TheEndGatewayBlockEntity.getPortalPosition, using the gateway block entity. + fn for_gateway(gateway: [i32; 3], entity: Option<&BlockEntity>) -> Self { let configured = match entity { Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)), _ => None, }; - match configured.or(legacy) { + match configured { Some((exit, exact)) => Self::Known { exit, exact }, // Worldgen return gateways (end_gateway_return) lead exactly to the End spawn. None if f64::from(gateway[0]).hypot(f64::from(gateway[2])) > 512.0 => Self::Known { @@ -105,17 +107,6 @@ fn exact_teleport(entity: Option<&BlockEntity>) -> bool { matches!(entity, Some(BlockEntity::EndGateway(g)) if g.exact_teleport) } -/// Moves an exit that only the side file knew into the gateway's block entity, unless the block -/// entity has its own; true when it took the saved one. -fn adopt_saved_exit(entity: &mut EndGateway, (exit, exact): ([i32; 3], bool)) -> bool { - if entity.exit_portal.is_some() { - return false; - } - entity.exit_portal = Some(exit); - entity.exact_teleport = exact; - true -} - /// The end of `getPortalPosition`: the exit itself when the teleport is exact, /// else the spot [`Game::resolve_gateway`] finds around it (`findExitPosition`). fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; 3]) -> [i32; 3] { @@ -124,8 +115,6 @@ fn exit_target(exit: [i32; 3], exact: bool, find: impl FnOnce([i32; 3]) -> [i32; #[derive(Default)] pub(super) struct EndState { - /// Gateway position -> (exit, exact teleport); loaded lazily from the save. - exits: Option>, job: Option, /// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown). gateway_cooldown: u32, @@ -932,74 +921,39 @@ impl Game<'_> { Ok(()) } - fn gateway_exits(&mut self) -> &mut HashMap<[i32; 3], ([i32; 3], bool)> { - if self.end.exits.is_none() { - let path = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end") - .join(GATEWAY_FILE); - let parsed: Option> = std::fs::read(&path) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()); - self.end.exits = Some( - parsed - .unwrap_or_default() - .into_iter() - .map(|(g, e, x)| (g, (e, x))) - .collect(), - ); - } - self.end.exits.as_mut().expect("loaded exits") - } - - fn save_gateway_exits(&mut self) -> Result<()> { - let list: Vec<_> = self - .gateway_exits() - .iter() - .map(|(g, (e, x))| (*g, *e, *x)) - .collect(); - let dir = horizon_client::save::dimension_dir(&self.world_root, "minecraft:the_end"); - std::fs::create_dir_all(&dir)?; - std::fs::write(dir.join(GATEWAY_FILE), serde_json::to_vec(&list)?)?; - Ok(()) - } - - /// Drops a gateway's entry from the side file: its block entity is the source of truth. - fn forget_gateway_exit(&mut self, gateway: [i32; 3]) -> Result<()> { - if self.gateway_exits().remove(&gateway).is_some() { - self.save_gateway_exits()?; - } - Ok(()) - } - + /// `TheEndGatewayBlockEntity.setExitPosition`: the block entity alone owns the exit. fn set_gateway_exit(&mut self, gateway: [i32; 3], exit: [i32; 3], exact: bool) -> Result<()> { - // TheEndGatewayBlockEntity.setExitPosition (also EndGatewayFeature.knownExit). - let key = (gateway[0], gateway[1], gateway[2]); let chunk = (gateway[0] >> 4, gateway[2] >> 4); - // The gateway's chunk can be unloaded by now (travel waits for the destination): the - // exit then waits in the side file until the gateway is used again (`gateway_job`). - if self.world.chunks.contains_key(&chunk) - && let BlockEntity::EndGateway(g) = self - .world - .block_entities - .get_or_insert_with(key, || BlockEntity::EndGateway(Default::default())) + if !self.world.chunks.contains_key(&chunk) { + return Err("gateway source chunk is not loaded".into()); + } + // A gateway removed while an entity waits for the destination stays removed. + if &*self.block_name(gateway) != "minecraft:end_gateway" { + return Ok(()); + } + if let BlockEntity::EndGateway(g) = self + .world + .block_entities + .get_or_insert_with((gateway[0], gateway[1], gateway[2]), || { + BlockEntity::EndGateway(Default::default()) + }) { g.exit_portal = Some(exit); g.exact_teleport = exact; self.world.modified.insert(chunk); - return self.forget_gateway_exit(gateway); } - self.gateway_exits().insert(gateway, (exit, exact)); - self.save_gateway_exits() + Ok(()) } - /// The exit a gateway already leads to: its block entity, or the side file - /// that kept it for a gateway whose chunk is not loaded. - fn known_gateway_exit(&mut self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> { - let key = (gateway[0], gateway[1], gateway[2]); - let saved = match self.world.block_entities.get(&key) { - Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|exit| (exit, g.exact_teleport)), + fn known_gateway_exit(&self, gateway: [i32; 3]) -> Option<([i32; 3], bool)> { + match self + .world + .block_entities + .get(&(gateway[0], gateway[1], gateway[2])) + { + Some(BlockEntity::EndGateway(g)) => g.exit_portal.map(|e| (e, g.exact_teleport)), _ => None, - }; - saved.or_else(|| self.gateway_exits().get(&gateway).copied()) + } } /// EndGatewayBlock.entityInside and TheEndGatewayBlockEntity.getPortalPosition: @@ -1021,32 +975,18 @@ impl Game<'_> { /// its exit, or the search for one, and the spot to load before either /// is resolved ([`Game::resolve_gateway`]). pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) { - let mut known = self.gateway_exits().get(&gateway).copied(); - // Once the gateway's chunk is loaded its block entity is the source of truth: an exit the - // side file kept for it moves there, and the entry is dropped, so removing or replacing - // the exit (`/data remove block`) is not undone by an old entry. - let key = (gateway[0], gateway[1], gateway[2]); - let chunk = (gateway[0] >> 4, gateway[2] >> 4); - if let Some(saved) = known - && self.world.chunks.contains_key(&chunk) - && let BlockEntity::EndGateway(g) = self - .world - .block_entities - .get_or_insert_with(key, || BlockEntity::EndGateway(Default::default())) - { - if adopt_saved_exit(g, saved) { - self.world.modified.insert(chunk); - } - known = None; - let _ = self.forget_gateway_exit(gateway); - } let job = GatewayJob::for_gateway( gateway, self.world .block_entities .get(&(gateway[0], gateway[1], gateway[2])), - known, ); + if let Some(c) = job.source_chunk() + && !self.end.areas.contains(&c) + { + self.end.areas.push(c); + self.streaming.extra.push((c, 1)); + } let destination = match job { GatewayJob::Known { exit, .. } => Vec3::new( f64::from(exit[0]) + 0.5, @@ -1108,6 +1048,13 @@ impl Game<'_> { find_exit(self, e) }))), GatewayJob::Find { gateway, exact } => { + if !self + .world + .chunks + .contains_key(&(gateway[0] >> 4, gateway[2] >> 4)) + { + return Err("gateway source chunk is not loaded".into()); + } // The player and the entities may resolve the same gateway: the // first one to arrive finds the exit, the others use it. let known = self.known_gateway_exit(gateway); @@ -1598,6 +1545,8 @@ impl Game<'_> { uuid, self.persist_raw(id), ); + // A gateway keeps the item in its level (teleportSameDimension + // moves it): it doesn't leave, so no native_entity_leave_level. self.dropped.remove(i); self.forget_entity(id); tag @@ -1668,7 +1617,12 @@ impl Game<'_> { /// on from there. The traveller comes back while it still waits. fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option { let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4); - if !self.world.chunks.contains_key(&chunk(arrival.target)) { + if !self.world.chunks.contains_key(&chunk(arrival.target)) + || arrival + .job + .source_chunk() + .is_some_and(|c| !self.world.chunks.contains_key(&c)) + { return Some(arrival); } let at = match self.resolve_gateway(arrival.job, arrival.target) { @@ -1736,7 +1690,16 @@ impl Game<'_> { ) }); let tickets = self.end.tickets.iter().filter(|_| in_end); - for c in arrivals.chain(tickets.map(|&(c, _)| c)) { + let sources = self + .end + .arrivals + .iter() + .filter_map(|a| a.job.source_chunk()); + for c in arrivals + .chain(tickets.map(|&(c, _)| c)) + .chain(sources) + .chain(self.gateway_source_chunks()) + { if !areas.contains(&c) { areas.push(c); self.streaming.extra.push((c, 1)); @@ -1785,23 +1748,6 @@ mod tests { use super::*; use horizon_client::containers::EndGateway; - #[test] - fn a_saved_exit_moves_into_a_gateway_without_one() { - let saved = ([8, 100, 0], true); - let mut bare = EndGateway::default(); - assert!(adopt_saved_exit(&mut bare, saved)); - assert_eq!(bare.exit_portal, Some([8, 100, 0])); - assert!(bare.exact_teleport); - // The block entity's own exit wins over a stale entry. - let mut own = EndGateway { - exit_portal: Some([1, 2, 3]), - ..Default::default() - }; - assert!(!adopt_saved_exit(&mut own, saved)); - assert_eq!(own.exit_portal, Some([1, 2, 3])); - assert!(!own.exact_teleport); - } - #[test] fn a_new_exit_keeps_the_gateways_exact_teleport() { let exact = BlockEntity::EndGateway(EndGateway { @@ -1823,7 +1769,7 @@ mod tests { } #[test] - fn end_gateway_job_prefers_block_entity_then_legacy_then_heuristic() { + fn end_gateway_job_reads_the_block_entity_and_keeps_new_sources_loaded() { let gateway = [702, 100, 0]; let exit = [8, 100, 0]; for exact in [true, false] { @@ -1833,18 +1779,14 @@ mod tests { ..Default::default() }); assert_eq!( - GatewayJob::for_gateway(gateway, Some(&entity), Some((END_SPAWN, !exact))), + GatewayJob::for_gateway(gateway, Some(&entity)), GatewayJob::Known { exit, exact }, ); } let empty = BlockEntity::EndGateway(EndGateway::default()); for entity in [None, Some(&empty)] { assert_eq!( - GatewayJob::for_gateway(gateway, entity, Some((exit, false))), - GatewayJob::Known { exit, exact: false } - ); - assert_eq!( - GatewayJob::for_gateway(gateway, entity, None), + GatewayJob::for_gateway(gateway, entity), GatewayJob::Known { exit: END_SPAWN, exact: true @@ -1856,14 +1798,14 @@ mod tests { ..Default::default() }); assert_eq!( - GatewayJob::for_gateway(nearby, Some(&exact), None), + GatewayJob::for_gateway(nearby, Some(&exact)), GatewayJob::Find { gateway: nearby, exact: true } ); assert_eq!( - GatewayJob::for_gateway(nearby, entity, None), + GatewayJob::for_gateway(nearby, entity), GatewayJob::Find { gateway: nearby, exact: false diff --git a/crates/horizon-client/src/game/event_loop.rs b/crates/horizon-client/src/game/event_loop.rs new file mode 100644 index 0000000000..9cd829ee45 --- /dev/null +++ b/crates/horizon-client/src/game/event_loop.rs @@ -0,0 +1,24 @@ +//! What the game asks of the loop that runs it. +//! +//! A window's game runs inside winit's event loop, and its methods used to take winit's +//! `ActiveEventLoop` only to stop the loop or to say when to wake next. A headless client +//! (#4951) has no display, so it cannot have that loop: it runs its own, and hands the same +//! methods this trait instead. winit's loop implements it, so a window's game is unchanged. + +use winit::event_loop::{ActiveEventLoop, ControlFlow}; + +pub(super) trait EventLoopCtl { + /// Ends the loop after the current iteration (`ActiveEventLoop::exit`). + fn exit(&self); + /// When the loop wakes next (`ActiveEventLoop::set_control_flow`). + fn set_control_flow(&self, flow: ControlFlow); +} + +impl EventLoopCtl for ActiveEventLoop { + fn exit(&self) { + ActiveEventLoop::exit(self); + } + fn set_control_flow(&self, flow: ControlFlow) { + ActiveEventLoop::set_control_flow(self, flow); + } +} diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 91aa5af295..90f7f9f97c 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -327,6 +327,9 @@ impl Game<'_> { /// whole, this push in it, once their server copy has travelled /// (Entity.markHurt, then ServerEntity.sendChanges), and replaces what the /// packet would add. + /// + /// `cause` is the blast's source as predicates see it, read before the + /// guests' turns swap their own combat state in. pub(super) fn explosion_hurt_player( &mut self, center: [f64; 3], @@ -334,6 +337,7 @@ impl Game<'_> { damage_type: &str, exploding: Option, attacker: Option<&(u32, Text)>, + cause: Option<&horizon_client::advancements::EntityView>, ) -> Option<[f64; 3]> { let diameter = f64::from(radius * 2.0); // Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and @@ -354,7 +358,7 @@ impl Game<'_> { return None; } // ServerPlayer.onExplosionHit: the blast that hit the player is theirs. - self.adv.marks.blast = Some((feet, self.combat.explosion_cause.clone())); + self.adv.marks.blast = Some((feet, cause.cloned())); let w = f64::from(p.dimensions.width) / 2.0; let h = f64::from(p.dimensions.height); let eye = [ @@ -406,6 +410,8 @@ impl Game<'_> { amount, blocking, attacker.is_some(), + // The blast's causing entity. + attacker.map_or(0, |(id, _)| *id as i32), ) { outcome = o; @@ -478,10 +484,18 @@ impl Game<'_> { }; // The player, then every other player in the level (LAN guests), // each swapped in as the player. In a guest's turn (their hit on an - // end crystal) the player is that guest. + // end crystal) the player is that guest. The blast's source is the + // same for all of them (`onExplosionHit(this.source)`). let attacker = self.blast_attacker(); - let own = - self.explosion_hurt_player(center, radius, damage_type, exploding, attacker.as_ref()); + let blast_source = self.combat.explosion_cause.clone(); + let own = self.explosion_hurt_player( + center, + radius, + damage_type, + exploding, + attacker.as_ref(), + blast_source.as_ref(), + ); let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect(); if let Some(mut host) = self.lan.take() { let guests: Vec = host @@ -499,6 +513,7 @@ impl Game<'_> { damage_type, exploding, attacker.as_ref(), + blast_source.as_ref(), ) }); if let Some(Some(push)) = push { @@ -565,6 +580,7 @@ impl Game<'_> { } self.mobs = mobs; // Dropped items: 5 health, destroyed by the explosion damage (nether stars resist). + let before = self.dropped_ids(); let mut items = std::mem::take(&mut self.dropped); items.retain_mut(|item| { let feet = [item.position.x, item.position.y, item.position.z]; @@ -591,9 +607,11 @@ impl Game<'_> { true }); self.dropped = items; + self.native_items_left_since(&before); // Experience orbs: `ExperienceOrb.hurtServer` takes the damage off // their 5 health, and `pushFromExplosion` moves the ones that stay // (a 0.5 x 0.5 box, the eyes at 0.85 of its height). + let orbs_before = self.orb_ids(); let mut orbs = std::mem::take(&mut self.orbs); orbs.retain_mut(|o| { let feet = [o.position.x, o.position.y, o.position.z]; @@ -620,6 +638,7 @@ impl Game<'_> { true }); self.orbs = orbs; + self.native_orbs_left_since(&orbs_before); // Primed TNT: knockback only, from its position (not its eyes). let mut tnts = std::mem::take(&mut self.tnts); for t in &mut tnts { diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index 6c20ccf93a..6a51940511 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -151,7 +151,7 @@ impl Game<'_> { 6.0, horizon_client::mobs::Damage { kind: horizon_client::mobs::DamageKind::Magic, - attacker: Some(horizon_client::mobs::Who::Mob(f.owner)), + attacker: owner.map(horizon_client::mobs::Who::Mob), from: Some(f.pos), }, ); diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index e263f4bfe0..c84c74542f 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -467,7 +467,13 @@ impl Game<'_> { fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option, at: [f64; 3]) { for &(id, amount) in hits { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - m.damage(amount, Damage::fireworks(attacker, at)); + let hurt = m.damage(amount, Damage::fireworks(attacker, at)); + // LivingEntity.hurtServer: a player's rocket makes them the + // last player to hurt it (`setLastHurtByPlayer`), so its death + // message names them and their item. + if hurt && attacker == Some(Who::Player) { + self.last_hurt_by_player.insert(id, self.lan_acting); + } } } } diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index d189c963e2..906ce7f8d2 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -291,7 +291,7 @@ impl Game<'_> { let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32); let c = self.world.chunks.get(&(x >> 4, z >> 4))?; Some( - self.res.biomes().list[c.biome(x, y, z) as usize] + world_biomes!(self).list[c.biome(x, y, z) as usize] .name .clone(), ) diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index 3413408e68..19f4fdcaef 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -2,7 +2,7 @@ //! `ItemFrame.survives`, `interact` (put an item in, then rotate it), //! `hurtServer` (take the item out, then break the frame) and //! `ItemFrameRenderer` (the frame model and the item at a 45 degree step). -use super::{Game, Hit}; +use super::{Game, Hit, InteractTarget}; use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary}; use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex}; use horizon_core::{ @@ -210,12 +210,17 @@ impl Game<'_> { > distance }) } - /// `ItemFrame.interact` for the frame under the crosshair. - pub(super) fn interact_frame(&mut self) -> bool { - let Some((i, d)) = self.frame_hit() else { + /// `ItemFrame.interact` for the frame under the crosshair, or the one a + /// guest's interact packet named. + pub(super) fn interact_frame(&mut self, target: Option) -> bool { + let Some((i, d)) = (match target { + Some(t) => Self::named_entity(&self.item_frames, t.id, |f| f.id).map(|i| (i, 0.0)), + None => self.frame_hit(), + }) else { return false; }; - if !self.before_block(d) || self.item_frames[i].fixed { + // Only a client's own pick is stopped by a block: the range check passed. + if (target.is_none() && !self.before_block(d)) || self.item_frames[i].fixed { return false; } let at = center(&self.item_frames[i]); diff --git a/crates/horizon-client/src/game/functions.rs b/crates/horizon-client/src/game/functions.rs index e3b8dbd42e..3d57068c10 100644 --- a/crates/horizon-client/src/game/functions.rs +++ b/crates/horizon-client/src/game/functions.rs @@ -15,7 +15,6 @@ use horizon_client::{ text::{self, Text}, }; use horizon_world::nbt::Tag; -use std::collections::BTreeSet; /// A `TimerQueue` event. #[derive(Clone, Debug, PartialEq)] @@ -47,8 +46,9 @@ pub(super) enum Outcome { Fail, } -/// The world's pack stack: the built-in pack (unless disabled) and the -/// packs of `/datapacks`, with the enabled ids from `datapacks.json`. +/// The world's pack stack: the built-in pack (unless disabled), the packs +/// of `/datapacks` and the resources root's feature packs, with the +/// enabled ids from `datapacks.json` (`PackRepository.reload`). pub(super) struct PackStack { pub builtin: Option, pub packs: Vec, @@ -58,33 +58,11 @@ pub(super) fn pack_stack( world_root: &std::path::Path, resources: &std::path::Path, vanilla_disabled: bool, + enabled_features: &[String], ) -> PackStack { let builtin = (!vanilla_disabled).then(|| DataPack::builtin(resources)); - let packs = datapack::discover(world_root); - let saved: Option = std::fs::read(world_root.join("datapacks.json")) - .ok() - .and_then(|b| serde_json::from_slice(&b).ok()); - let list = |key: &str| -> Vec { - saved - .as_ref() - .and_then(|v| v[key].as_array()) - .map(|a| { - a.iter() - .filter_map(|s| s.as_str().map(str::to_owned)) - .collect() - }) - .unwrap_or_default() - }; - let disabled: BTreeSet = list("disabled").into_iter().collect(); - let mut enabled: Vec = list("enabled") - .into_iter() - .filter(|id| packs.iter().any(|p| &p.id == id)) - .collect(); - for p in &packs { - if !enabled.contains(&p.id) && !disabled.contains(&p.id) { - enabled.push(p.id.clone()); - } - } + let packs = datapack::discover_with_features(world_root, resources); + let enabled = datapack::enabled_ids(world_root, &packs, enabled_features); PackStack { builtin, packs, @@ -99,8 +77,9 @@ pub(super) fn content_overlay( world_root: &std::path::Path, resources: &std::path::Path, vanilla_disabled: bool, + enabled_features: &[String], ) -> Vec<(String, Vec>)> { - let stack = pack_stack(world_root, resources, vanilla_disabled); + let stack = pack_stack(world_root, resources, vanilla_disabled, enabled_features); let packs: Vec<&DataPack> = stack .packs .iter() @@ -140,6 +119,7 @@ impl Game<'_> { &self.world_root, &self.options.resources, self.cmds.vanilla_disabled, + &self.level.enabled_features, ); self.fns.packs = stack.packs; self.fns.enabled = stack.enabled; @@ -163,6 +143,7 @@ impl Game<'_> { &self.world_root, &self.options.resources, self.cmds.vanilla_disabled, + &self.level.enabled_features, ); let packs: Vec<&DataPack> = stack .builtin @@ -209,6 +190,59 @@ impl Game<'_> { .map(|p| p.id.clone()) .filter(|id| !self.fns.enabled.contains(id)) .collect(); + // A pack's worldgen ids join the completion registries + // (`command-registries.json` lists only the game's own), and so do + // its worldgen tag ids for the `#tag` completions. The packs are the + // ones the world's worldgen is built from. + let packs = horizon_client::datapack::discover_with_features( + &self.world_root, + &self.options.resources, + ); + let enabled = horizon_client::datapack::enabled_ids( + &self.world_root, + &packs, + &self.level.enabled_features, + ); + let enabled: Vec<_> = enabled + .iter() + .filter_map(|id| packs.iter().find(|p| &p.id == id)) + .collect(); + let (overrides, _) = horizon_client::datapack::worldgen_overrides(&enabled, |_| true); + for (bundle, registry) in [ + ("biomes", "minecraft:worldgen/biome"), + ("structures", "minecraft:worldgen/structure"), + ("structure_sets", "minecraft:worldgen/structure_set"), + ("template_pools", "minecraft:worldgen/template_pool"), + ("processor_lists", "minecraft:worldgen/processor_list"), + ] { + let entry = reg.by_registry.entry(registry.to_owned()).or_default(); + for (id, _) in overrides.entries(bundle) { + if !entry.ids.contains(&id.to_owned()) { + entry.ids.push(id.to_owned()); + } + } + entry.ids.sort(); + } + let entry = reg + .by_registry + .entry("minecraft:worldgen/biome".to_owned()) + .or_default(); + for (id, _, _) in overrides.tags("biome_tags") { + if !entry.tags.contains(&id.to_owned()) { + entry.tags.push(id.to_owned()); + } + } + entry.tags.sort(); + let entry = reg + .by_registry + .entry("minecraft:worldgen/structure".to_owned()) + .or_default(); + for (id, _, _) in overrides.tags("structure_tags") { + if !entry.tags.contains(&id.to_owned()) { + entry.tags.push(id.to_owned()); + } + } + entry.tags.sort(); } // ---- scheduled events ------------------------------------------------------ @@ -725,10 +759,17 @@ impl Game<'_> { _ => None, }; let selected = self.selected_packs(); - // Feature packs need features this world lacks: never available. + // `DataPackCommand.listAvailablePacks`: a feature pack the world's + // features do not cover is not available. let available: Vec = std::iter::once("vanilla".to_owned()) .chain(self.fns.packs.iter().map(|p| p.id.clone())) .filter(|p| !selected.contains(p)) + .filter(|p| { + FEATURE_PACKS + .iter() + .find(|(f, _)| *f == p.as_str()) + .is_none_or(|(_, flag)| self.level.enabled_features.iter().any(|f| f == flag)) + }) .collect(); let known = |g: &Self, n: &str| { n == "vanilla" @@ -786,7 +827,11 @@ impl Game<'_> { if selected.contains(&name) { return Err(err("commands.datapack.enable.failed", &[&name])); } - if let Some((_, flag)) = FEATURE_PACKS.iter().find(|(p, _)| *p == name) { + if let Some((_, flag)) = FEATURE_PACKS.iter().find(|(p, _)| *p == name) + && !self.level.enabled_features.iter().any(|f| f == flag) + { + // `DataPackCommand`: the pack's requested features must + // be a subset of the world's enabled ones. return Err(err( "commands.datapack.enable.failed.no_flags", &[&name, flag], @@ -826,6 +871,11 @@ impl Game<'_> { if !selected.contains(&name) { return Err(err("commands.datapack.disable.failed", &[&name])); } + if FEATURE_PACKS.iter().any(|(p, _)| *p == name) { + // `DataPackCommand`'s ERROR_CANNOT_DISABLE_FEATURE: the + // flag stays on for the world's life. + return Err(err("commands.datapack.disable.failed.feature", &[&name])); + } if name == "vanilla" { self.cmds.vanilla_disabled = true; } else { diff --git a/crates/horizon-client/src/game/initial_spawn.rs b/crates/horizon-client/src/game/initial_spawn.rs index 30c32b2a23..122c5bd0fe 100644 --- a/crates/horizon-client/src/game/initial_spawn.rs +++ b/crates/horizon-client/src/game/initial_spawn.rs @@ -83,7 +83,7 @@ impl Game<'_> { horizon_client::flatgen::FlatTerrain::from_settings( &s, self.pack.assets.registry, - self.res.biomes(), + world_biomes!(self), self.level_shapes.overworld, ) }) diff --git a/crates/horizon-client/src/game/integrated.rs b/crates/horizon-client/src/game/integrated.rs index 5c4b47cd17..f41c042ae4 100644 --- a/crates/horizon-client/src/game/integrated.rs +++ b/crates/horizon-client/src/game/integrated.rs @@ -11,7 +11,7 @@ //! is DEPRECATED: never use it, it gets no fixes and will be removed soon. //! LAN guests and dedicated servers use the same code either way. use super::{ - Game, Options, PackResources, Resources, UiResources, WorldIcon, + Dim, Game, Options, PackResources, Resources, UiResources, WorldIcon, execution_role::ExecutionRole, lan::{LanSettings, Owner, SyncPreload, preload_sync_data}, world_loading::{WorldLoader, WorldRequest}, @@ -68,6 +68,10 @@ struct Flags { /// What the server's `LevelLoadListener` reports, for the window's /// `LevelLoadingScreen` (`LevelLoadTracker.serverProgress/statusView`). load: Arc, + /// Where the window's debug entries want the server's lines for (its + /// dimension and feet), and the lines of the last tick there. + debug_at: Mutex>, + debug: Mutex, } /// Work the window hands its server thread (`MinecraftServer.execute`). @@ -96,6 +100,20 @@ impl Integrated { pub fn smoothed_tick_ms(&self) -> f32 { f32::from_bits(self.flags.smoothed_tick_ms.load(Ordering::Relaxed)) } + /// Asks for the debug entries' server lines at `at`, or stops asking. + pub(super) fn request_debug(&self, at: Option<(Dim, [i32; 3])>) { + if let Ok(mut want) = self.flags.debug_at.lock() { + *want = at; + } + } + /// The debug entries' server lines of the last tick. + pub(super) fn debug(&self) -> super::debug_lines::ServerDebug { + self.flags + .debug + .lock() + .map(|d| d.clone()) + .unwrap_or_default() + } /// `MinecraftServer.isCurrentlySaving`. pub fn is_saving(&self) -> bool { self.flags.saving.load(Ordering::Relaxed) @@ -308,6 +326,19 @@ impl Game<'_> { options.server = None; options.world = None; self.join_world_rest()?; + // The window plays against the world's own biome table (its chunks + // carry the packs' ids past the built-in list, `set_registries` + // names them through it), as the server thread's copy does. + let world = std::env::current_dir()?.join("saves").join(folder); + let enabled_features = horizon_client::save::Level::read(&world) + .map(|l| l.enabled_features) + .unwrap_or_default(); + self.world_biomes = Some(super::world_loading::packed_biomes( + &world, + &self.options.resources, + self.res.biomes(), + &enabled_features, + )); let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone()); let flags = Arc::new(Flags::default()); flags @@ -506,6 +537,7 @@ impl Game<'_> { ) -> Result<()> { let result = self.run_integrated_inner(launch, flags, tasks, ready, opening); if let Err(error) = &result { + self.native_players_leaving(); self.native_lifecycle_close_failed(&error.to_string()); } result @@ -583,6 +615,23 @@ impl Game<'_> { self.stop_integrated() } + /// After the level of `dim` ticked: the window's debug entries' server + /// lines, when they want them from this level. + pub(super) fn publish_server_debug(&self, dim: Dim) { + let Some(serving) = &self.serving else { + return; + }; + let want = serving.flags.debug_at.lock().ok().and_then(|w| *w); + if let Some((at, feet)) = want + && at == dim + { + let lines = self.server_debug_lines(feet); + if let Ok(mut debug) = serving.flags.debug.lock() { + *debug = lines; + } + } + } + /// The integrated server's world open: `run_integrated`'s segment /// from `load_world` through `settle_spawn`, whole so its failures are /// one open failure (`WorldOpenFailed`, the engine's own error text). @@ -693,6 +742,7 @@ impl Game<'_> { self.native_lifecycle.begin_close(); // MinecraftServer.stopServer's start: everyone is still here. self.native_server_stopping(false); + self.native_players_leaving(); // IntegratedServer.stopServer: teardownPublishedState. if let Some(s) = self.serving.take() { s.flags.published.store(0, Ordering::Relaxed); diff --git a/crates/horizon-client/src/game/item_particles.rs b/crates/horizon-client/src/game/item_particles.rs index caa3a685eb..52072f7aac 100644 --- a/crates/horizon-client/src/game/item_particles.rs +++ b/crates/horizon-client/src/game/item_particles.rs @@ -93,7 +93,7 @@ impl Game<'_> { /// the component is removed), heard by this client only /// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of /// 0.8 to 1.2. - fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) { + pub(super) fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) { let models = &self.pack.item_renderer.models; let sound = match models .components(&stack_view(stack)) diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index fc90ce4760..cb8b8b3b88 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -150,6 +150,8 @@ pub struct LanSettings { pub(super) struct LanHost { pub hub: Hub, + /// The mods heard that everyone still here leaves with the server. + pub players_told_left: bool, pub settings: LanSettings, pub host_entity: i32, /// The host's swings the guests were told of (`Game::swings_started`). @@ -1329,9 +1331,10 @@ impl Game<'_> { ) -> Result<()> { let sync = sync_data(&self.options.resources, &self.pack_files())?; self.sync_dialog_ids(&sync); - let biome_names: Vec = self - .res - .biomes() + // The table the world plays with (`world_biomes!`): a world with + // biome data packs sends their ids past the built-in list, as the + // encoder's `SectionCoder` expects. + let biome_names: Vec = world_biomes!(self) .list .iter() .map(|b| b.name.clone()) @@ -1376,6 +1379,7 @@ impl Game<'_> { self.world.block_log = Some(Vec::new()); self.world.light_log = Some(Default::default()); self.lan = Some(LanHost { + players_told_left: false, hub, settings: LanSettings { port, ..settings }, host_entity, @@ -1422,6 +1426,7 @@ impl Game<'_> { return; } self.friends_lan_closed(); + self.native_players_leaving(); let Some(mut host) = self.lan.take() else { return; }; @@ -1453,11 +1458,12 @@ impl Game<'_> { pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) { // The vehicle the guest rides, for the file that names it. let vehicle = self.root_vehicle_uuid(guest.vehicle); - if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) { - if guest.placed { - playerdata::store(guest, &mut self.level, vehicle); - } - } else { + if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed { + // The world's own player state rides in the level (`/data`). + playerdata::store(guest, &mut self.level, vehicle); + } + if guest.placed { + // PlayerList.save/remove: every player saves to their own file. playerdata::save(guest, &self.world_root, vehicle); } self.save_guest_records(host, guest); @@ -1734,6 +1740,8 @@ impl Game<'_> { e.position = [d.position.x, d.position.y, d.position.z]; e.on_ground = d.on_ground; e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z]; + e.precise = d.needs_sync; + e.needs_sync = d.needs_sync; // Entity.DATA_SHARED_FLAGS_ID: glowing 0x40 (the Glowing tag). if self.entity_glowing(d.id) { e.meta.push((0, serializer::BYTE, Value::Byte(0x40))); @@ -1756,7 +1764,8 @@ impl Game<'_> { /// /// Both the pre-block packet phase and the normal LAN tick receive the /// same event stream. Keep their side effects in one place so joins, - /// departures, chat, and queued guest commands cannot drift apart. + /// departures, chat, and queued guest commands cannot drift apart. None when a native hook's + /// command closed the hub. fn lan_process_events( &mut self, mut host: LanHost, @@ -1764,7 +1773,7 @@ impl Game<'_> { state: Option<&HostState>, entities: &[EntityState], spawn: [f64; 3], - ) -> LanHost { + ) -> Option { for event in events { match event { event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => { @@ -1825,6 +1834,17 @@ impl Game<'_> { .with_color(horizon_client::text::YELLOW), ); } + if let Some((uuid, name)) = host + .hub + .guest(conn) + .filter(|g| g.placed) + .map(|g| (g.uuid, g.name.clone())) + { + self.lan = Some(host); + self.native_player_logged_in(uuid, &name); + // A hook's command can close the hub. + host = self.lan.take()?; + } } HostEvent::Left(mut guest) => { // The container they had open loses an opener. @@ -1872,6 +1892,11 @@ impl Game<'_> { .with_color(horizon_client::text::YELLOW), ); } + if guest.placed && !host.players_told_left { + self.lan = Some(host); + self.native_player_logged_out(guest.uuid, &guest.name); + host = self.lan.take()?; + } } HostEvent::Packet(conn, packet) => { self.lan_guest_packet(&mut host, conn, *packet); @@ -1883,7 +1908,7 @@ impl Game<'_> { } } } - host + Some(host) } /// Handle this level's player packets before scheduled block ticks @@ -1940,7 +1965,12 @@ impl Game<'_> { events.extend(host.hub.drain_packets(&mut ctx)); events }; - host = self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn); + let Some(back) = + self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn) + else { + return; + }; + host = back; if !host.quiet.is_empty() { self.lan = Some(host); self.run_quiet_commands(); @@ -2025,6 +2055,9 @@ impl Game<'_> { for p in &mut self.creatures.projectiles { p.needs_sync = false; } + for d in &mut self.dropped { + d.needs_sync = false; + } // `runBlockEvents`' broadcast precedes the tick's block changes // (`ChunkHolder.broadcastChanges` in `tick_level` below): the // guest re-runs a piston's `triggerEvent` while its level still @@ -2102,7 +2135,11 @@ impl Game<'_> { host.hub.set_host_game_mode(state.uuid, state.game_mode); } self.wake_woken_guests(&mut host); - host = self.lan_process_events(host, events, state.as_ref(), entities, spawn); + let Some(back) = self.lan_process_events(host, events, state.as_ref(), entities, spawn) + else { + return; + }; + host = back; host.states = states; } if !host.quiet.is_empty() { @@ -2181,19 +2218,28 @@ impl Game<'_> { let force = host.hub.config.force_game_mode; let root = self.world_root.clone(); let in_place = host.hub.config.dimension.clone(); - let owner = host.owner.as_mut(); + let mut owner = host.owner.as_mut(); let Some(g) = host.hub.guest_mut(conn) else { return; }; - let saved = owner - .filter(|o| o.uuid == g.uuid) - .and_then(|o| o.saved.take()); - let known = match saved { - Some(level) => { - playerdata::apply(g, &level); - level.position.is_some() + let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid); + // PlayerDataStorage.load: the player's own file first; the world's + // own player state is the fallback for a first join. + let known = if is_owner && playerdata::load(g, &root) { + true + } else { + let saved = if is_owner { + owner.as_mut().and_then(|o| o.saved.take()) + } else { + None + }; + match saved { + Some(level) => { + playerdata::apply(g, &level); + level.position.is_some() + } + None => playerdata::load(g, &root), } - None => playerdata::load(g, &root), }; if !known || force { g.game_mode = default_mode; @@ -2339,6 +2385,50 @@ impl Game<'_> { self.fox_eat_particles(id as u32); } } + /// `LivingEntity.onEquippedItemBroken` for the acting player (the guest whose turn it + /// is, else the host): `level.broadcastEntityEvent` with `entityEventForEquipmentBreak` + /// of the slot, which each client answers with `breakItem` (the sound and the crumbs, + /// `remote_equipment_break`). Without a hub to tell, or in a window that runs the world + /// itself, the sound and the crumbs are played here. + pub(super) fn player_equipment_broke( + &mut self, + slot: horizon_world::player_inventory::EquipmentSlot, + stack: &horizon_client::items::ItemStack, + ) { + use horizon_world::player_inventory::EquipmentSlot; + let event = match slot { + EquipmentSlot::MainHand => 47, + EquipmentSlot::OffHand => 48, + EquipmentSlot::Head => 49, + EquipmentSlot::Chest => 50, + EquipmentSlot::Legs => 51, + EquipmentSlot::Feet => 52, + EquipmentSlot::Body => 65, + EquipmentSlot::Saddle => 68, + }; + let Some(at) = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]) + else { + return; + }; + let told = if self.lan_acting.is_some() { + self.guest_equipment_breaks.push((event, at)); + true + } else if let Some(host) = &self.lan { + host.hub.entity_event(host.host_entity, event, at); + true + } else { + false + }; + if told && !self.options.execution_role.is_client() { + return; + } + // LivingEntity.breakItem: the stack's break sound where it stands, then the crumbs. + self.break_sound_at(stack, at); + self.eat_particles(stack, 5); + } /// `ServerPlayer.crit` (or `magicCrit` when `magic`): this window's /// emitter, and an animation for the attacker's client and observers once /// the hub is back in place. diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index c4aa0f4bf5..1dec714ecf 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -6,7 +6,8 @@ //! host, and every world change they cause reaches all players through the //! block log, the level events and the entity tracker. use super::{ - Dim, Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig, + Dim, Game, GuestUse, Hit, InteractTarget, Mining, Screen, lan::LanHost, + respawn_anchor::RespawnConfig, }; use horizon_client::{ command::vanilla, @@ -206,6 +207,8 @@ pub(super) struct GuestSwap { packets: Vec, /// The entity events queued for that guest. entity_events: Vec, + /// The equipment breaks queued for that guest. + equipment_breaks: Vec<(i8, [f64; 3])>, /// The host's count of swings started, kept while the guest acts. swings: u32, /// How many projectiles there were: those added since are the guest's. @@ -400,6 +403,7 @@ impl Game<'_> { let host_combat = std::mem::replace(&mut self.combat, guest_combat); self.potions.swap_player(&mut g.effects, &mut play.potions); motion.position = Vec3::new(g.position[0], g.position[1], g.position[2]); + motion.entity_id = g.entity_id; motion.ground.on_ground = g.on_ground; motion.yaw = g.yaw; motion.pitch = g.pitch; @@ -519,6 +523,7 @@ impl Game<'_> { ); let packets = std::mem::take(&mut self.guest_packets); let entity_events = std::mem::take(&mut self.guest_entity_events); + let equipment_breaks = std::mem::take(&mut self.guest_equipment_breaks); let swings = self.swings_started; let projectiles = self.creatures.projectiles.len(); let modifiers = std::mem::replace( @@ -594,6 +599,7 @@ impl Game<'_> { known_movement, packets, entity_events, + equipment_breaks, swings, projectiles, modifiers, @@ -669,6 +675,7 @@ impl Game<'_> { known_movement, packets, entity_events, + equipment_breaks, swings, projectiles, modifiers, @@ -703,6 +710,10 @@ impl Game<'_> { }), ); } + // LivingEntity.onEquippedItemBroken: `broadcastEntityEvent`, the guest too. + for (event, at) in std::mem::replace(&mut self.guest_equipment_breaks, equipment_breaks) { + host.hub.entity_event(entity_id, event, at); + } // Player.playSound: everyone near but the guest, in the players' source. let players = horizon_client::settings::SOUND_SOURCES .iter() @@ -1771,10 +1782,15 @@ impl Game<'_> { .is_some() { // handleInteract: the entity and that hand only; a click the - // entity passes comes back as UseItem. + // entity passes comes back as UseItem. The entity is the + // packet's own id, which the range check above passed. let hand = if p.hand == 1 { Hand::Off } else { Hand::Main }; + let target = InteractTarget { + id: p.id, + location: p.location, + }; let swung = self.as_guest(host, conn, |g| { - g.guest_use = Some(GuestUse::Entity(hand)); + g.guest_use = Some(GuestUse::Entity(hand, target)); let swings = g.swings_started; let _ = g.use_item(); g.guest_use = None; @@ -2538,11 +2554,18 @@ impl Game<'_> { .creatures .projectiles .iter() - .position(|p| p.id != 0 && p.id as i32 == id && super::deflect::redirectable(p)) + .position(|p| p.id != 0 && p.id as i32 == id && super::deflect::attackable(p)) { self.as_guest(host, conn, |g| { if g.within_interaction_range(i) { - g.deflect(i); + if matches!( + g.creatures.projectiles[i].kind, + horizon_client::projectiles::ProjectileKind::ShulkerBullet { .. } + ) { + g.hit_shulker_bullet(i); + } else { + g.deflect(i); + } } }); return; @@ -4005,7 +4028,7 @@ impl Game<'_> { let rest = self.as_guest(host, conn, |g| g.give(stack)).flatten(); match rest { None => { - let item = self.dropped.remove(i); + let item = self.remove_dropped(i); if let Some(thrower) = thrower { self.as_guest(host, conn, |g| { g.trigger_thrown_picked_up_by_player(&item.stack, thrower); @@ -4150,7 +4173,7 @@ impl Game<'_> { // A merged orb gives its value once per count and goes at zero. let orb = self.orbs[touching[pick]].clone(); if orb.count <= 1 { - self.orbs.remove(touching[pick]); + self.remove_orb(touching[pick]); } else { self.orbs[touching[pick]].count -= 1; } diff --git a/crates/horizon-client/src/game/lan_chat.rs b/crates/horizon-client/src/game/lan_chat.rs index 3b8c7f25dd..d368ffff02 100644 --- a/crates/horizon-client/src/game/lan_chat.rs +++ b/crates/horizon-client/src/game/lan_chat.rs @@ -4,7 +4,7 @@ //! (SayCommand, EmoteCommands, MsgCommand), relayed by the hub as chat. use super::{Game, lan::LanHost}; use horizon_client::{ - command::{Arg, ArgType, NodeKind, ParseResults, vanilla}, + command::{Arg, ParseResults, vanilla}, lan::{ data::component_nbt, hub::{HostEvent, chat::ChatMessage}, @@ -33,25 +33,8 @@ impl Game<'_> { permission: u8, command: &str, ) -> Vec<(String, String)> { - let parse = self.parse_as(permission, command); - let nodes = &vanilla::game_dispatcher().nodes; - let root = parse.context.root; - let mut out = Vec::new(); - let mut context = Some(&parse.context); - while let Some(c) = context { - for &(node, (start, end)) in &c.nodes { - if let NodeKind::Argument { - ty: ArgType::Message, - name, - } = &nodes[node].kind - && let Some(text) = command.get(start..end) - { - out.push((name.clone(), text.to_owned())); - } - } - context = c.child.as_deref().filter(|child| child.root != root); - } - out + self.parse_as(permission, command) + .signable_arguments(vanilla::game_dispatcher()) } /// ServerGamePacketListenerImpl.detectRateSpam: a chat message or command adds 20 to the diff --git a/crates/horizon-client/src/game/lan_crafting.rs b/crates/horizon-client/src/game/lan_crafting.rs index fde2aee5d0..506f5f1ab3 100644 --- a/crates/horizon-client/src/game/lan_crafting.rs +++ b/crates/horizon-client/src/game/lan_crafting.rs @@ -373,9 +373,10 @@ impl Game<'_> { if g.spectator() { return; } - let Some(id) = self - .res - .stations() + // The server menu uses the world's active data-pack tables, the same + // tables used by the guest menu below. The base resources can have a + // different display registry or placement for an overridden recipe. + let Some(id) = world_stations!(self) .recipe_index .recipe_of_display(p.recipe) .map(str::to_owned) @@ -384,9 +385,7 @@ impl Game<'_> { }; // An unknown or impossible recipe is refused outright. if !g.recipe_book.knows(&id) - || self - .res - .recipes() + || world_recipes!(self) .placement(&id) .is_none_or(|placement| placement.impossible()) { diff --git a/crates/horizon-client/src/game/lan_pvp.rs b/crates/horizon-client/src/game/lan_pvp.rs index 50c098ff1e..e568e7968e 100644 --- a/crates/horizon-client/src/game/lan_pvp.rs +++ b/crates/horizon-client/src/game/lan_pvp.rs @@ -60,6 +60,9 @@ pub(super) struct Strike { knockback_attack: bool, /// The attacker's team, for the victim's `Player.canHarmPlayer`. team: Option, + /// The attacker's main-hand item with a custom name, for the death + /// message's `.item` form. + item: Option, } /// A sweep attack's reach to the other players (`Player.doSweepAttack`), @@ -197,6 +200,7 @@ impl Game<'_> { knockback: (strength, xd, zd), knockback_attack, team: self.player_team().cloned(), + item: self.main_hand_custom_name(), }) } @@ -341,7 +345,7 @@ impl Game<'_> { let land = |g: &mut Self| { g.combat.player_attacker = Some((s.from, s.disable_blocking)); if let Some((id, name)) = attacker_note.clone() { - g.note_named_attacker("minecraft:player_attack", id as u32, name); + g.note_player_attacker("minecraft:player_attack", id as u32, name, s.item.clone()); } let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack"); g.combat.player_attacker = None; diff --git a/crates/horizon-client/src/game/lan_screen.rs b/crates/horizon-client/src/game/lan_screen.rs index a9ff5dce72..cb9031fd59 100644 --- a/crates/horizon-client/src/game/lan_screen.rs +++ b/crates/horizon-client/src/game/lan_screen.rs @@ -40,6 +40,8 @@ pub(super) struct WorldOptions { view: View, /// InWorldGameRulesScreen, while it is open. rules: Option, + focused_button: Option