diff --git a/Cargo.lock b/Cargo.lock index 50f9808fae..9d8302ca89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,6 +561,12 @@ dependencies = [ "typenum", ] +[[package]] +name = "cty" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35" + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1412,6 +1418,8 @@ name = "horizon-threads" version = "0.1.0" dependencies = [ "libc", + "libmimalloc-sys", + "mimalloc", "windows-sys 0.61.2", ] @@ -1787,6 +1795,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libmimalloc-sys" +version = "0.1.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9" +dependencies = [ + "cc", + "cty", +] + [[package]] name = "libredox" version = "0.1.25" @@ -1878,6 +1896,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mimalloc" +version = "0.1.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862" +dependencies = [ + "libmimalloc-sys", +] + [[package]] name = "minisign-verify" version = "0.3.0" diff --git a/README.md b/README.md index 8bbe6eba6a..596f5a95b1 100644 --- a/README.md +++ b/README.md @@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with ### Known issues -Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines. +Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines. ## Licence diff --git a/crates/horizon-client-core/src/registries.rs b/crates/horizon-client-core/src/registries.rs index d638637c39..6393e90369 100644 --- a/crates/horizon-client-core/src/registries.rs +++ b/crates/horizon-client-core/src/registries.rs @@ -28,6 +28,10 @@ pub struct Registries { /// Game rule name -> integer-valued. pub game_rules: Vec<(String, bool)>, pub players: Vec, + /// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat + /// completions the server sent (ClientboundCustomChatCompletionsPacket), + /// each once. + pub custom_completions: std::collections::BTreeSet, /// Scoreboard objective names, team names and tracked score holders. pub objectives: Vec, pub teams: Vec, @@ -87,7 +91,55 @@ pub struct RegistryIds { pub tags: Vec, } const EMPTY: &[String] = &[]; +/// ClientboundCustomChatCompletionsPacket.Action. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CustomCompletionAction { + Add, + Remove, + Set, +} +impl CustomCompletionAction { + /// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`, + /// so an unknown id is `ADD`. + pub fn from_id(id: i32) -> Self { + match id { + 1 => Self::Remove, + 2 => Self::Set, + _ => Self::Add, + } + } +} +/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds, +/// removes or replaces the extra chat completions. +pub fn modify_custom_completions( + set: &mut std::collections::BTreeSet, + action: CustomCompletionAction, + entries: &[String], +) { + match action { + CustomCompletionAction::Add => set.extend(entries.iter().cloned()), + CustomCompletionAction::Remove => { + for e in entries { + set.remove(e); + } + } + CustomCompletionAction::Set => { + set.clear(); + set.extend(entries.iter().cloned()); + } + } +} impl Registries { + /// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a + /// command) completes, the online players' names with the server's custom + /// completions, each once. + pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> { + self.players + .iter() + .chain(&self.custom_completions) + .map(String::as_str) + .collect() + } /// Fill what the game did not build itself from vanilla's exported /// registries (`command-registries.json`, written by /// `VanillaCommandParseOracle registries`): built-in registries such as diff --git a/crates/horizon-client-render/src/assets.rs b/crates/horizon-client-render/src/assets.rs index 3207ecf4c2..2bd22d9b52 100644 --- a/crates/horizon-client-render/src/assets.rs +++ b/crates/horizon-client-render/src/assets.rs @@ -192,8 +192,9 @@ impl Assets { pub fn reload(&self, root: &Path) -> Result { RenderAssets::load(root, self) } - /// [`Assets::reload`] for a server, which draws nothing: the models and - /// the sprites' layers, without the textures' pixels. + /// [`Assets::reload`] for a server, which draws nothing: the item models + /// and the sprites' layers, without the block models or the textures' + /// pixels. pub fn reload_for_server(&self, root: &Path) -> Result { RenderAssets::load_with(root, self, false) } @@ -202,14 +203,19 @@ impl RenderAssets { pub fn load(root: &Path, assets: &Assets) -> Result { Self::load_with(root, assets, true) } - fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result { + fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result { let registry = assets.registry; let mut sprites = crate::sprites::Sprites::with_disk_cache(root); for name in MATERIALS { sprites.insert(root, &format!("minecraft:block/{name}"))?; } let parsed = crate::model_library::ParsedModels::load(root); - let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?; + // A server draws no block: its models (about 24 MB baked) stay empty. + let models = if draws { + crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)? + } else { + crate::models::PreparedModels::default() + }; let model_library = parsed.into_item_library(); // Fluids are drawn by the fluid renderer, not block models; breaking // progress uses the destroy-stage overlays. @@ -226,7 +232,7 @@ impl RenderAssets { sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?; } sprites.save_disk_cache(); - if pixels { + if draws { sprites.finish(); } else { sprites.finish_without_pixels(); diff --git a/crates/horizon-client-render/src/audio.rs b/crates/horizon-client-render/src/audio.rs index c61812e45e..1a93200863 100644 --- a/crates/horizon-client-render/src/audio.rs +++ b/crates/horizon-client-render/src/audio.rs @@ -165,6 +165,8 @@ pub struct Audio { /// How many times each selection was stopped ("record *", "* minecraft:…", `*` for /// any), with or without an audio device, as `heard`. pub stopped: std::collections::BTreeMap, + /// The volume sliders' `SoundPreviewHandler`. + preview: crate::sound_preview::SoundPreview, } fn decode(path: &Path) -> Option<(Vec, u32)> { use symphonia::core::{ @@ -634,6 +636,7 @@ impl Audio { capture: None, heard: Default::default(), stopped: Default::default(), + preview: Default::default(), } } #[cfg(not(feature = "audio"))] @@ -817,6 +820,13 @@ impl Audio { false, ); } + /// `SoundPreviewHandler.preview`: a category volume slider moved to + /// `volume` on the title screen's options. + pub fn preview(&mut self, source: usize, volume: f32) { + let mut preview = std::mem::take(&mut self.preview); + preview.preview(self, source, volume); + self.preview = preview; + } /// `play` with the seed a sound packet carries. pub fn play_seeded( &mut self, diff --git a/crates/horizon-client-render/src/auth.rs b/crates/horizon-client-render/src/auth.rs index a86992735c..42099103c7 100644 --- a/crates/horizon-client-render/src/auth.rs +++ b/crates/horizon-client-render/src/auth.rs @@ -52,20 +52,20 @@ impl AuthSession { /// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}. pub fn parse(text: &str) -> Result { let v: serde_json::Value = - serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?; + serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?; let field = |k: &str| v.get(k).and_then(|s| s.as_str()); - let name = field("name").ok_or("the session file has no name")?; + let name = field("name").ok_or("the session has no name")?; if name.is_empty() || name.chars().count() > 16 { - return Err("the session file's name is not a valid player name".into()); + return Err("the session's name is not a valid player name".into()); } let uuid = field("uuid") .and_then(parse_uuid) - .ok_or("the session file has no valid uuid")?; + .ok_or("the session has no valid uuid")?; let access_token = field("access_token") .filter(|t| !t.is_empty()) - .ok_or("the session file has no access token")?; + .ok_or("the session has no access token")?; if field("user_type").is_some_and(|t| t != "msa") { - return Err("the session file is not a Microsoft account session".into()); + return Err("the session is not a Microsoft account session".into()); } Ok(Self { name: name.to_owned(), diff --git a/crates/horizon-client-render/src/chat.rs b/crates/horizon-client-render/src/chat.rs index ab6626a95b..8c34e388ac 100644 --- a/crates/horizon-client-render/src/chat.rs +++ b/crates/horizon-client-render/src/chat.rs @@ -929,7 +929,9 @@ impl ChatScreen { } else if !command.chars().all(char::is_whitespace) { let partial = &command[..cursor_b]; let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial)); - builder.suggest_matching(c.registries.players.iter().map(String::as_str)); + // ClientSuggestionProvider.getCustomTabSuggestions: the players + // and the server's custom chat completions. + builder.suggest_matching(c.registries.custom_tab_suggestions()); let (range, list) = merge_suggestions(partial, builder.out); self.awaiting = None; self.pending = Some((range.0, list)); diff --git a/crates/horizon-client-render/src/chat_tests.rs b/crates/horizon-client-render/src/chat_tests.rs index beec6cd818..c4a02cc0c4 100644 --- a/crates/horizon-client-render/src/chat_tests.rs +++ b/crates/horizon-client-render/src/chat_tests.rs @@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() { assert_eq!(screen.value(), "/coords"); } +#[test] +fn chat_completes_the_servers_custom_completions_with_the_players() { + // CommandSuggestions.updateCommandInfo completes chat from + // ClientSuggestionProvider.getCustomTabSuggestions: the online players + // and what ClientboundCustomChatCompletionsPacket added. + use crate::command::{CustomCompletionAction as Action, modify_custom_completions}; + let font = font(); + let m = Measure(&font); + let mut registries = Registries { + players: vec!["Steve".into()], + ..Registries::default() + }; + let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::>(); + let set = &mut registries.custom_completions; + modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"])); + modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"])); + modify_custom_completions(set, Action::Remove, &entries(&["Alex"])); + // An id past the last action is ADD (OutOfBoundsStrategy.ZERO). + modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"])); + assert_eq!( + registries + .custom_tab_suggestions() + .into_iter() + .collect::>(), + ["#trade", "Notch", "Steve", "Zuri"] + ); + let completed = |registries: &Registries, line: &str| { + let dispatcher = crate::command::vanilla::game_dispatcher(); + let c = Commands { + dispatcher, + registries, + }; + let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full()); + screen.type_text(&m, &c, line); + // Chat shows no list by itself: Tab shows it, Tab again uses it. + for _ in 0..2 { + screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false); + } + screen.value().to_owned() + }; + assert_eq!(completed(®istries, "hi no"), "hi Notch"); + assert_eq!(completed(®istries, "sell #tr"), "sell #trade"); + assert_eq!(completed(®istries, "hi st"), "hi Steve"); + assert_eq!(completed(®istries, "hi al"), "hi al"); + // SET replaces them all; the players stay. + let set = &mut registries.custom_completions; + modify_custom_completions(set, Action::Set, &entries(&["Alex"])); + assert_eq!(completed(®istries, "hi no"), "hi no"); + assert_eq!(completed(®istries, "hi al"), "hi Alex"); + assert_eq!(completed(®istries, "hi st"), "hi Steve"); +} + #[test] fn ask_server_nodes_wait_for_the_servers_completions() { // ClientSuggestionProvider.customSuggestion: an `ask_server` node at the diff --git a/crates/horizon-client-render/src/chunk.rs b/crates/horizon-client-render/src/chunk.rs index b8596d719e..e556418f20 100644 --- a/crates/horizon-client-render/src/chunk.rs +++ b/crates/horizon-client-render/src/chunk.rs @@ -160,6 +160,13 @@ impl ColumnLayout { self.min_y + index as i32 * 16 } } +/// `true` for each section of `layout`: what callers that keep no +/// per-section record (of fluids, say) pass as "every section may have it". +pub fn every_section(layout: ColumnLayout) -> &'static [bool] { + static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] = + [true; crate::section_mask::SectionMask::CAPACITY]; + &ALL[..layout.sections()] +} impl Default for ColumnLayout { fn default() -> Self { Self::VANILLA diff --git a/crates/horizon-client-render/src/collisions.rs b/crates/horizon-client-render/src/collisions.rs index d35a1adfa2..dba30917dd 100644 --- a/crates/horizon-client-render/src/collisions.rs +++ b/crates/horizon-client-render/src/collisions.rs @@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> { /// From the section's palette, without reading its blocks; a palette /// of the whole registry reads every cell. fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result { - let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4)) + let blocks = usize::try_from(section_y - (chunk.min_y() >> 4)) .ok() .and_then(|s| chunk.sections.get(s)) .map(|section| §ion.blocks); diff --git a/crates/horizon-client-render/src/crash.rs b/crates/horizon-client-render/src/crash.rs index b9bb4d5131..eb6e1bb87e 100644 --- a/crates/horizon-client-render/src/crash.rs +++ b/crates/horizon-client-render/src/crash.rs @@ -320,6 +320,10 @@ fn thread_name() -> String { } } +/// What starts the stderr line naming a saved report, the path follows: the Launch API's +/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API. +pub const SAVED_LINE: &str = "Horizon: crash report saved to "; + /// Writes the report and tells where it is; the log gets the error too. fn deliver(home: &Path, report: Report) -> Option { if REPORTED.swap(true, Ordering::SeqCst) { @@ -343,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option { crate::telemetry::keep_crash(home, &report); match report.save(home) { Ok(path) => { - eprintln!("Horizon: crash report saved to {}", path.display()); + eprintln!("{SAVED_LINE}{}", path.display()); Some(path) } Err(e) => { diff --git a/crates/horizon-client-render/src/gfx.rs b/crates/horizon-client-render/src/gfx.rs index 9fe2f75510..6623380232 100644 --- a/crates/horizon-client-render/src/gfx.rs +++ b/crates/horizon-client-render/src/gfx.rs @@ -590,6 +590,10 @@ pub struct Renderer { readback: readback::Readback, pub info: wgpu::AdapterInfo, pub suspended: bool, + /// Consecutive frames whose surface acquisition failed validation. The + /// first gets the swapchain a fresh configuration; later ones reattach + /// the window, as a lost surface does. + acquire_validation_failures: u32, pub drawn_sections: usize, /// Last frame's section draws (face ranges and translucent sections), /// and the draw calls the backend issued for them. @@ -792,9 +796,14 @@ fn mip_chain( /// pass is also what passes each draw's first instance to shaders reading /// `instance_index`, and DirectX 12 is the fallback, where working matters /// more than CPU time. +/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan +/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell, +/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second +/// try at a backend that had no adapter at all. fn instance_descriptor( backends: wgpu::Backends, window: Option<&Arc>, + allow_nonconformant: bool, ) -> wgpu::InstanceDescriptor { let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); if let Some(window) = window { @@ -807,7 +816,13 @@ fn instance_descriptor( .flags .remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL); } - descriptor.with_env() + let mut descriptor = descriptor.with_env(); + if allow_nonconformant { + descriptor + .flags + .insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER); + } + descriptor } /// The window's display connection, so the OpenGL backend creates its EGL /// display from the X11 or Wayland connection the window lives on. Without @@ -946,96 +961,147 @@ async fn open_device( if attempt > 0 { log::warn!("Horizon renderer: trying {name} ({})", failures.join("; ")); } - let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window))); - let surface = if offscreen { - None - } else { - match instance.create_surface(window.clone()) { - Ok(surface) => Some(surface), - Err(e) => { - failures.push(format!("{name} surface: {e}")); - continue; - } - } - }; - let adapter = match instance - .request_adapter(&wgpu::RequestAdapterOptions { - power_preference: wgpu::PowerPreference::HighPerformance, - compatible_surface: surface.as_ref(), - force_fallback_adapter: false, - ..Default::default() - }) - .await - { - Ok(adapter) => adapter, - Err(e) => { - failures.push(format!("{name}: {e}")); - continue; - } - }; - describe_adapter(&adapter); - let info = adapter.get_info(); - let limits = adapter.limits(); - if let Err(e) = check_layers(&limits, layers) { - failures.push(format!("{name}: {e}")); - continue; - } - // 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. - let features = timing::GpuTimer::features(&adapter) - | cull::Cull::features(&adapter) - | (adapter.features() - & (wgpu::Features::INDIRECT_FIRST_INSTANCE - | wgpu::Features::MAPPABLE_PRIMARY_BUFFERS - | wgpu::Features::TEXTURE_FORMAT_16BIT_NORM - | wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES - | wgpu::Features::RG11B10UFLOAT_RENDERABLE)); - let driver = format!("{} {}", info.driver, info.driver_info); - startup_step(format_args!( - "requesting the device (driver {}) with features [{features}] and the adapter's \ - limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)", - match driver.trim() { - "" => "unnamed", - driver => driver, - }, - limits.max_buffer_size, - limits.max_storage_buffer_binding_size, - limits.max_texture_array_layers, - )); - let desc = wgpu::DeviceDescriptor { - label: Some("Horizon device"), - required_features: features, - required_limits: limits, - ..Default::default() - }; - #[cfg(not(target_os = "macos"))] - let opened = match info.backend { - wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc), - _ => None, - }; - #[cfg(target_os = "macos")] - let opened = None; - let opened = match opened { - Some(opened) => opened, - None => adapter.request_device(&desc).await, - }; - match opened { - Ok((device, queue)) => { - return Ok(Opened { - instance, - surface, - adapter, - device, - queue, - }); - } - Err(e) => failures.push(format!("{name} device: {e}")), + match open_with_retry(backends, window, offscreen, layers).await { + Ok(opened) => return Ok(opened), + Err(e) => failures.push(e), } } Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into()) } +/// Why a backend gave no device: no adapter at all, which a second try with the +/// adapters wgpu hides can still find, or a failure after an adapter. +enum NoDevice { + NoAdapter(String), + Failed(String), +} +/// Opens a device on one backend, and on a backend whose adapters wgpu all hid +/// for reporting no Vulkan conformance version tries it again with them shown: +/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so +/// without this the player is told they have no graphics driver while Vulkan +/// runs (a `gl not compatible with provided surface` fallback, or nothing, is +/// all they get). Returns why the backend gave no device. +async fn open_with_retry( + backends: wgpu::Backends, + window: &Arc, + offscreen: bool, + layers: u32, +) -> std::result::Result { + match open_backend(backends, window, offscreen, false, layers).await { + Err(NoDevice::NoAdapter(hidden)) => { + log::warn!( + "Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \ + ones wgpu hides as non-conformant", + backends_name(backends) + ); + match open_backend(backends, window, offscreen, true, layers).await { + Ok(opened) => { + log::warn!( + "Horizon renderer: using {} although its driver reports no Vulkan \ + conformance version", + opened.adapter.get_info().name + ); + Ok(opened) + } + Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e), + } + } + Err(NoDevice::Failed(e)) => Err(e), + Ok(opened) => Ok(opened), + } +} +/// Opens a device on `backends` alone: its surface, adapter and device, or what +/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try. +async fn open_backend( + backends: wgpu::Backends, + window: &Arc, + offscreen: bool, + allow_nonconformant: bool, + layers: u32, +) -> std::result::Result { + let name = backends_name(backends); + let instance = wgpu::Instance::new(instance_descriptor( + backends, + Some(window), + allow_nonconformant, + )); + let surface = if offscreen { + None + } else { + match instance.create_surface(window.clone()) { + Ok(surface) => Some(surface), + Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))), + } + }; + let adapter = match instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: surface.as_ref(), + force_fallback_adapter: false, + ..Default::default() + }) + .await + { + Ok(adapter) => adapter, + Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))), + }; + describe_adapter(&adapter); + let info = adapter.get_info(); + let limits = adapter.limits(); + if let Err(e) = check_layers(&limits, layers) { + return Err(NoDevice::Failed(format!("{name}: {e}"))); + } + // 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. + let features = timing::GpuTimer::features(&adapter) + | cull::Cull::features(&adapter) + | (adapter.features() + & (wgpu::Features::INDIRECT_FIRST_INSTANCE + | wgpu::Features::MAPPABLE_PRIMARY_BUFFERS + | wgpu::Features::TEXTURE_FORMAT_16BIT_NORM + | wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES + | wgpu::Features::RG11B10UFLOAT_RENDERABLE)); + let driver = format!("{} {}", info.driver, info.driver_info); + startup_step(format_args!( + "requesting the device (driver {}) with features [{features}] and the adapter's \ + limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)", + match driver.trim() { + "" => "unnamed", + driver => driver, + }, + limits.max_buffer_size, + limits.max_storage_buffer_binding_size, + limits.max_texture_array_layers, + )); + let desc = wgpu::DeviceDescriptor { + label: Some("Horizon device"), + required_features: features, + required_limits: limits, + ..Default::default() + }; + #[cfg(not(target_os = "macos"))] + let opened = match info.backend { + wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc), + _ => None, + }; + #[cfg(target_os = "macos")] + let opened = None; + let opened = match opened { + Some(opened) => opened, + None => adapter.request_device(&desc).await, + }; + match opened { + Ok((device, queue)) => Ok(Opened { + instance, + surface, + adapter, + device, + queue, + }), + Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))), + } +} /// What to tell a player whose system offers no usable graphics adapter. /// Vulkan is loaded at run time (the game links no graphics library), so a /// missing driver shows here rather than as a missing-DLL dialog. @@ -2530,10 +2596,19 @@ impl Renderer { step_mode: wgpu::VertexStepMode::Vertex, attributes: &TERRAIN_ATTRIBUTES, })]; + let info = adapter.get_info(); + let min_sections = if flat::is_broken_intel_vulkan(&info) + || std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") + { + 0 + } else { + flat::MIN_SECTIONS + }; flat::Flat::new( &device, &bindings, arena.page_vertices(), + min_sections, |layout, label, fragment, blend| { pipeline_in( layout, @@ -2764,6 +2839,7 @@ impl Renderer { readback: readback::Readback::default(), info, suspended: !drawable_size(size.width, size.height, max_side), + acquire_validation_failures: 0, drawn_sections: 0, section_draws: 0, section_draw_calls: 0, @@ -3765,9 +3841,32 @@ impl Renderer { return Ok(false); } Some(wgpu::CurrentSurfaceTexture::Validation) => { - return Err("surface acquisition failed validation".into()); + // Acquire caught a validation error: the swapchain cannot be + // used as configured (wgpu reports a configuration raced with + // a submission this way). Give it a fresh configuration like + // an Outdated one, and once that has not helped, reattach the + // window like a Lost one — a fullscreen loading screen then + // skips frames instead of failing every one of them. + if self.acquire_validation_failures == 0 { + if let Some(surface) = &self.surface { + Self::configure( + &self.device, + surface, + &self.surface_config(), + self.hdr.as_ref(), + ); + } + } else { + self.recreate_surface()?; + } + self.acquire_validation_failures = + self.acquire_validation_failures.saturating_add(1); + return Ok(false); } }; + if surface_frame.is_some() { + self.acquire_validation_failures = 0; + } let size = [self.config.width, self.config.height]; let target_texture = match (&surface_frame, &mut self.hdr) { (Some(_), Some(hdr)) => hdr.frame(&self.device, size), @@ -5947,6 +6046,23 @@ mod tests { assert_eq!(order(api, "macos"), [Backends::default()]); } } + /// The second try at a backend shows the adapters wgpu hides for reporting + /// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and + /// Haswell, reports 0.0.0.0 and works). + #[test] + fn the_second_try_at_a_backend_shows_nonconformant_adapters() { + let shown = |allow| { + super::instance_descriptor(wgpu::Backends::VULKAN, None, allow) + .flags + .contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER) + }; + assert!(shown(true)); + // The first try keeps wgpu's own filtering, unless `with_env` turns the + // flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER). + if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() { + assert!(!shown(false)); + } + } /// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend /// writes it for FXC (shader model 5.1), entry point by entry point. #[test] @@ -6102,7 +6218,7 @@ mod tests { #[test] fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() { let validated = |backends| { - super::instance_descriptor(backends, None) + super::instance_descriptor(backends, None, false) .flags .contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL) }; diff --git a/crates/horizon-client-render/src/gfx/animate.rs b/crates/horizon-client-render/src/gfx/animate.rs index 96427cca34..e6eb9dd5b7 100644 --- a/crates/horizon-client-render/src/gfx/animate.rs +++ b/crates/horizon-client-render/src/gfx/animate.rs @@ -147,7 +147,7 @@ impl SpriteAnimator { group, }); } - let Some(pipeline) = pipeline else { + let Some((pipeline, layout)) = pipeline else { let groups = keys .iter() .zip(&counts) @@ -193,7 +193,6 @@ impl SpriteAnimator { usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false, }); - let layout = pipeline.get_bind_group_layout(0); let groups = keys .iter() .zip(&counts) @@ -364,11 +363,15 @@ impl SpriteAnimator { } } -/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL -/// driver can list compute shaders among its extensions while wgpu writes -/// the GLSL version of its core version, which has none (Mesa's -/// `MESA_GL_VERSION_OVERRIDE`, older drivers). -fn pipeline(device: &wgpu::Device) -> Option { +/// `animate.wgsl`'s pipeline and the bind group layout of its group 0, +/// unless the device fails to make the pipeline: an OpenGL driver can list +/// compute shaders among its extensions while wgpu writes the GLSL version +/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`, +/// older drivers). A device lost between the scopes' pushes answers pipeline +/// creation with a silent placeholder and reports its invalidity only when +/// the layout is asked for — inside the scopes that error is caught, outside +/// them it is uncaptured and panics (#3866). +fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> { let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); let internal = device.push_error_scope(wgpu::ErrorFilter::Internal); let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { @@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option { compilation_options: Default::default(), cache: None, }); + let layout = pipeline.get_bind_group_layout(0); let internal = pollster::block_on(internal.pop()); match internal.or(pollster::block_on(validation.pop())) { - None => Some(pipeline), + None => Some((pipeline, layout)), Some(e) => { log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})"); None diff --git a/crates/horizon-client-render/src/gfx/flat.rs b/crates/horizon-client-render/src/gfx/flat.rs index 5d86888da4..db41f4f041 100644 --- a/crates/horizon-client-render/src/gfx/flat.rs +++ b/crates/horizon-client-render/src/gfx/flat.rs @@ -115,7 +115,7 @@ pub struct Flat { /// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they /// were 2.2 to 2.5 per section; a section's draw count now depends on the /// sides it shows the eye (`mesher::Sides`), so it counts sections. -const MIN_SECTIONS: usize = 1600; +pub const MIN_SECTIONS: usize = 1600; /// Whether flat draws apply: the backend emulates multi-draw, and vertex /// shaders can read storage buffers large enough for an index page. @@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool { Ok("1") => return supported(adapter, device), _ => {} } - adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device) + let info = adapter.get_info(); + (info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info)) + && supported(adapter, device) +} + +/// 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. +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)) } fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool { @@ -254,6 +267,7 @@ impl Flat { device: &wgpu::Device, terrain_layout: &wgpu::BindGroupLayout, page_vertices: u64, + min_sections: usize, pipeline: impl Fn( &wgpu::PipelineLayout, &str, @@ -357,11 +371,7 @@ impl Flat { phase1_calls: 0, solid_calls: 0, shadow_calls: [0; 2], - min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") { - 0 - } else { - MIN_SECTIONS - }, + min_sections, range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16) .max(1), } diff --git a/crates/horizon-client-render/src/gfx/upload.rs b/crates/horizon-client-render/src/gfx/upload.rs index 0d56c73a03..ec6bf9271c 100644 --- a/crates/horizon-client-render/src/gfx/upload.rs +++ b/crates/horizon-client-render/src/gfx/upload.rs @@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20; pub struct Uploads { belt: wgpu::util::StagingBelt, + chunk: u64, } impl Uploads { pub fn new(device: &wgpu::Device) -> Self { Self::with_chunk(device, CHUNK) } - /// A belt whose staging buffers hold `chunk` bytes (larger writes get - /// a buffer of their own). + /// A belt whose staging buffers hold `chunk` bytes (a larger write gets + /// a buffer of its own, rounded up to a power of two). pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self { Self { belt: wgpu::util::StagingBelt::new(device.clone(), chunk), + chunk, } } + /// A mapped staging slice of at least `size` bytes. The belt gives a + /// write larger than its chunk a buffer of exactly that size, keeps it for + /// good and only reuses it for writes that fit: an upload that grows a + /// little every frame (the entity batch while mobs appear) took a new + /// buffer each frame until the game ran out of memory (#2826). Rounded up + /// to a power of two, the large buffers it keeps are reused, one per + /// doubling. + fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> { + let size = if size.get() > self.chunk { + size.checked_next_power_of_two().unwrap_or(size) + } else { + size + }; + self.belt.allocate(size, alignment) + } /// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip /// level of one array layer, padding rows to the copy alignment. pub fn texture( @@ -41,7 +58,7 @@ impl Uploads { }; let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)) .expect("non-zero alignment"); - let slice = self.belt.allocate(size, alignment); + let slice = self.allocate(size, alignment); { let mut view = slice .get_mapped_range_mut() @@ -80,8 +97,20 @@ impl Uploads { let Some(size) = NonZeroU64::new(bytes.len() as u64) else { return; }; - self.belt - .write_buffer(encoder, target, offset, size) + // The belt's own `write_buffer` checks, kept now that a large write's + // slice is rounded up past them. + assert!( + offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT) + && size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT), + "buffer upload at {offset} of {size} bytes must be 4-byte aligned" + ); + let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment"); + let slice = self.allocate(size, alignment).slice(..size.get()); + encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get()); + slice + .get_mapped_range_mut() + .expect("staging belt slices are mapped") + .slice(..) .copy_from_slice(bytes); } /// Close this frame's staging buffers: they come back for reuse once the @@ -217,6 +246,80 @@ mod tests { } } + /// Staging buffers the belt holds, once every submitted frame is done + /// with them. The belt only reports its buffers through `Debug`. + fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize { + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + // Finishing an empty frame takes back every recalled buffer. + uploads.finish(&device.create_command_encoder(&Default::default())); + let belt = format!("{:?}", uploads.belt); + let count = |field: &str| -> usize { + let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2; + let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next(); + digits.and_then(|d| d.parse().ok()).expect(&belt) + }; + count("active_chunks") + count("closed_chunks") + count("free_chunks") + } + + /// #2826: a write larger than the chunk that grows a little every frame + /// (the entity batch while mobs appear) got a new exact-size buffer each + /// frame, all of them kept, until the game ran out of memory. + #[test] + fn growing_large_writes_reuse_the_belt_buffers() { + let Some((device, queue)) = device() else { + eprintln!("no GPU adapter: skipped"); + return; + }; + const CHUNK: u64 = 1 << 16; + const TARGET: usize = 4 * CHUNK as usize; + let target = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: TARGET as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: TARGET as u64, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut uploads = Uploads::with_chunk(&device, CHUNK); + let mut expected = vec![0u8; TARGET]; + let frames = 64; + for frame in 0..frames { + let mut encoder = device.create_command_encoder(&Default::default()); + // One small write, then the large one, 1.5 to 3.5 chunks long. + uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]); + expected[TARGET - 4..].fill(9); + let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4); + let bytes: Vec = (0..len).map(|i| (i + frame) as u8).collect(); + uploads.buffer(&mut encoder, &target, 0, &bytes); + expected[..len].copy_from_slice(&bytes); + encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64); + uploads.finish(&encoder); + queue.submit([encoder.finish()]); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, |r| r.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + // Only the written bytes land: the rest of the target is untouched. + assert!( + *readback.slice(..).get_mapped_range().unwrap() == expected[..], + "frame {frame}" + ); + readback.unmap(); + } + // A chunk for the small writes and a buffer per doubling of the large + // one (2 and 4 chunks), maybe one more where a small write took the + // start of a buffer the large one needed whole: not one per frame. + let kept = kept_buffers(&device, &mut uploads); + assert!( + kept <= 4, + "the belt kept {kept} buffers over {frames} frames" + ); + } + #[test] fn belt_buffer_writes_land_in_order_at_their_offsets() { let Some((device, queue)) = device() else { diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index 0f610d282f..dc3512aca3 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -71,6 +71,7 @@ pub mod signs; pub mod skins; pub mod sound_engine; pub mod sound_events; +pub mod sound_preview; pub mod spans; pub mod split_layout; mod sprite_cache; diff --git a/crates/horizon-client-render/src/light.rs b/crates/horizon-client-render/src/light.rs index 3b06b4e245..8262b3c848 100644 --- a/crates/horizon-client-render/src/light.rs +++ b/crates/horizon-client-render/src/light.rs @@ -856,6 +856,23 @@ pub fn relight( relight_region(&mut region, tables, sky_light, positions, force, changed); region.finish(); } +/// The columns `changed` touched that border a loaded column not lit yet +/// (`unlit`). `relight` reads and writes only lit columns, so an edit's +/// darkening can take away light such a neighbour sends in and never put it +/// back, and nothing relights the column when that neighbour's own light +/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine +/// keeps one light storage for every chunk, so its edits and its chunk +/// lighting see each other. These columns need a full light job, which +/// lights them from the blocks of their whole 3x3, that neighbour's included. +pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> { + let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect(); + columns.sort_unstable(); + columns.dedup(); + columns.retain(|&(x, z)| { + (-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz))) + }); + columns +} fn relight_region( world: &mut Region, tables: &LightTables, @@ -1109,6 +1126,70 @@ mod tests { // Without the shape test the room would be lit (slabs have no dampening). assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light"); } + /// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0) + /// was lit from the 3x3's blocks, its torch's light and the far torch's + /// in (1, 0) both. Taking its torch away darkens everything the torch + /// lit, the far torch's light at the border too, and `relight` cannot + /// bring that back from a column it doesn't read. Columns like that are + /// lit again whole; without it the border stays at 0 for good once + /// (1, 0)'s light lands beside it. + #[test] + fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() { + let tables = tables(); + let mut w: HashMap<(i32, i32), Arc> = HashMap::new(); + for z in -1..=1 { + for x in -1..=1 { + let mut c = Chunk::empty(x, z); + for y in MIN_Y..0 { + for i in 0..256 { + c.set(i & 15, y, i >> 4, STONE); + } + } + if (x, z) == (0, 0) { + c.set(14, 0, 8, TORCH); + } else if (x, z) == (1, 0) { + c.set(6, 0, 8, TORCH); + } + w.insert((x, z), Arc::new(c)); + } + } + let light = |w: &HashMap<(i32, i32), Arc>, (x, z): (i32, i32)| { + let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| { + std::array::from_fn(|dx| { + w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c) + }) + }); + light_chunk(®ion, &tables, true) + }; + let keys: Vec<_> = w.keys().copied().collect(); + for key in &keys { + let lights = light(&w, *key); + let c = Arc::make_mut(w.get_mut(key).unwrap()); + apply_lighting(c, lights); + c.lit = *key != (1, 0); + } + // The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13. + assert_eq!(block(&w, 15, 0, 8), 13); + + Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR); + let mut changed = LightChanges::default(); + relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed); + let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit)); + for &key in &again { + let lights = light(&w, key); + let c = Arc::make_mut(w.get_mut(&key).unwrap()); + apply_relighting(c, lights, &mut LightChanges::default()); + } + // (1, 0)'s own job lands. + let lights = light(&w, (1, 0)); + let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap()); + apply_lighting(c, lights); + c.lit = true; + assert_eq!(block(&w, 16, 0, 8), 8); + assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border"); + assert_eq!(block(&w, 14, 0, 8), 6); + assert!(again.contains(&(0, 0)), "{again:?}"); + } #[test] fn relight_keeps_unchanged_and_unlit_sections() { let mut w = world(|_| {}); diff --git a/crates/horizon-client-render/src/lod.rs b/crates/horizon-client-render/src/lod.rs index f2c8882266..1b7bbb7f2f 100644 --- a/crates/horizon-client-render/src/lod.rs +++ b/crates/horizon-client-render/src/lod.rs @@ -186,8 +186,15 @@ impl Column { self.push(span); } } - /// `kinds`: `state_kinds`, per block state. - fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self { + /// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`, + /// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as. + fn from_chunk( + chunk: &Chunk, + kinds: &[u8], + water: Option, + x: i32, + z: i32, + ) -> Self { let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0); let face_light = |y: i32| { let mut sky = 0; @@ -222,11 +229,17 @@ impl Column { let step = if uniform { 16 } else { 1 }; for offset in (0..16).step_by(step) { let y = min + offset; - let state = BlockStateId(chunk.get(x, y, z)); - let kind = kind(state); + let mut state = BlockStateId(chunk.get(x, y, z)); + let mut kind = kind(state); if kind & KEPT == 0 { continue; } + if kind & WATERY != 0 + && let Some(water) = water + { + state = water; + kind = kinds[water.0 as usize]; + } let light = face_light(y); column.push(Span { min: y as i16, @@ -269,15 +282,28 @@ impl Column { } /// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker. fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> { + let water = water_source(kinds); Arc::new(std::array::from_fn(|i| { - Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32) + Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32) })) } -/// `state_kinds` bits: kept in columns, a fluid, the air state; the light -/// emission is in the high nibble. +/// The water source state: the first plain kept fluid without light +/// (`minecraft:water` comes before lava, and its level 0 first). +fn water_source(kinds: &[u8]) -> Option { + kinds + .iter() + .position(|&kind| kind == KEPT | FLUID) + .map(|i| BlockStateId(i as u32)) +} +/// `state_kinds` bits: kept in columns, a fluid, the air state, a block +/// holding water; the light emission is in the high nibble. const KEPT: u8 = 1; const FLUID: u8 = 2; const AIR: u8 = 4; +/// A block holding water that draws as none (kelp, seagrass, bubble columns, +/// waterlogged blocks that aren't kept): far terrain draws water there, or +/// kelp reaching the surface leaves holes in the sea. +const WATERY: u8 = 8; /// What `Column::from_chunk` needs of each block state, read from the facts /// once: matching their names for every block made observing a chunk slow. fn state_kinds(facts: &BlockFacts) -> Vec { @@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec { || f.full_collision || f.name.ends_with("_leaves") || chorus - || fluid); - (u8::from(kept) * KEPT) - | (u8::from(fluid) * FLUID) + || fluid + || nearly_full(&f.name)); + let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water; + (u8::from(kept || watery) * KEPT) + | (u8::from(fluid || watery) * FLUID) + | (u8::from(watery) * WATERY) | (u8::from(state == facts.air) * AIR) | (f.emission.min(15) << 4) }) .collect() } +/// Blocks that fill their cell but neither draw solid nor collide as a full +/// block: far terrain draws them as blocks, or patches of powder snow leave +/// holes in mountains (and paths and fields dips). +fn nearly_full(name: &str) -> bool { + matches!( + name, + "minecraft:powder_snow" + | "minecraft:dirt_path" + | "minecraft:farmland" + | "minecraft:honey_block" + ) || name.ends_with("_stairs") +} #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct TileKey { pub x: i32, @@ -1085,6 +1126,16 @@ impl Lod { }) .copied() .collect(); + // Retiring fine columns are empty stand-ins, excluded from `active` + // while their normal meshes still cover the gap. Request their detail + // explicitly: neither the active-tile requests nor sampling an already + // ready stand-in can fill it, so its GPU sections would never retire. + keys.extend(retiring_detail( + &self.plan, + &self.retiring, + &self.saved, + &self.detail_masks, + )); keys.sort_unstable_by_key(|key| (key.distance(camera), *key)); let mut chunks = FxHashSet::default(); for key in keys { @@ -1589,11 +1640,84 @@ impl Drop for Lod { } } } +/// Fine retiring stand-ins need actual column detail before `Plan::replaces` +/// can release their normal meshes. The caller keeps the usual 32-column +/// request budget and waits for the replacement mesh before changing cutout. +fn retiring_detail<'a>( + plan: &'a Plan, + retiring: &'a FxHashSet<(i32, i32)>, + saved: &'a Saved, + masks: &'a FxHashMap, +) -> impl Iterator + 'a { + retiring.iter().filter_map(move |&(x, z)| { + let key = TileKey { x, z, level: 0 }; + (plan.nodes.contains(&key) + && !saved.contains_key(&(x, z)) + && masks.get(&key) != Some(&[u64::MAX; 4])) + .then_some(key) + }) +} #[cfg(test)] mod tests { use super::*; use crate::mesher::TerrainVertex; #[test] + fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() { + let column = (12, 0); + let fine = TileKey { + x: 12, + z: 0, + level: 0, + }; + let mut plan = Plan::default(); + plan.nodes.insert(fine); + let ready: FxHashMap = [(fine, ())].into_iter().collect(); + let retiring = [column].into_iter().collect(); + let saved = Saved::default(); + let mut masks = FxHashMap::default(); + // A covered stand-in is ready, but empty: ordinary sampling skips it, + // and selection leaves it out of `active`. It cannot yet replace the + // old full-detail column, even though the near world has unloaded it. + assert!(!plan.replaces(column, &ready, |key| masks.get(&key) + == Some(&[u64::MAX; 4]))); + assert_eq!( + retiring_detail(&plan, &retiring, &saved, &masks).collect::>(), + [fine] + ); + // The existing RequestDetail -> apply_fine path marks complete detail; + // only then may selection retire it and publish the new mesh cutout. + masks.insert(fine, [u64::MAX; 4]); + assert!( + retiring_detail(&plan, &retiring, &saved, &masks) + .next() + .is_none() + ); + assert!(plan.replaces(column, &ready, |key| masks.get(&key) + == Some(&[u64::MAX; 4]))); + } + #[test] + fn retiring_detail_requests_only_planned_columns_still_missing_detail() { + let fine = |x| TileKey { x, z: 0, level: 0 }; + let mut plan = Plan::default(); + plan.nodes.extend((0..4).map(fine)); + plan.nodes.insert(fine(5)); + let retiring = (0..5).map(|x| (x, 0)).collect(); + let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))] + .into_iter() + .collect(); + let masks = [ + (fine(2), [u64::MAX; 4]), + (fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]), + ] + .into_iter() + .collect(); + let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect(); + requested.sort_unstable(); + // Saved or complete detail needs no request; an unplanned retiree and + // a planned column that is not retiring must not consume the budget. + assert_eq!(requested, [fine(0), fine(3)]); + } + #[test] fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() { let leaves = |plan: &Plan| { plan.nodes @@ -1840,6 +1964,40 @@ mod tests { } } #[test] + fn nearly_full_blocks_are_kept() { + assert!(nearly_full("minecraft:powder_snow")); + assert!(nearly_full("minecraft:dirt_path")); + assert!(nearly_full("minecraft:oak_stairs")); + assert!(!nearly_full("minecraft:oak_fence")); + assert!(!nearly_full("minecraft:wheat")); + } + #[test] + fn kelp_to_the_surface_is_drawn_as_water() { + // 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none). + let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY]; + let mut chunk = Chunk::empty(0, 0); + for x in [3, 4] { + for y in MIN_Y..40 { + chunk.set(x, y, 3, 1); + } + } + for y in 40..63 { + chunk.set(3, y, 3, 3); + chunk.set(4, y, 3, 2); + } + let water = water_source(&kinds); + assert_eq!(water, Some(BlockStateId(2))); + let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3); + let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3); + assert_eq!(kelp.spans, sea.spans); + assert_eq!( + kelp.spans.last().map(|s| (s.min, s.max, s.state)), + Some((40, 63, BlockStateId(2))) + ); + assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2)))); + assert_eq!(kelp.surface.unwrap().height, 40); + } + #[test] fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() { let span = |min, max, state| Span { min, @@ -2035,6 +2193,24 @@ mod tests { chunk.set(8, 71, 8, chorus.0); chunk.set(8, 72, 8, chorus.0); chunk.set(8, 73, 8, flower.0); + // Kelp to the surface is water; powder snow and a waterlogged stair + // are kept as themselves. + let state = |name: &str| assets.registry.default_state(name).unwrap(); + let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow")); + let stair = facts + .states + .iter() + .position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged) + .map(|i| BlockStateId(i as u32)) + .unwrap(); + chunk.set(2, 54, 2, stone.0); + for y in 55..64 { + chunk.set(2, y, 2, kelp.0); + } + chunk.set(6, 90, 6, stone.0); + chunk.set(6, 91, 6, powder_snow.0); + chunk.set(10, 54, 10, stone.0); + chunk.set(10, 55, 10, stair.0); lod.observe(Arc::new(chunk.clone()), &facts); receive_observed(&mut lod); let observed = &lod.saved[&(-1, -1)]; @@ -2052,6 +2228,12 @@ mod tests { .collect::>(), vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)] ); + let kelp_column = &observed[2 * 16 + 2]; + assert_eq!(kelp_column.surface.unwrap().height, 55); + assert_eq!(kelp_column.surface.unwrap().water, Some((64, water))); + assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow); + assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92); + assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair); let fine = TileKey { x: -1, diff --git a/crates/horizon-client-render/src/logging.rs b/crates/horizon-client-render/src/logging.rs index 282831f675..7217d9e005 100644 --- a/crates/horizon-client-render/src/logging.rs +++ b/crates/horizon-client-render/src/logging.rs @@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf { std::env::temp_dir() } -/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the -/// platform data folder (launcher/src/backend.rs `game_home`). +/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`. +static HOME: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Makes `game_home` return `home` for the rest of the run (`--home`). +pub fn set_game_home(home: PathBuf) { + let _ = HOME.set(home); +} + +/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else +/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`). pub fn game_home() -> PathBuf { + if let Some(home) = HOME.get() { + return home.clone(); + } match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) { Some(h) => PathBuf::from(h), None => data_dir().join("horizon"), diff --git a/crates/horizon-client-render/src/models.rs b/crates/horizon-client-render/src/models.rs index 5979672e97..ddbcfd31c5 100644 --- a/crates/horizon-client-render/src/models.rs +++ b/crates/horizon-client-render/src/models.rs @@ -45,6 +45,7 @@ pub struct ModelInstance { pub layer: u32, pub shade: f32, } +#[derive(Default)] pub struct PreparedModels { pub catalog: StateModels, pub placements: Vec<(BlockPos, BlockStateId)>, diff --git a/crates/horizon-client-render/src/models/catalog.rs b/crates/horizon-client-render/src/models/catalog.rs index c02e972405..db249cd8bc 100644 --- a/crates/horizon-client-render/src/models/catalog.rs +++ b/crates/horizon-client-render/src/models/catalog.rs @@ -42,6 +42,9 @@ pub struct BakedModel { pub cube: Option<[super::cube::CubeStyle; 6]>, } type VariantKey = (String, [u8; 3], bool); +/// Empty by default: no state has a model (a dedicated server, which draws +/// nothing). +#[derive(Default)] pub struct StateModels { /// Each definition's choices, a variant standing as its index in `baked`. choices: Vec>, diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index eca76f5ec1..9c68a7da4f 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -143,6 +143,7 @@ impl Opt { | Opt::ChunkBuilder // Saved, but the creative tabs and the mouse don't read them yet. | Opt::OperatorItemsTab + | Opt::QuitShortcuts | Opt::CtrlClickEmulatesRightClick ) } @@ -950,6 +951,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) { apply_preset(s); } } +/// A slider pressed or dragged to `v`: apply it live, and tell the game when +/// a category volume changed (`Options.createSoundSliderOptionInstance`'s +/// callback, which `OptionInstance.set` runs only for a new value). +fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec) { + let volume = |s: &Settings| match opt { + Opt::Volume(0) => Some(s.master_volume), + Opt::Volume(i) => Some(s.volumes[i]), + _ => None, + }; + let before = volume(s); + set_slider(opt, s, v); + events.push(Event::SliderDrag); + if let (Opt::Volume(source), Some(after)) = (opt, volume(s)) + && before != Some(after) + { + events.push(Event::Volume(source, after)); + } +} /// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture /// filtering and anisotropy have no option here yet). pub fn apply_preset(s: &mut Settings) { @@ -1104,6 +1123,9 @@ pub enum Event { Changed, /// A slider is being dragged: apply settings live but defer saving. SliderDrag, + /// A category volume slider (`SoundSource` index) moved to a new value, + /// after its `SliderDrag` (the sound slider's `onValueUpdate`). + Volume(usize, f32), /// The language changed (reload translations). Language(String), /// The pack selection changed (reload resources). @@ -2745,8 +2767,7 @@ impl OptionsUi { { let v = ui::slider_value(wd.x, wd.w, x); self.slider_value = Some((opt, v, Instant::now())); - set_slider(opt, s, v); - events.push(Event::SliderDrag); + drag_slider(opt, s, v, &mut events); } return events; } @@ -2859,8 +2880,7 @@ impl OptionsUi { self.dragging = Some(opt); let v = ui::slider_value(wd.x, wd.w, x); self.slider_value = Some((opt, v, Instant::now())); - set_slider(opt, s, v); - events.push(Event::SliderDrag); + drag_slider(opt, s, v, &mut events); } WKind::LanguageRow { code, .. } => { if self.language_choice.as_deref() == Some(code.as_str()) { @@ -3422,6 +3442,15 @@ mod tests { Opt::QuitShortcuts.tooltip_key(&s), "options.quitShortcuts.tooltip" ); + + #[test] + fn operator_items_tab_option_can_be_switched() { + // The creative screen reads it (CreativeModeInventoryScreen.hasPermissions). + assert!(Opt::OperatorItemsTab.available()); + let mut s = Settings::default(); + assert!(!s.operator_items_tab); + Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]); + assert!(s.operator_items_tab); } #[test] @@ -4102,7 +4131,7 @@ mod tests { for cx in [-100.0, 250.0, 1000.0] { assert_eq!( ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false), - vec![Event::SliderDrag] + vec![Event::SliderDrag, Event::Volume(0, s.master_volume)] ); } assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]); @@ -4112,6 +4141,35 @@ mod tests { ); } + #[test] + fn a_moved_volume_slider_reports_its_category_and_value() { + let atlas = atlas(); + let size = (480.0, 320.0); + let mut s = Settings::default(); + let mut ui = OptionsUi::default(); + ui.show(Page::Sound); + let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap(); + let (widgets, _, _, _) = ui.layout(&s, size.0, size.1); + let slider = widgets + .iter() + .find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile)) + .unwrap(); + let y = slider.y + 1.0; + let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5; + let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false); + let volume = s.volumes[hostile]; + assert!((volume - 0.5).abs() < 1e-3, "{volume}"); + assert_eq!( + events, + vec![Event::SliderDrag, Event::Volume(hostile, volume)] + ); + // OptionInstance.set: an unchanged value runs no callback. + assert_eq!( + ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false), + vec![Event::SliderDrag] + ); + } + #[test] fn classic_sliders_follow_the_pointer_and_snap_on_release() { let atlas = atlas(); diff --git a/crates/horizon-client-render/src/sound_preview.rs b/crates/horizon-client-render/src/sound_preview.rs new file mode 100644 index 0000000000..babb802dd3 --- /dev/null +++ b/crates/horizon-client-render/src/sound_preview.rs @@ -0,0 +1,179 @@ +//! `SoundPreviewHandler`: the sound a category volume slider plays while it +//! moves on the title screen's options (`Options.createSoundSliderOptionInstance` +//! calls `preview` when there is no level). +use crate::settings::SOUND_SOURCES; +use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds}; + +/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index, +/// or `None` (`SoundEvents.EMPTY`) for master, music and voice. +pub fn preview_event(source: usize) -> Option<&'static str> { + Some(match *SOUND_SOURCES.get(source)? { + "record" => "minecraft:block.note_block.guitar", + "weather" => "minecraft:entity.lightning_bolt.thunder", + "block" => "minecraft:block.grass.place", + "hostile" => "minecraft:entity.zombie.ambient", + // SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound() + "neutral" => "minecraft:entity.cow.ambient", + "player" => "minecraft:entity.generic.eat", + "ambient" => "minecraft:ambient.cave", + "ui" => "minecraft:ui.button.click", + _ => return None, + }) +} + +/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`. +#[derive(Debug, Default)] +pub struct SoundPreview { + active: Option, + previous: Option, +} + +impl SoundPreview { + /// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the + /// preview of another category, then, unless the last preview still + /// plays, play this category's sound (`SimpleSoundInstance.forUI` at + /// pitch 1 and the slider's volume). + pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) { + self.stop_other_category_preview(sounds, source); + if !self.can_play_sound(sounds) { + return; + } + let Some(event) = preview_event(source) else { + return; + }; + if let Some(old) = self.active.take() { + sounds.release(old); + } + let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded()); + self.active = Some(sounds.play_instance(instance, true).0); + } + + /// The preview playing now, if any. + pub fn active(&self) -> Option { + self.active + } + + /// `stopOtherCategoryPreview`. + fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) { + if self.previous != Some(source) { + self.previous = Some(source); + if let Some(active) = self.active { + sounds.stop_instance(active); + } + } + } + + /// `canPlaySound`: no preview yet, or the last one has finished. + fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool { + self.active.is_none_or(|active| !sounds.is_active(active)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null}; + use crate::sound_events::SoundRegistry; + + fn engine() -> SoundEngine { + let mut registry = SoundRegistry::default(); + registry.add_file( + "minecraft", + br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]}, + "entity.cow.ambient": {"sounds": ["mob/cow/say1"]}, + "ui.button.click": {"sounds": ["random/click"]}}"#, + &|_, _| true, + ); + SoundEngine::new(registry) + } + + fn source(name: &str) -> usize { + SOUND_SOURCES.iter().position(|s| *s == name).unwrap() + } + + #[test] + fn each_category_previews_its_vanilla_sound() { + let expected = [ + ("master", None), + ("music", None), + ("record", Some("minecraft:block.note_block.guitar")), + ("weather", Some("minecraft:entity.lightning_bolt.thunder")), + ("block", Some("minecraft:block.grass.place")), + ("hostile", Some("minecraft:entity.zombie.ambient")), + ("neutral", Some("minecraft:entity.cow.ambient")), + ("player", Some("minecraft:entity.generic.eat")), + ("ambient", Some("minecraft:ambient.cave")), + ("voice", None), + ("ui", Some("minecraft:ui.button.click")), + ]; + for (name, event) in expected { + assert_eq!(preview_event(source(name)), event, "{name}"); + } + } + + fn slide( + preview: &mut SoundPreview, + engine: &mut SoundEngine, + backend: &mut Null, + name: &str, + volume: f32, + ) { + preview.preview(&mut Bound { engine, backend }, source(name), volume); + } + + /// Ticks enough for the engine to drop finished or stopped channels. + fn settle(engine: &mut SoundEngine, backend: &mut Null) { + for _ in 0..40 { + engine.tick(false, &TickContext::default(), backend); + } + } + + #[test] + fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() { + let (mut engine, mut backend) = (engine(), Null::default()); + let mut preview = SoundPreview::default(); + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4); + let first = preview + .active() + .expect("the hostile slider plays a preview"); + let instance = engine.instance(first).unwrap(); + assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient"); + assert_eq!(instance.source, UI); + assert_eq!((instance.volume, instance.pitch), (0.4, 1.0)); + assert!(instance.relative); + assert!(engine.is_active(first)); + // canPlaySound: still playing, so dragging further does not restart it. + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6); + assert_eq!(preview.active(), Some(first)); + assert_eq!(engine.active_instances(), vec![first]); + // Once it has finished, the next move plays it again. + for stopped in backend.stopped.values_mut() { + *stopped = true; + } + settle(&mut engine, &mut backend); + assert!(!engine.is_active(first)); + slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6); + let second = preview.active().unwrap(); + assert_ne!(second, first); + assert!(engine.is_active(second)); + // Another category stops the playing preview (SoundManager.stop); the + // engine lets its channel go on the next ticks, then that category's + // own sound plays. + slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0); + assert!(backend.stopped.values().all(|stopped| *stopped)); + settle(&mut engine, &mut backend); + assert!(engine.active_instances().is_empty()); + slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0); + let third = preview.active().unwrap(); + assert_eq!( + engine.instance(third).unwrap().identifier, + "minecraft:entity.cow.ambient" + ); + // Master has no sound but still stops the other category's preview. + slide(&mut preview, &mut engine, &mut backend, "master", 1.0); + settle(&mut engine, &mut backend); + assert!(engine.active_instances().is_empty()); + slide(&mut preview, &mut engine, &mut backend, "master", 1.0); + assert!(engine.active_instances().is_empty()); + } +} diff --git a/crates/horizon-client-render/src/streaming.rs b/crates/horizon-client-render/src/streaming.rs index e754355ef1..fcdac3095e 100644 --- a/crates/horizon-client-render/src/streaming.rs +++ b/crates/horizon-client-render/src/streaming.rs @@ -3,6 +3,7 @@ //! hands immutable `Arc` snapshots to workers; results carry a sequence number //! so stale work is dropped. use crate::fast_hash::{FxHashMap, FxHashSet}; +use crate::section_mask::SectionMask; use crate::{ chunk::Chunk, light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk}, @@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static { fn light_view(&self, _proto: &Proto) -> Option { None } + /// A decorated proto chunk's column sections (`Chunk::sections` + /// indexes) holding a block other than air: what its neighbours' light + /// layers count once it is at INITIALIZE_LIGHT. + fn non_air_sections(&self, _proto: &Proto) -> Option { + None + } fn saved_proto(&self, _proto: &Proto) -> Option { None } @@ -275,6 +282,7 @@ impl Mailbox { pub struct Streaming { shared: Arc, light_view: Arc Option + Send + Sync>, + non_air_sections: Arc Option + Send + Sync>, threads: Vec>, order: u64, /// Ticks since the columns and protos that fell out of range were swept. @@ -653,9 +661,14 @@ impl Streaming { let generator = generator.clone(); Arc::new(move |p: &Proto| generator.light_view(p)) }; + let non_air_sections = { + let generator = generator.clone(); + Arc::new(move |p: &Proto| generator.non_air_sections(p)) + }; Self { shared, light_view, + non_air_sections, threads, order: 0, scan_age: 0, @@ -956,6 +969,15 @@ impl Streaming { pub fn is_lit(&self, x: i32, z: i32) -> bool { self.lit.contains(&(x, z)) } + /// The non-air sections of a column that is a decorated proto chunk + /// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level + /// chunk's light layers count them (#4294). + pub fn proto_sections(&self, x: i32, z: i32) -> Option { + if !self.decorated.contains(&(x, z)) { + return None; + } + (self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?) + } /// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the /// furthest step this column has reached, `None` before it is asked for. pub fn latest_status( @@ -1475,6 +1497,7 @@ impl Streaming { &mut changed, ); Self::mark_light_changes(world, &changed); + Self::relight_beside_unlit(world, &changed); if !self.light_snapshots.is_empty() { for p in positions { self.light_edit_sequence += 1; @@ -1504,6 +1527,7 @@ impl Streaming { &mut changed, ); Self::mark_light_changes(world, &changed); + Self::relight_beside_unlit(world, &changed); } let oldest = self.light_snapshots.values().copied().min(); match oldest { @@ -1511,6 +1535,14 @@ impl Streaming { None => self.light_log.clear(), } } + /// A column whose light an edit changed beside a column not lit yet gets + /// a full light job ([`crate::light::beside_unlit`]). + fn relight_beside_unlit(world: &mut World, changed: &LightChanges) { + let columns = crate::light::beside_unlit(changed, |x, z| { + world.chunks.get(&(x, z)).is_some_and(|c| !c.lit) + }); + world.dirty_light.extend(columns); + } /// Sections whose meshes sample a changed light value (faces and corners /// read light from neighbouring blocks). fn mark_light_changes(world: &mut World, changed: &LightChanges) { diff --git a/crates/horizon-client-render/src/world.rs b/crates/horizon-client-render/src/world.rs index 7080fa4ff6..5cf70a1955 100644 --- a/crates/horizon-client-render/src/world.rs +++ b/crates/horizon-client-render/src/world.rs @@ -262,6 +262,14 @@ impl World { pub fn layout(&self) -> ColumnLayout { self.shape.column() } + /// The lowest block Y the level's chunks hold. + pub fn bottom_y(&self) -> i32 { + self.layout().min_y() + } + /// One above the highest block Y the level's chunks hold. + pub fn top_y(&self) -> i32 { + self.layout().max_y() + } /// Resident storage write; marks the touched sections (and neighbours on /// section borders) for remeshing and the surrounding chunks for relighting. /// Neighbour updates, drops and block entities belong to the gameplay layer. diff --git a/crates/horizon-client/examples/probe/creative_search_fixture.rs b/crates/horizon-client/examples/probe/creative_search_fixture.rs index 1f4f04b37a..f3bae36491 100644 --- a/crates/horizon-client/examples/probe/creative_search_fixture.rs +++ b/crates/horizon-client/examples/probe/creative_search_fixture.rs @@ -1,9 +1,10 @@ //! The creative inventory, for tools/test_creative_parity.py: loads an //! exported `creative-inventory.json` into `horizon_client::creative::Creative` //! and prints the tabs it shows (canonical stacks) and the search tab's -//! results for every query of the vanilla oracle's output. +//! results for every query of the vanilla oracle's output; with `permissions`, +//! as an operator with the Operator Items Tab option on. //! -//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT +//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions] use horizon_client::{ creative::{Creative, Names}, item_data::canon_stack, @@ -13,11 +14,13 @@ use std::{fs, path::Path}; pub fn main() -> horizon_client::Result<()> { let args: Vec = crate::args().collect(); - let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT"; + let usage = + "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]"; let names = Names::load(Path::new(args.get(1).ok_or(usage)?)); - let creative = Creative::from_json(&serde_json::from_slice(&fs::read( + let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read( args.get(2).ok_or(usage)?, )?)?)?; + creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names); let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?; let tabs: Vec = creative .tabs diff --git a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs index 7ef9c8de71..198ad6a594 100644 --- a/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs +++ b/crates/horizon-client/examples/probe/entity_death_loot_fixture.rs @@ -4,7 +4,12 @@ //! //! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT use horizon_client::{ - mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube}, + assets::Assets, + blocks::BlockFacts, + items::Items, + mobs::{ + Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube, + }, random_sequences::RandomSequences, }; use horizon_core::{geometry::Vec3, random::LegacyRandom}; @@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> { let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?; let types = MobTypes::load(root)?; let loot = LootData::load(root, &PackOverlay::NONE)?; + // Block loot for an enderman's carried block (BlockState.getDrops). + let assets = Assets::load(root)?; + let registry = assets.registry; + let facts = BlockFacts::load(root, registry)?; + let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?; + // The stand-in level's random (RandomSource.create(0)), never reset. + let mut level_random = LegacyRandom::new(0); let mut out = String::new(); for raw in input.lines() { if raw.trim().is_empty() || raw.starts_with('#') { @@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> { m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8; } m.extra.sheared = get("sheared", "0") == "1"; + if let Some(block) = c.get("carried") { + m.extra.carried = Some( + registry + .default_state(block) + .ok_or_else(|| format!("unknown block {block}"))?, + ); + } if get("leader", "0") == "1" { raider::state_mut(&mut m).patrol_leader = true; } @@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> { for d in items { let _ = match d { drops::Drop::Item(s) => writeln!(out, "{}", line(&s)), + drops::Drop::CarriedBlock(state) => { + let stacks = enderman::carried_block_drops( + &block_items, + registry, + state, + &mut level_random, + Some((&mut sequences, world_seed)), + ); + for s in stacks { + let _ = write!(out, "drop {} {}", s.item, s.count); + if s.damage != 0 { + let _ = write!(out, " damage={}", s.damage); + } + let _ = writeln!(out); + } + Ok(()) + } drops::Drop::Orb(v) => writeln!(out, "orb {v}"), }; } diff --git a/crates/horizon-client/examples/probe/main.rs b/crates/horizon-client/examples/probe/main.rs index f208fcbe8f..272e9ccaa6 100644 --- a/crates/horizon-client/examples/probe/main.rs +++ b/crates/horizon-client/examples/probe/main.rs @@ -99,6 +99,7 @@ mod toggle_keys_probe; mod tooltip_display_fixture; mod trading_fixture; mod translucency_fixture; +mod trident_probe; mod weather_fixture; mod world_border_render_fixture; mod world_options_fixture; @@ -191,6 +192,7 @@ probes! { text_display_fixture, text_display_render_fixture, trading_fixture, + trident_probe, translucency_fixture, weather_fixture, world_border_render_fixture, diff --git a/crates/horizon-client/examples/probe/marker_particles_probe.rs b/crates/horizon-client/examples/probe/marker_particles_probe.rs index 6025a563cd..8dc48c45c7 100644 --- a/crates/horizon-client/examples/probe/marker_particles_probe.rs +++ b/crates/horizon-client/examples/probe/marker_particles_probe.rs @@ -76,6 +76,7 @@ pub fn main() -> Result<()> { raining_at: &|_| false, height_no_leaves: &|_| 0, firefly_bush_sounds: false, + creaking_active: false, sky_darken: 0, marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)), model_offset: &|_, _, _| None, diff --git a/crates/horizon-client/examples/probe/trident_probe.rs b/crates/horizon-client/examples/probe/trident_probe.rs new file mode 100644 index 0000000000..9974756880 --- /dev/null +++ b/crates/horizon-client/examples/probe/trident_probe.rs @@ -0,0 +1,130 @@ +//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`), +//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and +//! `AbstractArrow.onHitBlock`, one line of state per fixture. +#![forbid(unsafe_code)] +use horizon_client::{ + Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World, +}; +use horizon_core::geometry::Vec3; +use serde_json::Value; +use std::{ + fs, + io::{BufRead, Write}, + path::PathBuf, +}; + +fn number(v: &Value) -> f64 { + f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap()) +} +fn vector(v: &Value) -> Vec3 { + Vec3::new(number(&v[0]), number(&v[1]), number(&v[2])) +} +fn bits(v: Vec3) -> String { + format!( + "{:016x},{:016x},{:016x}", + v.x.to_bits(), + v.y.to_bits(), + v.z.to_bits() + ) +} + +pub fn main() -> Result<()> { + let args: Vec<_> = crate::args().skip(1).collect(); + if args.len() != 3 { + return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into()); + } + let root = PathBuf::from(&args[0]); + let dir = PathBuf::from(&args[1]); + let assets = Assets::load(&root)?; + let render = assets.reload(&root)?; + let mut scene = World::test_scene(&assets, &render, 1)?; + fs::write( + dir.join("scene.json"), + serde_json::to_vec( + &serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}), + )?, + )?; + if args[2] == "--describe" { + return Ok(()); + } + let binding = collisions::load(&root, assets.registry)?; + let facts = BlockFacts::load(&root, assets.registry)?; + let side = &args[2]; + let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?); + for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() { + let f: Value = serde_json::from_str(&line?)?; + let mut position = vector(&f["position"]); + let mut velocity = vector(&f["velocity"]); + let mut in_ground = false; + let mut shake = 0u32; + for op in f["ops"].as_array().unwrap() { + match op["mode"].as_str().unwrap() { + "block" => { + let p = &op["position"]; + let mut state = assets + .registry + .default_state(op["name"].as_str().unwrap()) + .ok_or("missing fixture block")?; + if let Some(properties) = op["properties"].as_object() { + let mut definition = assets.registry.state(state).unwrap().clone(); + for (name, value) in properties { + definition + .properties + .insert(name.clone(), value.as_str().unwrap().into()); + } + state = assets + .registry + .id(&definition) + .ok_or("missing fixture state")?; + } + scene.set( + p[0].as_i64().unwrap() as i32, + p[1].as_i64().unwrap() as i32, + p[2].as_i64().unwrap() as i32, + state, + )?; + } + // Ops only the vanilla oracle needs. + "level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {} + "tick" => { + // AbstractArrow.tick: shakeTime down, then the block step. + shake = shake.saturating_sub(1); + if in_ground { + continue; + } + if let Some((stop, _)) = + arrows::clip_block_stop(&scene, &facts, &binding, position, velocity) + { + // onHitBlock: pulled back, stopped, stuck, shaking 7 ticks. + position = stop; + velocity = Vec3::ZERO; + in_ground = true; + shake = 7; + } else { + // A MISS moves the whole movement, then air drag and gravity. + position = Vec3::new( + position.x + velocity.x, + position.y + velocity.y, + position.z + velocity.z, + ); + velocity = Vec3::new( + velocity.x * f64::from(0.99f32), + velocity.y * f64::from(0.99f32) - 0.05, + velocity.z * f64::from(0.99f32), + ); + } + } + other => return Err(format!("unknown operation {other}").into()), + } + } + out.write_all( + format!( + "trident {} {} {in_ground} {shake}\n", + bits(position), + bits(velocity) + ) + .as_bytes(), + )?; + } + Ok(()) +} diff --git a/crates/horizon-client/src/animate_tick.rs b/crates/horizon-client/src/animate_tick.rs index ac23d329ee..eed6a4d365 100644 --- a/crates/horizon-client/src/animate_tick.rs +++ b/crates/horizon-client/src/animate_tick.rs @@ -101,6 +101,8 @@ enum Animate { DeadBush, /// `ShortDryGrassBlock` and `TallDryGrassBlock`. DryGrass, + /// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`). + CreakingHeart, } fn step(facing: &str) -> [i32; 3] { match facing { @@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option) -> Animate { "firefly_bush" => Animate::FireflyBush, "open_eyeblossom" => Animate::OpenEyeblossom, "pale_hanging_moss" => Animate::PaleHangingMoss, + "creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => { + Animate::CreakingHeart + } "dead_bush" => Animate::DeadBush, "short_dry_grass" | "tall_dry_grass" => Animate::DryGrass, "sculk_sensor" | "calibrated_sculk_sensor" @@ -442,6 +447,8 @@ pub struct AnimateContext<'a> { pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32, /// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline). pub firefly_bush_sounds: bool, + /// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline). + pub creaking_active: bool, /// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`. pub sky_darken: i32, /// Creative main-hand marker block and a resolver for each placed state's sprite. @@ -1259,6 +1266,39 @@ fn block_animate_tick( } } } + Animate::CreakingHeart => { + // CreakingHeartBlock.animateTick: the idle creak is drawn only while + // the attribute is on, then needs pale oak logs on all six sides + // (`CreakingHeartBlock.isSurroundedByLogs`). + if ctx.creaking_active + && next_int(engine.random(), 16) == 0 + && [ + [-1, 0, 0], + [1, 0, 0], + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], + ] + .iter() + .all(|d| { + let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]]; + tables + .pale_oak_logs + .get(ctx.state(p).0 as usize) + .copied() + .unwrap_or(false) + }) + { + sound( + engine, + "minecraft:block.creaking_heart.idle", + [fx, fy, fz], + 1.0, + 1.0, + ); + } + } Animate::DeadBush => { // AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds. if next_int(engine.random(), 130) == 0 { diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index ff8f8022ce..0b331d45e3 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -186,6 +186,8 @@ pub struct ArrowState { /// The arrow's own random (`Entity.random`). pub random: LegacyRandom, pub events: Vec, + /// The speed (`deltaMovement.length()`) it hit a block at, before it stuck. + pub hit_speed: f64, } impl fmt::Debug for ArrowState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -230,6 +232,7 @@ impl ArrowState { removed: false, random, events: Vec::new(), + hit_speed: 0.0, } } /// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo @@ -375,6 +378,61 @@ impl ArrowScene<'_, '_> { true } } +/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width +/// `level().clip(new ClipContext(position(), position().add(movement), +/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then +/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns +/// the position to stop at (pull-back included) and the block that was hit. +pub fn clip_block_stop( + world: &World, + facts: &BlockFacts, + collision: &BlockCollisionRegistry<'_>, + from: Vec3, + movement: Vec3, +) -> Option<(Vec3, BlockPos)> { + let to = Vec3::new( + from.x + movement.x, + from.y + movement.y, + from.z + movement.z, + ); + let mut scene = SceneCollisions { + world, + binding: collision, + subject: None, + }; + let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| { + let state = world.block(p.x, p.y, p.z); + if facts.is_air(state) { + return None; + } + let shape = collision + .collision_shape(&mut scene, state, [p.x, p.y, p.z]) + .ok()?; + shape.clip(from, to, p) + }) + .ok() + .flatten()?; + Some((back_off(hit.location, movement), hit.block)) +} +/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by +/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0). +pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 { + fn signum(v: f64) -> f64 { + if v > 0.0 { + 1.0 + } else if v < 0.0 { + -1.0 + } else { + 0.0 + } + } + let offset = f64::from(0.05f32); + Vec3::new( + location.x - signum(movement.x) * offset, + location.y - signum(movement.y) * offset, + location.z - signum(movement.z) * offset, + ) +} fn bounds(at: Vec3) -> Aabb { // EntityDimensions.makeBoundingBox: half the float width. let w = f64::from(WIDTH / 2.0); @@ -664,7 +722,7 @@ impl Ticker<'_, '_, '_, '_> { if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) { self.st.fall_distance *= 0.5; } - if self.position().y < f64::from(crate::chunk::MIN_Y - 64) { + if self.position().y < f64::from(self.scene.world.bottom_y() - 64) { self.st.removed = true; } self.st.first_tick = false; @@ -848,6 +906,8 @@ impl Ticker<'_, '_, '_, '_> { /// it moved on, stop, and stick. fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) { self.st.last_state = Some(state); + // BlockState.onProjectileHit still sees the deltaMovement it hit at. + self.st.hit_speed = self.p.velocity.length(); // Projectile.onHitBlock: BlockState.onProjectileHit. if self.st.on_fire() && let Some(reaction) = self.block_reaction(state) @@ -1159,10 +1219,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> { Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(())) } fn chunk_sections(&mut self, _: ()) -> Result, Error> { - static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS]; + let layout = self.scene.world.layout(); Ok(FluidSections { - minimum_section_y: crate::chunk::MIN_Y >> 4, - has_fluid: &SECTIONS, + minimum_section_y: layout.min_y() >> 4, + has_fluid: crate::chunk::every_section(layout), }) } fn bounding_box_min_y(&self) -> f64 { diff --git a/crates/horizon-client/src/creative.rs b/crates/horizon-client/src/creative.rs index b9c25b7814..3dda7faa1f 100644 --- a/crates/horizon-client/src/creative.rs +++ b/crates/horizon-client/src/creative.rs @@ -24,6 +24,31 @@ pub struct Tab { /// tab is selected (`set_saved_hotbars`). pub hotbar: bool, } +impl Tab { + /// The tab without its stacks. + fn header(&self) -> Self { + Self { + id: self.id.clone(), + title: self.title.clone(), + icon: self.icon.clone(), + items: Vec::new(), + top: self.top, + column: self.column, + search: self.search, + inventory: self.inventory, + hotbar: self.hotbar, + } + } +} +/// A tab as exported, built with permissions: `operator` marks the stacks a +/// build without them leaves out (the export's `permissions_only`). +#[derive(Clone, Debug)] +struct Exported { + tab: Tab, + /// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`). + category: bool, + operator: Vec, +} /// What the client's creative search trees (SessionSearchTrees) index for one /// stack of the search tab: its creative tooltip lines and its item tags. #[derive(Clone, Debug, Default)] @@ -37,6 +62,8 @@ struct SearchEntry { tags: Vec<(String, String)>, } pub struct Creative { + /// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current + /// permissions put in them. pub tabs: Vec, pub selected: usize, /// Scroll position 0..1 (scrollOffs). @@ -48,6 +75,13 @@ pub struct Creative { /// The search tab's stacks, in its display order. search_items: Vec, search_index: Vec, + /// Every exported tab, and the search tab's stacks with their entries and + /// whether only permissions list them: `rebuild` keeps what is shown. + exported: Vec, + exported_search: Vec<(ItemStack, SearchEntry, bool)>, + /// The `hasPermissions` the tabs were last built with + /// (`CreativeModeTabs.tryRebuildTabContents`). + permissions: bool, /// ItemPickerMenu.items: what the grid shows, filled when the tab or the /// search changes (selectTab, refreshSearchResults), not on every frame. shown: Mutex>, @@ -202,14 +236,22 @@ impl Creative { } pub fn from_json(raw: &Value) -> Result { let mut tabs = Vec::new(); - let mut search_items = Vec::new(); - let mut search_index = Vec::new(); + let mut search = Vec::new(); for t in raw["tabs"].as_array().into_iter().flatten() { + // The stacks only a build with permissions lists, by index. + let only: Vec = t["permissions_only"] + .as_array() + .into_iter() + .flatten() + .filter_map(Value::as_u64) + .collect(); let mut items = Vec::new(); - for v in t["items"].as_array().into_iter().flatten() { + let mut operator = Vec::new(); + for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() { let snbt = v.as_str().unwrap_or("-"); if let Some(stack) = crate::item_data::parse_stack(snbt)? { items.push(stack); + operator.push(only.contains(&(i as u64))); } } let kind = t["type"].as_str().unwrap_or(""); @@ -223,34 +265,35 @@ impl Creative { }; for (i, stack) in items.iter().enumerate() { let (namespace, path) = split_id(&java_lower(&stack.item)); - search_index.push(SearchEntry { + let entry = SearchEntry { namespace, path, lines: strings(&t["lines"][i]), tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(), - }); + }; + search.push((stack.clone(), entry, operator[i])); } - search_items = items.clone(); - } - if t["visible"] == false { - continue; } let icon = t["icon"] .as_str() .and_then(|s| crate::item_data::parse_stack(s).ok().flatten()); - tabs.push(Tab { - id: t["id"].as_str().unwrap_or("").to_owned(), - title: t["title"].as_str().unwrap_or("").to_owned(), - icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()), - items, - top: t["row"] == "TOP", - column: t["column"].as_u64().unwrap_or(0) as u32, - search: kind == "SEARCH", - inventory: kind == "INVENTORY", - hotbar: kind == "HOTBAR", + tabs.push(Exported { + tab: Tab { + id: t["id"].as_str().unwrap_or("").to_owned(), + title: t["title"].as_str().unwrap_or("").to_owned(), + icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()), + items, + top: t["row"] == "TOP", + column: t["column"].as_u64().unwrap_or(0) as u32, + search: kind == "SEARCH", + inventory: kind == "INVENTORY", + hotbar: kind == "HOTBAR", + }, + category: kind == "CATEGORY", + operator, }); } - Ok(Self::new(tabs, search_items, search_index)) + Ok(Self::new(tabs, search)) } /// The old export: item ids per tab, no components, tooltips or tags. fn from_legacy(raw: &Value) -> Result { @@ -272,34 +315,39 @@ impl Creative { } } let kind = t["type"].as_str().unwrap_or(""); - tabs.push(Tab { - id: t["id"].as_str().unwrap_or("").to_owned(), - title: t["title"].as_str().unwrap_or("").to_owned(), - icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(), - items, - top: t["row"] == "TOP", - column: t["column"].as_u64().unwrap_or(0) as u32, - search: kind == "SEARCH", - inventory: kind == "INVENTORY", - hotbar: false, + tabs.push(Exported { + operator: vec![false; items.len()], + tab: Tab { + id: t["id"].as_str().unwrap_or("").to_owned(), + title: t["title"].as_str().unwrap_or("").to_owned(), + icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(), + items, + top: t["row"] == "TOP", + column: t["column"].as_u64().unwrap_or(0) as u32, + search: kind == "SEARCH", + inventory: kind == "INVENTORY", + hotbar: false, + }, + category: false, }); } - let search_index = search_items - .iter() + let search = search_items + .into_iter() .map(|s| { let (namespace, path) = split_id(&java_lower(&s.item)); - SearchEntry { + let entry = SearchEntry { namespace, path, ..Default::default() - } + }; + (s, entry, false) }) .collect(); - Ok(Self::new(tabs, search_items, search_index)) + Ok(Self::new(tabs, search)) } - fn new(tabs: Vec, search_items: Vec, search_index: Vec) -> Self { - Self { - tabs, + fn new(exported: Vec, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self { + let mut creative = Self { + tabs: Vec::new(), selected: 0, scroll: 0.0, scrolling: false, @@ -310,10 +358,81 @@ impl Creative { search.text_color = u32::MAX; search }, - search_items, - search_index, + search_items: Vec::new(), + search_index: Vec::new(), + exported, + exported_search, + permissions: false, shown: Mutex::new(None), + }; + creative.rebuild(); + creative + } + /// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator + /// tab's display items generator lists its stacks only with them + /// (`hasPermissions`), and the search tab with it; a category tab is + /// shown with a stack (`CreativeModeTab.shouldDisplay`). + fn rebuild(&mut self) { + let permissions = self.permissions; + let keep = |operator: bool| permissions || !operator; + self.tabs = self + .exported + .iter() + .filter_map(|e| { + let items: Vec = (e.tab.items.iter().zip(&e.operator)) + .filter(|(_, o)| keep(**o)) + .map(|(s, _)| s.clone()) + .collect(); + (!e.category || !items.is_empty()).then(|| Tab { + items, + ..e.tab.header() + }) + }) + .collect(); + (self.search_items, self.search_index) = self + .exported_search + .iter() + .filter(|(_, _, o)| keep(*o)) + .map(|(s, e, _)| (s.clone(), e.clone())) + .unzip(); + self.forget_items(); + } + /// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its + /// constructor and `containerTick` run with `hasPermissions` + /// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option): + /// when that changed, the tabs are built again. A selected tab left + /// without stacks (the operator tab) gives way to the default tab; any + /// other keeps its first row (`refreshCurrentTabContents`). True when the + /// tabs were rebuilt. + pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool { + if permissions == self.permissions { + return false; } + let selected = self.tab().id.clone(); + let row = self.first_row(names); + self.permissions = permissions; + self.rebuild(); + match self.tabs.iter().position(|t| t.id == selected) { + Some(tab) => { + self.selected = tab; + // ItemPickerMenu.getScrollForRowIndex. + let hidden = self.hidden_rows(names); + self.scroll = if hidden > 0 { + (row as f32 / hidden as f32).clamp(0.0, 1.0) + } else { + 0.0 + }; + } + None => { + // CreativeModeTabs.getDefaultTab. + let default = self + .tabs + .iter() + .position(|t| t.id == "minecraft:building_blocks"); + self.select(default.unwrap_or(0)); + } + } + true } /// Where a stack comes in the creative tabs (the search tab lists them /// all, in tab order): the same stack's place, else its item's first. @@ -327,20 +446,7 @@ impl Creative { } /// The tabs without their stacks: what the screen draws of them each frame. pub fn tab_headers(&self) -> Vec { - self.tabs - .iter() - .map(|t| Tab { - id: t.id.clone(), - title: t.title.clone(), - icon: t.icon.clone(), - items: Vec::new(), - top: t.top, - column: t.column, - search: t.search, - inventory: t.inventory, - hotbar: t.hotbar, - }) - .collect() + self.tabs.iter().map(Tab::header).collect() } pub fn tab(&self) -> &Tab { &self.tabs[self.selected.min(self.tabs.len() - 1)] @@ -601,7 +707,7 @@ impl Creative { pub fn tab_position(tab: &Tab) -> (f32, f32) { let mut x = tab.column as f32 * 27.0; if tab.column >= 5 { - // Right-aligned tabs (search, inventory). + // Right-aligned tabs (search, operator, inventory). x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0; } let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 }; @@ -1083,6 +1189,84 @@ mod tests { assert_eq!(c.search.value, ""); } + /// An export built with permissions: the operator tab's stacks, and their + /// place at the end of the search tab, only with them. + fn with_operator_tab() -> Creative { + let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}"); + let raw = serde_json::json!({"schema": 3, "tabs": [ + {"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks", + "icon": stack("bricks"), "visible": true, "items": [stack("stone")]}, + {"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities", + "icon": stack("command_block"), "visible": false, + "items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]}, + {"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items", + "icon": stack("compass"), "visible": true, + "items": [stack("stone"), stack("command_block"), stack("barrier")], + "lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []], + "permissions_only": [1, 2]}, + {"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory", + "icon": stack("chest"), "visible": true, "items": []} + ]}); + Creative::from_json(&raw).unwrap() + } + + fn tab_ids(c: &Creative) -> Vec<&str> { + c.tabs.iter().map(|t| t.id.as_str()).collect() + } + + #[test] + fn operator_stacks_stay_out_without_permissions() { + let c = with_operator_tab(); + let names = Names::default(); + assert_eq!( + tab_ids(&c), + [ + "minecraft:building_blocks", + "minecraft:search", + "minecraft:inventory" + ] + ); + assert_eq!(c.search_items().len(), 1); + assert!(c.search_results("command", &names).is_empty()); + assert_eq!(c.search_results("", &names), [0]); + } + + #[test] + fn operator_tab_comes_and_goes_with_permissions() { + let mut c = with_operator_tab(); + let names = Names::default(); + assert!(!c.set_permissions(false, &names)); + assert!(c.set_permissions(true, &names)); + assert!(!c.set_permissions(true, &names)); + assert_eq!( + tab_ids(&c), + [ + "minecraft:building_blocks", + "minecraft:op_blocks", + "minecraft:search", + "minecraft:inventory" + ] + ); + let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect(); + assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]); + assert_eq!(c.search_results("command", &names), [1]); + assert_eq!(c.search_results("", &names), [0, 1, 2]); + // The search tab keeps its place across the rebuild. + c.select(2); + c.search.set_value("block"); + assert!(c.set_permissions(false, &names)); + assert!(c.tab().search); + assert_eq!(c.search.value, "block"); + assert!(c.items(&names).is_empty()); + // The selected operator tab is left empty: the default tab instead. + c.set_permissions(true, &names); + c.select(1); + assert_eq!(c.items(&names).len(), 2); + c.set_permissions(false, &names); + assert_eq!(c.tab().id, "minecraft:building_blocks"); + assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone"); + } + #[test] fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() { let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [ diff --git a/crates/horizon-client/src/end_gateway.rs b/crates/horizon-client/src/end_gateway.rs index 41f2a66342..245665d0e1 100644 --- a/crates/horizon-client/src/end_gateway.rs +++ b/crates/horizon-client/src/end_gateway.rs @@ -4,6 +4,7 @@ //! (the entity goes through on the next tick), and //! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands). use crate::nether_portal::{ENTITY_DELAY, PortalState}; +use crate::projectiles::{Projectile, ProjectileKind}; use horizon_core::{geometry::Vec3, position::BlockPos}; /// The End gateway travel of one entity. @@ -65,6 +66,35 @@ pub fn swept_gateway( found } +/// The End gateway a thrown ender pearl passed through on its last move, from +/// where it started the tick to where it is (`Entity.checkInsideBlocks` from +/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles +/// are not looked at. +pub fn pearl_gateway( + pearl: &Projectile, + is_gateway: impl FnMut(BlockPos) -> bool, +) -> Option { + if !matches!(pearl.kind, ProjectileKind::EnderPearl) { + return None; + } + let size = pearl.kind.size() as f32; + swept_gateway(pearl.previous, pearl.position, size, size, is_gateway) +} + +/// A thrown ender pearl at its gateway's exit `at` +/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a +/// `TeleportTransition` with no motion, rotation 0 and nothing relative). +/// It falls from there and takes its owner where it lands. +pub fn place_pearl(pearl: &mut Projectile, at: Vec3) { + pearl.position = at; + pearl.previous = at; + pearl.velocity = Vec3::ZERO; + pearl.yaw = 0.0; + pearl.pitch = 0.0; + // A server tells its clients of the jump at once. + pearl.needs_sync = true; +} + /// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller /// lands at a gateway's exit. pub fn bottom_center(p: [i32; 3]) -> Vec3 { @@ -150,6 +180,75 @@ mod tests { assert_eq!(traveller.travel(false), None); } + /// A world with no blocks in the way and nothing to hit: the gateway + /// block has no collision shape, so a projectile flies into it. + struct Open; + impl crate::projectiles::ProjectileWorld for Open { + fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { + None + } + fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool { + false + } + fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] { + &[] + } + } + + /// The ticks a projectile flies, thrown east at the gateway, until its + /// move goes through one (`None` if it never does in 5 ticks). + fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option) { + let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None); + shot.velocity = at(1.5, 0.0, 0.0); + for _ in 0..5 { + shot.tick(&Open); + if let Some(g) = pearl_gateway(&shot, is_gateway) { + return (shot, Some(g)); + } + } + (shot, None) + } + + #[test] + fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() { + let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl); + let gateway = BlockPos::new(702, 100, 0); + // ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside + // takes it in, and it goes through on the next tick. + assert_eq!(found, Some(gateway)); + let mut traveller = Traveller::default(); + traveller.enter(gateway); + assert_eq!(traveller.travel(true), Some(gateway)); + // getPortalDestination: at the exit, no motion and rotation 0. + let exit = bottom_center([8, 100, 0]); + place_pearl(&mut pearl, exit); + assert_eq!(pearl.position, exit); + assert_eq!(pearl.previous, exit); + assert_eq!(pearl.velocity, Vec3::ZERO); + assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0)); + // It falls straight down from the exit (ThrowableProjectile gravity, + // then inertia), where it lands and takes its owner along. + pearl.tick(&Open); + assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5)); + assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9); + // The pearl is out of the gateway: it does not go back at once. + assert_eq!(traveller.travel(true), None); + assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1); + } + + #[test] + fn a_pearl_flying_past_the_gateway_stays_out_of_it() { + // Thrown one block higher, over the gateway block. + let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None); + pearl.velocity = at(1.5, 0.0, 0.0); + for _ in 0..5 { + pearl.tick(&Open); + assert_eq!(pearl_gateway(&pearl, is_gateway), None); + } + // Only the pearl travels: a snowball on the same path is not taken in. + assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None); + } + #[test] fn a_traveller_lands_at_the_bottom_centre_of_the_exit() { // TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom diff --git a/crates/horizon-client/src/entities/fluids.rs b/crates/horizon-client/src/entities/fluids.rs index 3b3dffa99b..7b61b66098 100644 --- a/crates/horizon-client/src/entities/fluids.rs +++ b/crates/horizon-client/src/entities/fluids.rs @@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> { Ok(self.world.chunks.contains_key(&(x, z)).then_some(())) } fn chunk_sections(&mut self, _: ()) -> Result, Error> { - static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS]; + let layout = self.world.layout(); Ok(FluidSections { - minimum_section_y: crate::chunk::MIN_Y >> 4, - has_fluid: &SECTIONS, + minimum_section_y: layout.min_y() >> 4, + has_fluid: crate::chunk::every_section(layout), }) } fn bounding_box_min_y(&self) -> f64 { diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 2106b6f594..089fb44508 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option ProjectileKind::SmallFireball, + "minecraft:wind_charge" => ProjectileKind::WindCharge, + "minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge, _ => return None, }; let owner = uuid_from_tag(tag.get("Owner")); @@ -3419,6 +3421,20 @@ mod fireball_nbt_tests { (p.kind, p.acceleration), (ProjectileKind::LargeFireball { power: 1 }, 0.1) ); + // `/summon` and a saved world give back both wind charges. + for (id, kind) in [ + ("minecraft:wind_charge", ProjectileKind::WindCharge), + ( + "minecraft:breeze_wind_charge", + ProjectileKind::BreezeWindCharge, + ), + ] { + let tag = Tag::compound([ + ("id", Tag::String(id.into())), + ("Pos", doubles(&[0.0, 0.0, 0.0])), + ]); + assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind); + } } } diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7d80f6d4b0..ae3411fb8a 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -136,6 +136,7 @@ mod lan_stations; mod lan_trading; mod lan_vehicles; mod lan_waypoints; +mod launch_api; mod leads; mod level_particles; mod levels; @@ -162,6 +163,7 @@ mod native_panel; mod native_panel_recipes; mod native_recipes; mod native_shaders; +mod native_world_read; mod natural_spawning; mod nautili; mod net_harness; @@ -189,6 +191,7 @@ mod pumpkin; mod raids; mod recipe_book; mod remote; +mod remote_camera; mod remote_chunks; mod remote_mob_data; mod remote_mob_particles; @@ -255,7 +258,6 @@ use horizon_client::{ audio::Audio, biome::Biomes, blocks::{BlockFacts, Fluid}, - chunk::{HEIGHT, MIN_Y}, clock::FixedStep, collisions, containers::{BlockEntity, Providers}, @@ -374,6 +376,16 @@ struct Options { server_thread: bool, /// What this Game executes; independent of its current server connection. execution_role: execution_role::ExecutionRole, + /// `--fullscreen` (vanilla's): the window opens fullscreen whatever options.txt says, + /// as vanilla's DisplayData does, without saving it there. + fullscreen: bool, + /// `--game-dir DIR`: the folder `read` makes the working directory (saves, options.txt...). + game_dir: Option, + /// `--quickPlaySingleplayer`, checked once the game folder is known. + quick_play: Option, + session_file: Option, + /// `--session-stdin`: the session file's JSON comes on standard input instead. + session_stdin: bool, } /// `--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated: /// the in-frame world is unmaintained, gets no fixes and will be removed soon. @@ -394,7 +406,65 @@ impl Options { ) }) } + /// The command line, then what it asks of the process: the game folder, the account. + /// A bad command line is a [`launch_api::UsageError`] (exit code 2, no crash report). fn read() -> Result> { + let Some(mut o) = Self::parse(std::env::args().skip(1)) + .map_err(|e| launch_api::UsageError(e.to_string()))? + else { + return Ok(None); + }; + // The launcher's account: online-mode servers need it. A bad file + // only leaves the game offline. + let session = if o.session_stdin { + let mut text = String::new(); + std::io::Read::read_to_string(&mut std::io::stdin(), &mut text) + .map_err(|e| e.to_string()) + .and_then(|_| horizon_client::auth::AuthSession::parse(&text)) + .map(Some) + } else { + o.session_file + .take() + .filter(|p| !p.is_empty()) + .map(|path| horizon_client::auth::AuthSession::load(path.as_ref())) + .transpose() + }; + match session { + Ok(Some(session)) => { + o.player_name = Some(session.name.clone()); + horizon_client::auth::set_current(session); + } + Ok(None) => {} + Err(e) => eprintln!("Horizon: not signed in: {e}"), + } + if let Some(dir) = o.game_dir.take() { + // Paths given on the command line stay relative to where the game was started. + o.resources = std::path::absolute(&o.resources)?; + o.report = o.report.map(std::path::absolute).transpose()?; + o.control = o.control.map(std::path::absolute).transpose()?; + std::fs::create_dir_all(&dir) + .and_then(|()| std::env::set_current_dir(&dir)) + .map_err(|e| format!("game folder {}: {e}", dir.display()))?; + } + // Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a + // world that exists opens; else the title screen. --world also creates one. + if let Some(world) = o.quick_play.take() { + if std::env::current_dir()? + .join("saves") + .join(&world) + .join("level.dat") + .is_file() + { + o.world = Some(world); + } else { + eprintln!("Quick play: there is no world {world} in saves/"); + } + } + Ok(Some(o)) + } + /// The command line alone: no file is read and nothing changes. `None` for `--help` and + /// `--launch-api`, which print and end the run. + fn parse(mut args: impl Iterator) -> Result> { let sibling = std::env::current_exe()? .parent() .ok_or("missing executable directory")? @@ -442,18 +512,27 @@ impl Options { on }), execution_role: execution_role::ExecutionRole::Client, + fullscreen: false, + game_dir: None, + quick_play: None, + session_file: std::env::var("HORIZON_SESSION_FILE").ok(), + session_stdin: false, }; - let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok(); - let mut args = std::env::args().skip(1); while let Some(arg) = args.next() { match arg.as_str() { "--help" | "-h" => { println!( - "Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", + "Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.", horizon_core::VANILLA_VERSION ); return Ok(None); } + "--launch-api" => { + println!("{}", launch_api::reply(&horizon_client::crash::version())); + return Ok(None); + } + "--fullscreen" => o.fullscreen = true, + "--session-stdin" => o.session_stdin = true, "--dedicated" => { o.execution_role = execution_role::ExecutionRole::DedicatedServer; o.dedicated = Some(args.by_ref().collect()); @@ -475,92 +554,92 @@ impl Options { let value = args .next() .ok_or_else(|| format!("missing value after {arg}"))?; - match arg.as_str() { - "--resources" => o.resources = value.into(), - "--render-distance" | "--radius" => { - o.distance = value.parse()?; - o.distance_override = Some(o.distance); - let max = horizon_client::options_file::MAX_RENDER_DISTANCE; - if !(2..=max).contains(&o.distance) { - return Err(format!("render distance must be 2..{max}").into()); + // A launcher shows the error to the player: it names the option. + let read = || -> Result<()> { + match arg.as_str() { + "--resources" => o.resources = value.into(), + "--render-distance" | "--radius" => { + o.distance = value.parse()?; + o.distance_override = Some(o.distance); + let max = horizon_client::options_file::MAX_RENDER_DISTANCE; + if !(2..=max).contains(&o.distance) { + return Err(format!("render distance must be 2..{max}").into()); + } } - } - "--seed" => { - o.seed = value.parse().unwrap_or_else(|_| { - // Text seeds hash like String.hashCode. - value - .encode_utf16() - .fold(0i32, |h, c| { - h.wrapping_mul(31).wrapping_add(i32::from(c)) - }) - .into() - }) - } - "--frames" => o.frames = Some(value.parse()?), - "--world-frames" => { - o.world_frames = Some(value.parse()?); - // A frame-limited run in every other respect; --timeout-seconds still applies. - o.frames.get_or_insert(u64::MAX); - } - "--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?), - "--smoke-report" => o.report = Some(value.into()), - "--screenshot-after" => o.screenshot_after = Some(value.parse()?), - "--width" => o.window_size[0] = value.parse::()?.max(1), - "--height" => o.window_size[1] = value.parse::()?.max(1), - "--world" => o.world = Some(value), - // Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a - // world that exists opens; else the title screen. --world also creates one. - "--quickPlaySingleplayer" => { - let folder = std::env::current_dir()?.join("saves").join(&value); - if folder.join("level.dat").is_file() { - o.world = Some(value); - } else { - eprintln!("Quick play: there is no world {value} in saves/"); + "--seed" => { + o.seed = value.parse().unwrap_or_else(|_| { + // Text seeds hash like String.hashCode. + value + .encode_utf16() + .fold(0i32, |h, c| { + h.wrapping_mul(31).wrapping_add(i32::from(c)) + }) + .into() + }) } - } - "--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?), - "--benchmark" => { - if horizon_client::benchmark::scenes(&value).is_none() { - return Err(format!( + "--frames" => o.frames = Some(value.parse()?), + "--world-frames" => { + o.world_frames = Some(value.parse()?); + // A frame-limited run in every other respect; --timeout-seconds still applies. + o.frames.get_or_insert(u64::MAX); + } + "--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?), + "--smoke-report" => o.report = Some(value.into()), + "--screenshot-after" => o.screenshot_after = Some(value.parse()?), + "--width" => o.window_size[0] = value.parse::()?.max(1), + "--height" => o.window_size[1] = value.parse::()?.max(1), + // Vanilla opens only a level id that is one folder of saves/. --world, for + // harnesses, also takes a path (tools/bundle_tooltip_e2e.py). + "--world" => o.world = Some(value), + "--quickPlaySingleplayer" => { + let mut parts = std::path::Path::new(&value).components(); + if !matches!( + (parts.next(), parts.next()), + (Some(std::path::Component::Normal(_)), None) + ) { + return Err("not a folder name of saves/".into()); + } + o.quick_play = Some(value); + } + "--game-dir" | "--gameDir" => o.game_dir = Some(value.into()), + // main has made it the game home already (logging::set_game_home). + "--home" => {} + "--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?), + "--benchmark" => { + if horizon_client::benchmark::scenes(&value).is_none() { + return Err(format!( "unknown benchmark {value}: standard, village, forest, ocean or mountain" ) .into()); + } + o.benchmark = Some(value); } - o.benchmark = Some(value); + "--server" | "--quickPlayMultiplayer" => o.server = Some(value), + "--name" => o.player_name = Some(value), + "--session-file" => o.session_file = Some(value), + "--control-socket" => o.control = Some(value.into()), + "--time" => o.time = Some(value.parse()?), + "--weather" => o.weather = Some(value), + "--dimension" => { + o.dimension = Some(if value.contains(':') { + value + } else { + format!("minecraft:{value}") + }) + } + "--look" => { + let (y, p) = + value.split_once(',').ok_or("--look takes YAW,PITCH")?; + o.look = Some((y.parse()?, p.parse()?)); + } + _ => return Err("unknown option".into()), } - "--server" | "--quickPlayMultiplayer" => o.server = Some(value), - "--name" => o.player_name = Some(value), - "--session-file" => session_file = Some(value), - "--control-socket" => o.control = Some(value.into()), - "--time" => o.time = Some(value.parse()?), - "--weather" => o.weather = Some(value), - "--dimension" => { - o.dimension = Some(if value.contains(':') { - value - } else { - format!("minecraft:{value}") - }) - } - "--look" => { - let (y, p) = value.split_once(',').ok_or("--look takes YAW,PITCH")?; - o.look = Some((y.parse()?, p.parse()?)); - } - _ => return Err(format!("unknown option {arg}").into()), - } + Ok(()) + }; + read().map_err(|e| format!("{arg}: {e}"))?; } } } - // The launcher's account: online-mode servers need it. A bad file - // only leaves the game offline. - if let Some(path) = session_file.filter(|p| !p.is_empty()) { - match horizon_client::auth::AuthSession::load(path.as_ref()) { - Ok(session) => { - o.player_name = Some(session.name.clone()); - horizon_client::auth::set_current(session); - } - Err(e) => eprintln!("Horizon: not signed in: {e}"), - } - } Ok(Some(o)) } } @@ -685,6 +764,8 @@ struct GameLevel<'w> { /// `Level.isClientSide`: a client's copy of the level (a guest of a LAN /// world, a window on its server thread, a dedicated server's client). client_side: bool, + /// Only the integrated or dedicated server dispatches native observations. + native_authoritative: bool, } struct PlayerCollision { y: f64, @@ -871,6 +952,22 @@ impl GameLevel<'_> { } } impl LevelAccess for GameLevel<'_> { + fn observe_block_changed(&self, pos: BlockPos, old: BlockStateId, new: BlockStateId) { + if self.native_authoritative { + self.native_block_changed(self.dimension, pos, old, new); + } + } + fn observe_neighbor_changed( + &self, + pos: BlockPos, + state: BlockStateId, + changed: horizon_world::level::BlockId, + moved: bool, + ) { + if self.native_authoritative { + self.native_neighbor_changed(self.dimension, pos, state, changed, moved); + } + } fn is_client_side(&self) -> bool { self.client_side } @@ -998,7 +1095,10 @@ impl LevelAccess for GameLevel<'_> { self.world.block(pos.x, pos.y, pos.z) } fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option { - if !self.world.loaded(pos.x, pos.z) || pos.y < MIN_Y || pos.y >= MIN_Y + HEIGHT { + if !self.world.loaded(pos.x, pos.z) + || pos.y < self.world.bottom_y() + || pos.y >= self.world.top_y() + { return None; } let previous = self.world.block(pos.x, pos.y, pos.z); @@ -1009,19 +1109,11 @@ impl LevelAccess for GameLevel<'_> { self.world.loaded(pos.x, pos.z) } fn min_y(&self) -> i32 { - if self.dimension != LevelDimension::Overworld { - 0 - } else { - MIN_Y - } + self.world.shape.min_y } fn max_y(&self) -> i32 { // Exclusive, as LevelAccess::max_y documents. - if self.dimension != LevelDimension::Overworld { - 256 - } else { - MIN_Y + HEIGHT - } + self.world.shape.max_y() } fn random(&mut self) -> &mut horizon_core::random::LegacyRandom { &mut self.world.random @@ -1161,6 +1253,13 @@ impl LevelAccess for GameLevel<'_> { // player is this client's own: the block's `playSound(null, ...)` // (a piston's move, a note) reaches it from the server instead. LevelEvent::Sound { .. } if self.client_side => {} + // NoteBlock.triggerEvent plays the head's sound with a null + // player, so only the server's level plays it; a guest or a + // remote client hears it from the sound packet. + LevelEvent::BlockEvent { + kind: "note_custom_sound", + .. + } if self.client_side => {} // Block.popResource of the block's own item (tall flowers, flower beds). LevelEvent::BlockEvent { pos, @@ -1522,6 +1621,9 @@ struct Game<'a> { pending_entry: Option, world_loader: Option, pending_pack_reload: bool, + /// A pack reload staged on a worker (`LoadingOverlay`): the frame draws + /// its progress until `drive_pack_reload` applies it. + pack_reload: Option, /// A server's resource packs (game/server_packs.rs). server_packs: server_packs::ServerPackState, /// The link a chat click asks to open (`ConfirmLinkScreen`). @@ -1663,6 +1765,9 @@ struct Game<'a> { /// Controllers (Horizon's own). pad: pad::Pads, frames: u64, + /// The glint clock and options, read once at the start of each frame + /// (game/item_draw.rs). + glint_frame: horizon_client::item_render::GlintFrame, /// `item_level` for one frame, game time, day time and dimension. item_level_cache: std::cell::RefCell< Option<( @@ -1794,6 +1899,9 @@ struct Game<'a> { /// LocalPlayer (joining, respawning, changing dimension) starts at 0. sway: horizon_client::first_person::HandSway, visible_hands: [(usize, Option); 2], + /// Whether the main and off hand's slots were set to a new stack that + /// the hand doesn't show yet (`Game::hand_stack_replaced`). + hands_replaced: [bool; 2], /// `Minecraft`'s attack handling, attack clicks since the last tick and /// the attack strength tickers the hand reads. clicks: horizon_client::first_person::Clicks, @@ -2170,25 +2278,15 @@ impl Dim { } /// `Level.getMinY`: the dimension type's minimum y. fn min_y(self) -> i32 { - match self { - Self::Overworld => MIN_Y, - Self::Nether | Self::End => 0, - } + self.shape().min_y } /// `DimensionType.logicalHeight`. fn logical_height(self) -> i32 { - match self { - Self::Overworld => HEIGHT, - Self::Nether => 128, - Self::End => 256, - } + self.shape().logical_height } /// `Level.getMaxY`: the dimension type's minimum y plus height, less one. fn max_y(self) -> i32 { - match self { - Self::Overworld => MIN_Y + HEIGHT - 1, - Self::Nether | Self::End => 255, - } + self.shape().max_y() - 1 } } fn facing_offset(facing: &str) -> [i32; 3] { @@ -2254,6 +2352,14 @@ impl Game<'_> { /// crosshair target (blocks, fluids, entities, mod raycasts) is found /// along this ray, so a tracked controller can stand in for it. fn aim(&self) -> Option<([f64; 3], [f64; 3])> { + // Minecraft.pick: from the camera entity's eyes along its view + // (LivingEntity.getViewYRot: the head's yaw). + if let Some(m) = self.camera_mob() { + return Some(( + [m.position.x, m.eye_y(), m.position.z], + view_vector(m.head_yaw, m.head_pitch), + )); + } Some((self.eye_position()?, view_vector(self.yaw, self.pitch))) } fn reach(&self) -> f64 { @@ -3193,6 +3299,7 @@ impl Game<'_> { random_tick_sections: &mut self.random_tick_sections, lid_openers: &self.lid_openers, client_side: self.remote.is_some(), + native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(), }; let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks); self.flush_spill(); @@ -3221,6 +3328,73 @@ impl Game<'_> { } self.process_level_events() } + /// `AbstractCandleBlock.extinguish`: the smoke from each wick, at + /// CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1. + fn candle_smoke(&mut self, pos: [i32; 3], state: BlockStateId) { + use horizon_client::particles::{ParticleOptions, ParticleType}; + let candles = self + .pack + .assets + .registry + .state(state) + .and_then(|s| s.properties.get("candles")?.parse::().ok()) + .unwrap_or(1); + const OFFSETS: [&[[u8; 3]]; 4] = [ + &[[8, 8, 8]], + &[[6, 7, 8], [10, 8, 7]], + &[[8, 5, 10], [6, 7, 8], [9, 8, 7]], + &[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]], + ]; + for o in OFFSETS[candles.clamp(1, 4) - 1] { + self.particles.add( + &ParticleOptions::simple(ParticleType::Smoke), + [ + f64::from(pos[0]) + f64::from(o[0]) / 16.0, + f64::from(pos[1]) + f64::from(o[1]) / 16.0, + f64::from(pos[2]) + f64::from(o[2]) / 16.0, + ], + [0.0, 0.1, 0.0], + ); + } + } + /// `LeverBlock.makeParticle` at full scale: a powered lever puffs one red + /// dust on the clicking client (`LeverBlock.useWithoutItem`). + fn lever_puff(&mut self, pos: [i32; 3]) { + // The server's side of a guest's pull draws nothing. + if self.lan_acting.is_some() { + return; + } + let state = self.world.block(pos[0], pos[1], pos[2]); + let Some(registry_state) = self.pack.assets.registry.state(state) else { + return; + }; + let prop = |key: &str| registry_state.properties.get(key).map(String::as_str); + if prop("powered") != Some("true") { + return; + } + // FACING is horizontal; getConnectedDirection is the face's direction. + let facing = match prop("facing") { + Some("north") => [0, 0, -1], + Some("south") => [0, 0, 1], + Some("west") => [-1, 0, 0], + Some("east") => [1, 0, 0], + _ => return, + }; + let connected = match prop("face") { + Some("ceiling") => [0, -1, 0], + Some("floor") => [0, 1, 0], + _ => facing, + }; + let at = |base: i32, i: usize| { + f64::from(base) + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i]) + }; + let p = [at(pos[0], 0), at(pos[1], 1), at(pos[2], 2)]; + self.particles.add( + &horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), + p, + [0.0; 3], + ); + } /// Track which chunks are ticking (Ticks needs chunk load/unload). fn sync_tick_chunks(&mut self) { if !self.tick_chunks.synchronize(&self.world.chunks) { @@ -3958,6 +4132,35 @@ impl Game<'_> { [note / 24.0, 0.0, 0.0], ); } + // NoteBlock.getCustomSoundId: the skull above's note block sound, + // played at volume 3 (vanilla's pitch for a head is 1). + LevelEvent::BlockEvent { + pos, + kind: "note_custom_sound", + .. + } => { + if let Some(BlockEntity::Skull(skull)) = + self.world.block_entities.get(&(pos.x, pos.y + 1, pos.z)) + && let Some(sound) = skull.note_block_sound.as_deref() + { + let at = [ + f64::from(pos.x) + 0.5, + f64::from(pos.y) + 0.5, + f64::from(pos.z) + 0.5, + ]; + self.audio.play(sound, Some(at), 3.0, 1.0); + } + } + // `level.addParticle`: only the local player's own click draws it. + LevelEvent::BlockEvent { + kind: "candle_smoke", + .. + } if self.lan_acting.is_some() => {} + LevelEvent::BlockEvent { + pos, + state, + kind: "candle_smoke", + } => self.candle_smoke([pos.x, pos.y, pos.z], state), LevelEvent::GrowTree { .. } | LevelEvent::PlaceFeature { .. } | LevelEvent::BlockEvent { .. } => {} @@ -3983,10 +4186,6 @@ impl Game<'_> { return; } if horizon_client::command_block::Mode::of_block(&name).is_some() { - // `ServerPlayer.openCommandBlock` sends the block entity's data - // (`ClientboundBlockEntityDataPacket`) so the opening client shows - // the stored command, not its stale copy. - self.mark_block_entity_updated(key); self.open_command_block(key); return; } @@ -4151,7 +4350,7 @@ impl Game<'_> { // FallingBlockEntity.tick: off the ground, it gives up after 600 // ticks (100 outside the world) and drops itself as an item. let y = next.y.floor() as i32; - let outside = y <= horizon_client::chunk::MIN_Y || y >= horizon_client::chunk::MIN_Y + 384; + let outside = y <= self.world.bottom_y() || y >= self.world.top_y(); if !on_ground && (f.age > 100 && outside || f.age > 600) { return Ok(if f.drop_item && entity_drops { FallStep::Dropped(next) @@ -4444,6 +4643,16 @@ impl Game<'_> { xp, ); } + // TurtleEggBlock.playerDestroy: after the drops, one egg of a + // mined stack is taken and the rest stay. + self.with_level(|b, level, ticks| { + b.turtle_egg_destroyed_by_player( + level, + ticks, + BlockPos::new(pos[0], pos[1], pos[2]), + broken, + ); + }); } // IceBlock.playerDestroy: the ice melts back into water (the // #prevents_ice_melting tool tag holds only silk touch). @@ -4693,6 +4902,7 @@ impl Game<'_> { match &*name { "minecraft:note_block" => self.stats.custom("tune_noteblock", 1), "minecraft:bell" => self.stats.custom("bell_ring", 1), + "minecraft:lever" => self.lever_puff(hit.pos), _ => {} } } @@ -5218,6 +5428,7 @@ impl Game<'_> { ticks: 0, }); } + let block_break_speed = self.block_break_speed(); let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); let facts = self.res.facts().get(state); if facts.hardness < 0.0 { @@ -5232,6 +5443,7 @@ impl Game<'_> { let held = self.held(); let mut speed = self.items().mining_speed(held, &facts.name); speed = self.modify_destroy_speed(speed); + speed *= block_break_speed; let player = self.player.as_ref().ok_or("no player")?; if !player.ground.on_ground { speed /= 5.0; @@ -5295,8 +5507,8 @@ impl Game<'_> { } return None; } - let mut y = MIN_Y + HEIGHT - 1; - while y > MIN_Y { + let mut y = self.world.top_y() - 1; + while y > self.world.bottom_y() { let s = self.world.block(x, y, z); if !self.res.facts().is_air(s) { let f = self.res.facts().get(s); @@ -5469,6 +5681,9 @@ impl Game<'_> { self.tick_debug_keys()?; self.tick_debug_charts(); self.tick_tutorial(); + if self.screen == Screen::Creative { + self.refresh_creative_tabs(); + } if self.remote.is_some() { return self.tick_remote(); } @@ -5484,6 +5699,9 @@ impl Game<'_> { self.track_experience_display(); // `/tick freeze` stops the world but not the player. let running = self.clock.world_runs(); + if self.serving.is_some() || self.options.dedicated.is_some() { + self.native_lifecycle_server_tick_start(running); + } // GameRenderer.tick: a wither's bar darkens the world, then fades; // the activated item counts down. self.boss_overlay.tick_darken(running); @@ -5791,7 +6009,7 @@ impl Game<'_> { let Some(player) = self.player.as_mut() else { return Ok(()); }; - if player.position.y < f64::from(MIN_Y - 64) { + if player.position.y < f64::from(self.world.bottom_y() - 64) { // Out of the world. let _ = player.set_health(0.0, false); } @@ -6679,6 +6897,14 @@ impl Game<'_> { } batch } + /// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks` + /// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`, + /// as the screen's constructor and `containerTick` do. + fn refresh_creative_tabs(&mut self) { + let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab; + self.creative_screen + .set_permissions(permissions, &self.pack.names); + } fn creative_inventory_open(&self) -> bool { self.screen == Screen::Creative && self.creative_screen.tab().inventory } @@ -8911,8 +9137,9 @@ impl Game<'_> { let mut best: Option<(usize, f64)> = None; for (i, m) in self.mobs.iter().enumerate() { // ProjectileUtil.getEntityHitResult: the mob the player rides - // shares its root vehicle and is never the hit. - if !m.alive() || Some(m.id) == self.riding { + // shares its root vehicle and is never the hit, nor is the + // camera entity the pick starts from. + if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() { continue; } if let Some(t) = ray_box(eye, dir, reach, m.bounds()) @@ -9731,6 +9958,7 @@ impl Game<'_> { let (streaming, save) = self.make_streaming(&self.world_root.clone(), target, self.level.seed)?; let mut world = World::new(self.res.facts().air, self.level.seed); + world.shape = target.shape(); world.fast_lava = target == Dim::Nether; world.game_time = self.world.game_time; world.day_time = self.world.day_time; @@ -11161,7 +11389,7 @@ impl Game<'_> { // renderArmWithItem: only the main hand's empty arm shows, // on the main arm (the left one with the main hand set to // left), and not while the player is invisible. - if hand == Hand::Off || self.potions.effects.get("invisibility").is_some() { + if hand == Hand::Off || self.own_invisible() { continue; } // renderPlayerArm @@ -11175,7 +11403,7 @@ impl Game<'_> { let hold = MapHold::of(hand, off_hand_empty); // renderOneHandedMap and renderTwoHandedMap: the arms under // the map, unless the player is invisible. - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack) { self.first_person_arm(arm, to_world * pose, packed, &mut out); @@ -11351,7 +11579,7 @@ impl Game<'_> { /// `LevelExtractor.allChanged`: every loaded section is rebuilt. fn all_sections_changed(&mut self) { for &(x, z) in self.world.chunks.keys() { - for sy in 0..horizon_client::chunk::HEIGHT / 16 { + for sy in 0..self.world.layout().height() / 16 { self.world.dirty_sections.insert((x, sy, z)); } } @@ -12224,6 +12452,7 @@ impl Game<'_> { }); } fn render(&mut self) -> Result { + self.glint_frame = self.capture_glint_frame(); if self.renderer.as_mut().is_some_and(|r| r.display_changed()) { self.apply_ui_settings(); } @@ -12278,9 +12507,9 @@ impl Game<'_> { } return Ok(false); } - // A server pack's reload covers the frame with its progress - // (`LoadingOverlay`) until `tick_server_packs` applies it. - if let Some(reload) = &self.server_packs.reload { + // A pack reload covers the frame with its progress + // (`LoadingOverlay`) until `drive_pack_reload` applies it. + if let Some(reload) = &self.pack_reload { if let Some(renderer) = &mut self.renderer { let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]); horizon_client::level_loading::draw_progress( @@ -12634,8 +12863,8 @@ impl Game<'_> { debug_lines_on_top: &debug_lines_on_top, debug_crosshair: &debug_crosshair, hud: &hud, - glint_alpha: self.settings.file.double("glintStrength") as f32, - glint_ticks: self.glint_ticks(), + glint_alpha: self.glint_frame.alpha, + glint_ticks: self.glint_frame.scroll_ticks(), clouds: (!title && self.dim == Dim::Overworld && horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3])) @@ -12911,6 +13140,7 @@ impl Game<'_> { } if self.creative { self.screen = Screen::Creative; + self.refresh_creative_tabs(); self.creative_screen.reopen(); self.refresh_saved_hotbars(); } else { @@ -12946,6 +13176,8 @@ impl Game<'_> { // handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for // the off hand, then the main hand. std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]); + self.hand_stack_replaced(self.selected); + self.hand_stack_replaced(shield::OFFHAND_SLOT); let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone()); self.player_on_equip("offhand", main.as_ref(), offhand.as_ref()); self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref()); @@ -13313,8 +13545,8 @@ impl ApplicationHandler for Game<'_> { if self.window.is_some() { return; } - // The Window constructor: created fullscreen when options.txt says so. - let fullscreen = self.settings.fullscreen; + // The Window constructor: created fullscreen when options.txt or --fullscreen says so. + let fullscreen = self.settings.fullscreen || self.options.fullscreen; let size = self.options.window_size.map(f64::from); self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen); let attributes = Window::default_attributes() @@ -13461,8 +13693,8 @@ impl ApplicationHandler for Game<'_> { return; } // The pack reload's progress overlay takes no input - // (`LoadingOverlay`): `start_pack_reload` released what was held. - if self.server_packs.reload.is_some() + // (`LoadingOverlay`): `spawn_pack_reload` released what was held. + if self.pack_reload.is_some() && !matches!( &event, WindowEvent::CloseRequested @@ -14501,6 +14733,7 @@ impl<'a> Game<'a> { pending_entry: None, world_loader: None, pending_pack_reload: false, + pack_reload: None, server_packs: Default::default(), link_prompt: None, code_of_conduct: None, @@ -14596,6 +14829,7 @@ impl<'a> Game<'a> { last_input: now, pad: Default::default(), frames: 0, + glint_frame: Default::default(), item_level_cache: Default::default(), world_frames: 0, fps: 0.0, @@ -14678,6 +14912,7 @@ impl<'a> Game<'a> { heights: Default::default(), sway: Default::default(), visible_hands: [(0, None), (0, None)], + hands_replaced: [false; 2], clicks: Default::default(), attack_clicks: 0, hand_strength: Default::default(), @@ -15983,8 +16218,9 @@ impl Loading { if self.window.is_some() { return; } - // The Window constructor: created fullscreen when options.txt says so. - let fullscreen = self.settings.fullscreen; + // The Window constructor: created fullscreen when options.txt or --fullscreen says so. + let fullscreen = + self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen); let size = self .options .as_ref() @@ -16353,6 +16589,8 @@ fn run_loading( Ok(()) } +pub use launch_api::{UsageError, exit_code}; + pub fn run() -> Result<()> { let Some(mut options) = Options::read()? else { return Ok(()); @@ -16752,7 +16990,10 @@ mod tests { fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() { let mut creative = Creative::from_json(&serde_json::json!({ "schema": 2, - "tabs": [{"type": "CATEGORY"}, {"type": "INVENTORY"}] + "tabs": [ + {"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]}, + {"type": "INVENTORY"} + ] })) .unwrap(); for button in [Button::Left, Button::Right] { diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 5f56184b56..5a7ce481b4 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -7,7 +7,6 @@ use super::{Game, Screen}; use horizon_client::{ Result, block_entity_anim::Ticker, - chunk::{MIN_Y, SECTIONS}, items::ItemStack, mobs::{ Species, @@ -549,7 +548,7 @@ impl Game<'_> { selected, } => json!({ "mode": "select-world", - "worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::>(), + "worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::>(), "selected": selected, }), Mode::Create { @@ -603,6 +602,17 @@ impl Game<'_> { Mode::BackupBeforeUpgrade { world, .. } => { json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name }) } + Mode::Recover { world, issues } => { + let issue = |i: &Option| match i { + None => json!(null), + Some(horizon_client::save::DatIssue::Missing) => json!("missing file"), + Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why), + }; + json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) }) + } + Mode::Recovered { world, success } => { + json!({ "mode": "recovery-done", "folder": world.folder, "success": success }) + } Mode::Error { message, .. } => { json!({ "mode": "world-operation-error", "message": message }) } @@ -1088,7 +1098,9 @@ impl Game<'_> { .world .chunks .get(&(x >> 4, z >> 4)) - .filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y)) + .filter(|_| { + (self.world.bottom_y()..self.world.top_y()).contains(&y) + }) .map(|c| c.light(x & 15, y, z & 15)); let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.'); sky.push(packed.map_or('.', |v| digit(v >> 4))); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 7019dc8245..c7f6d10bbc 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -635,17 +635,23 @@ impl Game<'_> { } } for i in dead.into_iter().rev() { - let a = self.armor_stands.remove(i); - let at = [a.position.x, a.position.y, a.position.z]; - let (pos, drops) = burnt_armor_stand_equipment(a); - for stack in drops { - self.pop_resource(pos, stack); - } - self.audio - .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); - self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + self.break_armor_stand_by_anything(i); } } + /// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast + /// breaks a stand: its equipment pops above its block, the stand item does + /// not drop. + pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) { + let a = self.armor_stands.remove(i); + let at = [a.position.x, a.position.y, a.position.z]; + let (pos, drops) = broken_armor_stand_equipment(a); + for stack in drops { + self.pop_resource(pos, stack); + } + self.audio + .play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0); + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + } /// ArmorStandRenderer: the stand with its default pose, then armor layers. pub(super) fn armor_stand_geometry( &self, @@ -1055,7 +1061,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> { }) } -fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { +/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer` +/// refusing an invisible or marker stand: such a stand outlasts any blast. +pub(super) fn spared_by_blast(a: &ArmorStand) -> bool { + a.invisible || a.marker +} + +/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing +/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`. +fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { let pos = [ a.position.x.floor() as i32, a.position.y.floor() as i32 + 1, @@ -1073,6 +1087,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec) { mod burn_drop_tests { use super::*; #[test] + fn only_visible_non_marker_stands_are_broken_by_blasts() { + let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0); + assert!(!spared_by_blast(&stand)); + stand.invisible = true; + assert!(spared_by_blast(&stand)); + stand.invisible = false; + stand.marker = true; + assert!(spared_by_blast(&stand)); + } + #[test] fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() { let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0); let kept = ItemStack::new("minecraft:diamond_boots", 1); @@ -1083,7 +1107,7 @@ mod burn_drop_tests { .push(("minecraft:vanishing_curse".into(), 1)); stand.equipment[0] = Some(kept.clone()); stand.equipment[1] = Some(vanished); - let (pos, drops) = burnt_armor_stand_equipment(stand); + let (pos, drops) = broken_armor_stand_equipment(stand); assert_eq!(pos, [-1, 5, 0]); assert_eq!(drops, vec![kept]); } diff --git a/crates/horizon-client/src/game/beacon.rs b/crates/horizon-client/src/game/beacon.rs index a67a9f2341..52fe19f8be 100644 --- a/crates/horizon-client/src/game/beacon.rs +++ b/crates/horizon-client/src/game/beacon.rs @@ -177,7 +177,7 @@ impl Game<'_> { } } if b.last_check_y >= surface { - b.last_check_y = super::MIN_Y - 1; + b.last_check_y = self.world.bottom_y() - 1; b.sections = std::mem::take(&mut b.checking); let (was, is) = (previous > 0, b.levels > 0); if !was && is { @@ -231,7 +231,7 @@ impl Game<'_> { let mut levels = 0; for step in 1..=4 { let ly = y - step; - if ly < super::MIN_Y { + if ly < self.world.bottom_y() { break; } let ok = (x - step..=x + step).all(|lx| { diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index b40051eb83..a56471c90f 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -461,7 +461,7 @@ impl Game<'_> { if is_conduit(section.get(i)) { found.push([ cx * 16 + (i & 15) as i32, - horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32, + self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32, cz * 16 + ((i >> 4) & 15) as i32, ]); } diff --git a/crates/horizon-client/src/game/block_items.rs b/crates/horizon-client/src/game/block_items.rs index 80e75c5206..58b23ab8bc 100644 --- a/crates/horizon-client/src/game/block_items.rs +++ b/crates/horizon-client/src/game/block_items.rs @@ -8,7 +8,8 @@ use horizon_client::{ items::ItemStack, save, }; use horizon_world::{ - nbt::{Compound, Tag}, + level::classes::Class, + nbt::{Compound, JavaString, Tag}, registry::BlockStateId, }; @@ -45,7 +46,17 @@ impl Game<'_> { if &*facts.item == "minecraft:air" { return None; } - let mut stack = ItemStack::new(&facts.item, 1); + // FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant + // (`new ItemStack(potted)`), an empty pot as the pot itself. + let behaviours = self.res.behaviours(); + let plant = behaviours.data(state); + let plant_item = plant + .is(Class::FlowerPot) + .then_some(plant.link) + .flatten() + .map(|block| behaviours.block_data(block).item.clone()) + .filter(|item| &**item != "minecraft:air"); + let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1); let mut patch = Compound::new(); // BeehiveBlock.getCloneItemStack: the honey level as the item's block state. if include_data @@ -62,16 +73,53 @@ impl Game<'_> { Tag::compound([("honey_level", Tag::String(level.as_str().into()))]), ); } + // CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's + // block_state (`CopperGolemStatueBlockEntity.getItem`), data or not. + if let Some(pose) = self + .pack + .assets + .registry + .state(state) + .filter(|s| s.name.ends_with("copper_golem_statue")) + .and_then(|s| s.properties.get("copper_golem_pose")) + { + patch.insert( + "minecraft:block_state".into(), + Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]), + ); + } + // LightBlock.getCloneItemStack, setLightOnStack: the light's level as + // the item's block state, whether or not the block data is picked. + if let Some(level) = self + .pack + .assets + .registry + .state(state) + .filter(|s| s.name.as_str() == "minecraft:light") + .and_then(|s| s.properties.get("level")) + { + patch.insert( + "minecraft:block_state".into(), + Tag::compound([("level", Tag::String(level.as_str().into()))]), + ); + } // AbstractBannerBlock.getCloneItemStack: the banner's own item // (BannerBlockEntity.getItem), data or not. let banner = matches!( self.world.block_entities.get(&key), Some(BlockEntity::Banner(_)) ); - if (include_data || banner) + // DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the + // sherds only; the pot's item comes with include_data. + let pot = &*facts.name == "minecraft:decorated_pot"; + if (include_data || banner || pot) && let Some(saved) = self.saved_block_entity(key) { - patch.extend(block_entity_item::pick(&saved)); + let mut picked = block_entity_item::pick(&saved); + if pot && !include_data { + picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations")); + } + patch.extend(picked); } if !patch.is_empty() { let mut all = components(&stack); diff --git a/crates/horizon-client/src/game/chat.rs b/crates/horizon-client/src/game/chat.rs index 09d0d9d0bd..020df57e7f 100644 --- a/crates/horizon-client/src/game/chat.rs +++ b/crates/horizon-client/src/game/chat.rs @@ -474,6 +474,7 @@ impl Game<'_> { if let Some(names) = self.remote_online_names() { self.ui.registries.players = names; } + self.ui.registries.custom_completions = self.remote_custom_completions(); } /// `ChatComponent.createScreen`: T or / opens the chat with the draft kept /// last time, when this method can restore it (a command draft only for @@ -1204,7 +1205,7 @@ impl Game<'_> { let Some(p) = self.player.as_ref() else { return; }; - let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64); + let below_world = p.position.y < f64::from(self.world.bottom_y() - 64); let name = self.player_display_text(); let message = match cause { Some(key) => Text::translate(&key, &[name]), diff --git a/crates/horizon-client/src/game/client_ambient.rs b/crates/horizon-client/src/game/client_ambient.rs index 343390a1af..bad8b24d13 100644 --- a/crates/horizon-client/src/game/client_ambient.rs +++ b/crates/horizon-client/src/game/client_ambient.rs @@ -144,7 +144,7 @@ impl Game<'_> { .filter_map(|e| effect_particle(e.id, e.ambient)) .collect(); let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient); - let invisible = effects.iter().any(|e| e.id == "invisibility"); + let invisible = self.own_invisible(); let size = ( f64::from(p.dimensions.width), f64::from(p.dimensions.height), diff --git a/crates/horizon-client/src/game/command_blocks.rs b/crates/horizon-client/src/game/command_blocks.rs index 3ca65c40bc..c3adf0b3be 100644 --- a/crates/horizon-client/src/game/command_blocks.rs +++ b/crates/horizon-client/src/game/command_blocks.rs @@ -30,7 +30,7 @@ type Pos = (i32, i32, i32); /// The open `CommandBlockEditScreen`. pub(super) struct CommandBlockScreen { - pub(super) pos: Pos, + pos: Pos, /// The command box with its `CommandSuggestions`. command: ChatScreen, /// When the command box was focused, for its cursor's blink. @@ -112,6 +112,7 @@ impl CommandBlockScreen { self.previous.set_value(&shown); } } + fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> { registry.state(state) } @@ -240,21 +241,21 @@ impl Game<'_> { let at = BlockPos::new(pos.0, pos.1, pos.2); self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at)); } - /// `CommandBlockEditScreen.updateGui`: the server's block-entity data - /// arrived while the screen is open; refresh the fields from the entity. - /// The mode and conditional live in the block state here (vanilla's - /// `CommandBlockEntity.Mode`). - pub(super) fn update_command_block_screen(&mut self) { - let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else { + /// `ClientPacketListener.handleBlockEntityData` then + /// `CommandBlockEditScreen.updateGui`: new data for the command block the + /// edit screen shows refreshes its fields. The mode and conditional live in + /// the block state here (vanilla's `CommandBlockEntity.Mode`). + pub(super) fn command_block_data_arrived(&mut self, pos: Pos) { + if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) { return; - }; - let state = self.block_state_at(screen_pos).map(|s| { + } + let state = self.block_state_at(pos).map(|s| { ( Mode::of_block(&s.name).unwrap_or(Mode::Redstone), conditional(s), ) }); - let block = self.command_block(screen_pos).cloned(); + let block = self.command_block(pos).cloned(); let m = Measure(&self.gui.atlas.font); let c = Commands { dispatcher: super::chat::chat_tree(&self.remote), @@ -825,18 +826,6 @@ impl Game<'_> { }), ); } - /// `ClientPacketListener.handleBlockEntityData`: new data for the command - /// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`). - pub(super) fn command_block_data_arrived(&mut self, pos: Pos) { - let open = self.screen == Screen::CommandBlock - && self - .command_block_screen - .as_ref() - .is_some_and(|s| s.pos == pos); - if open { - self.open_command_block(pos); - } - } /// What `handleSetCommandBlock` does once the player may edit the block. fn apply_command_block(&mut self, s: &CommandBlockEdit) { let pos = s.pos; diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index cea056268c..582e4be1bf 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -301,9 +301,7 @@ impl Game<'_> { if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) { return Err(err("argument.pos.unloaded", &[])); } - if p[1] < horizon_client::chunk::MIN_Y - || p[1] >= horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - { + if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() { return Err(err("argument.pos.outofworld", &[])); } Ok(p) @@ -689,7 +687,14 @@ impl Game<'_> { .iter() // The kinds /data can write (entity_nbt). .filter(|p| { - p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball) + p.is_arrow() + || matches!( + p.kind, + K::LargeFireball { .. } + | K::SmallFireball + | K::WindCharge + | K::BreezeWindCharge + ) }) .map(|p| Target::Projectile(p.id)), ); @@ -719,6 +724,27 @@ impl Game<'_> { }); dims } + /// `Level.getEntity`: the entity of the level in place with a network + /// id, players aside. + pub(in crate::game) fn level_entity(&self, id: i32) -> Option { + let id = u32::try_from(id).ok()?; + [ + Target::Mob(id), + Target::Item(id), + Target::Orb(id), + Target::Projectile(id), + Target::Stand(id), + Target::Frame(id), + Target::Painting(id), + Target::Boat(id), + Target::Cart(id), + Target::Other(id), + Target::Crystal(id), + Target::Dragon(id), + ] + .into_iter() + .find(|t| self.level_holds(*t)) + } /// Whether the level in place holds the entity a target names. fn level_holds(&self, t: Target) -> bool { match t { @@ -1034,6 +1060,11 @@ impl Game<'_> { let custom = s.data().and_then(|data| data.custom_name.as_ref()); let hover = if let Some(c) = custom { horizon_client::component::name_text(c) + } else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) { + // `ItemStack.getItemName`: the `item_name` component wins over the + // item's own name (`Item.getName`), so a datapack's or a server's + // renaming shows here as it does on the tooltip's first line. + horizon_client::component::name_text(name) } else if let Some(title) = s .data() .and_then(|d| d.written.as_ref()) @@ -2966,7 +2997,7 @@ impl Game<'_> { if !section.blocks.maybe_has(&mut wanted_state) { continue; } - let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32; + let base_y = self.world.bottom_y() + 16 * i as i32; for j in 0..4096 { let state = section.get(j); let Some(kind) = cache.get(&state).copied().flatten() else { @@ -3028,8 +3059,8 @@ impl Game<'_> { radius: 6400, horizontal: 32, vertical: 64, - min_y: horizon_client::chunk::MIN_Y, - max_y: horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1, + min_y: self.world.bottom_y(), + max_y: self.world.top_y() - 1, }; let found = self .end diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 3657391e79..0999423210 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -318,6 +318,9 @@ impl Game<'_> { self.take_loot_table(key, &tag, &mut new); self.world.block_entities.insert(key, new); self.update_spawner_display(key); + // BlockDataAccessor.setData: setChanged and sendBlockUpdated(3), + // even when the block state itself stays the same. + self.mark_block_entity_updated(key); let state = self.world.block(p[0], p[1], p[2]); // Redraw what shows the block entity (signs, banners). let _ = self.world.set(p[0], p[1], p[2], state); diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index e0efcc094c..29ed6e7211 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -785,8 +785,8 @@ impl Game<'_> { } /// The highest block of a heightmap type at a column (`Level.getHeight`). fn heightmap_at(&self, kind: &str, x: i32, z: i32) -> i32 { - let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT; - for y in (horizon_client::chunk::MIN_Y..top).rev() { + let top = self.world.top_y(); + for y in (self.world.bottom_y()..top).rev() { let state = self.world.block(x, y, z); if state == self.world.air { continue; @@ -809,7 +809,7 @@ impl Game<'_> { return y + 1; } } - horizon_client::chunk::MIN_Y + self.world.bottom_y() } /// A `store` modifier: a new callback in the source's chain. fn store_source(&mut self, c: &Context, s: Source) -> R { diff --git a/crates/horizon-client/src/game/commands/terrain_cmds.rs b/crates/horizon-client/src/game/commands/terrain_cmds.rs index 73dfb4b63d..237c5fb86a 100644 --- a/crates/horizon-client/src/game/commands/terrain_cmds.rs +++ b/crates/horizon-client/src/game/commands/terrain_cmds.rs @@ -1,7 +1,6 @@ //! `/forceload`, `/fillbiome`, `/worldborder` and `/loot`. use super::{Arg, Context, Game, R, Target, err, items::Owner}; use horizon_client::{ - chunk::{HEIGHT, MIN_Y}, command::CommandError, items::ItemStack, scoreboard_cmd::{Holder, Tracker}, @@ -213,7 +212,8 @@ impl Game<'_> { if usize::from(current) == target { continue; } - let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1); + let qy = ((y - self.world.bottom_y()) >> 2) + .clamp(0, self.world.layout().height() / 4 - 1); if let Some(chunk) = self.world.chunks.get_mut(&key) { let chunk = std::sync::Arc::make_mut(chunk); chunk.set_biome_cell((x & 15) >> 2, qy, (z & 15) >> 2, target as u16); @@ -228,7 +228,7 @@ impl Game<'_> { } for key in &touched { self.world.modified.insert(*key); - for sy in 0..horizon_client::chunk::SECTIONS as i32 { + for sy in 0..self.world.layout().sections() as i32 { self.world.dirty_sections.insert((key.0, sy, key.1)); } } diff --git a/crates/horizon-client/src/game/commands/world_cmds.rs b/crates/horizon-client/src/game/commands/world_cmds.rs index 899286b2de..188b5b1c0b 100644 --- a/crates/horizon-client/src/game/commands/world_cmds.rs +++ b/crates/horizon-client/src/game/commands/world_cmds.rs @@ -202,10 +202,15 @@ impl Game<'_> { attacker, from, }; - self.mobs + let hurt = self + .mobs .iter_mut() .find(|m| m.id == id) - .is_some_and(|m| m.damage(amount, damage)) + .is_some_and(|m| m.damage(amount, damage)); + if hurt { + self.resolve_player_responsible(id, damage); + } + hurt } _ => false, }; @@ -1278,12 +1283,12 @@ impl Game<'_> { let respect = matches!(ctx.get("respectTeams"), Some(Arg::Bool(true))); let max_y = match ctx.get("maxHeight") { Some(Arg::Int(h)) => *h, - _ => horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT, + _ => self.world.top_y(), }; - if max_y < horizon_client::chunk::MIN_Y { + if max_y < self.world.bottom_y() { return Err(err( "commands.spreadplayers.failed.invalid.height", - &[&max_y, &horizon_client::chunk::MIN_Y], + &[&max_y, &self.world.bottom_y()], )); } let targets = self.entities(ctx, "targets", false)?; @@ -1484,7 +1489,7 @@ impl Game<'_> { let mut air2 = air(y); y -= 1; let mut air1 = air(y); - while y > horizon_client::chunk::MIN_Y { + while y > self.world.bottom_y() { y -= 1; let current = air(y); if !current && air1 && air2 { diff --git a/crates/horizon-client/src/game/creaking.rs b/crates/horizon-client/src/game/creaking.rs index 19b3442c27..8868662557 100644 --- a/crates/horizon-client/src/game/creaking.rs +++ b/crates/horizon-client/src/game/creaking.rs @@ -342,7 +342,7 @@ impl Game<'_> { let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32); let pos = ( key.0 * 16 + lx, - horizon_client::chunk::MIN_Y + si as i32 * 16 + ly, + self.world.bottom_y() + si as i32 * 16 + ly, key.1 * 16 + lz, ); self.world diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 4c8e95414b..2a0b44a6ae 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -107,12 +107,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> { hit.direction.normal(), )) } - fn in_water(&self, at: Vec3) -> bool { - self.w.fluid( - at.x.floor() as i32, - at.y.floor() as i32, - at.z.floor() as i32, - ) == Fluid::Water + /// Entity.updateFluidInteraction over the projectile's box, not the + /// block at its feet (#3072). + fn in_water(&self, bounds: Aabb) -> bool { + self.w.fluids(bounds, bounds.max_position().y).in_water } fn targets(&self) -> &[(Who, Aabb)] { &self.targets @@ -130,21 +128,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> { /// `AbstractWindCharge` is pickable, so a thrown pearl's /// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`). -const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1; +/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below +/// the dragon's reserved ids. +const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000; -fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> { +/// The index of the wind charge a `wind_charge_hitboxes` id stands for. +fn wind_charge_hit(id: u32) -> Option { + (WIND_CHARGE_IDS..super::dragon::SPECIAL) + .contains(&id) + .then(|| (id - WIND_CHARGE_IDS) as usize) +} + +fn wind_charge_hitboxes( + projectiles: &[Projectile], + alive: &[bool], + skip: usize, +) -> Vec<(Who, Aabb)> { projectiles .iter() .enumerate() .filter(|(j, q)| { *j != skip + && alive[*j] && !q.stuck && matches!( q.kind, ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge ) }) - .map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb())) + .map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb())) .collect() } @@ -354,6 +366,41 @@ impl Game<'_> { } Some(id) } + /// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already + /// removed: a new mob of `kind`, made without spawn finalization, takes + /// its place with what `conversion::single` carries over, and the old + /// mob's riders move onto it. A zombie that comes out of it rolls + /// `Zombie.handleAttributes(special, CONVERSION)` (the + /// `postConversionStep` of its `ConversionTracker`). + pub(super) fn convert_to( + &mut self, + from: &Mob, + kind: &str, + params: horizon_client::mobs::conversion::Params, + ) -> Option { + let t = self.res.mob_types().get(kind).cloned()?; + self.next_entity += 1; + let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw); + horizon_client::mobs::conversion::single(from, &mut mob, params); + if matches!(from.species(), Species::Zombie | Species::Husk) { + let at = [from.position.x, from.position.y, from.position.z]; + let special = self.local_difficulty(at).special(); + horizon_client::mobs::finalize::zombie_attributes( + &mut mob, + special, + horizon_client::mobs::finalize::Reason::Conversion, + ); + } + let id = mob.id; + // ConversionType.SINGLE: the passengers get off and board the new mob. + for rider in self.mobs.iter_mut() { + if rider.extra.mount == Some(from.id) { + rider.extra.mount = Some(id); + } + } + self.mobs.push(mob); + Some(id) + } /// The `summon` command: spawn a mob in front of the player or at a position. pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result { let at = match at { @@ -454,13 +501,13 @@ impl Game<'_> { /// Highest motion-blocking (no leaves) block + 1 (Heightmap MOTION_BLOCKING_NO_LEAVES). pub(super) fn motion_top(&self, x: i32, z: i32) -> i32 { let top = if self.dim == Dim::Nether { 127 } else { 319 }; - for y in (horizon_client::chunk::MIN_Y..=top).rev() { + for y in (self.world.bottom_y()..=top).rev() { let f = self.res.facts().get(self.world.block(x, y, z)); if (f.full_collision || f.fluid != Fluid::None) && !f.name.ends_with("leaves") { return y + 1; } } - horizon_client::chunk::MIN_Y + self.world.bottom_y() } pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> { let (rain, thunder) = self.world.weather.levels(1.0); @@ -883,8 +930,12 @@ impl Game<'_> { } } self.score_entity_removed(m.id); - self.raider_died(m, by_player); - self.villager_murdered(m, by_player, killer.flatten()); + // Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to + // the causing entity, not the credited player: a tame wolf's kill is + // neither its owner's heroism nor a murder by them. + let slain_by_player = by_player && !killed_by_mob(m); + self.raider_died(m, slain_by_player); + self.villager_murdered(m, slain_by_player, killer.flatten()); let pos = [m.position.x, m.position.y + 0.5, m.position.z]; self.drop_death_loot(&mut dying, by_player, consumed, pos); self.create_wither_rose(m); @@ -919,7 +970,12 @@ impl Game<'_> { /// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s /// statistics and scores, and the `player_killed_entity` trigger. fn credit_kill(&mut self, m: &Mob) { - self.stats.add("killed", &m.kind.id, 1); + // Player.killedEntity: the killed statistic goes to the causing + // entity, so a kill by a mob (a tame wolf credited to its owner) + // leaves it out; ServerPlayer.awardKillScore counts the rest. + if !killed_by_mob(m) { + self.stats.add("killed", &m.kind.id, 1); + } self.stats.custom("mob_kills", 1); self.score_player_killed(super::commands::Target::Mob(m.id)); let view = self.mob_entity(m); @@ -1056,6 +1112,20 @@ impl Game<'_> { // the orbs' merge ids and block ticks read next). self.spawn_item(item, pos, dispenser::new_item_motion(), 10); } + drops::Drop::CarriedBlock(state) => { + // Enderman.dropCustomDeathLoot: spawnAtLocation for each + // of the carried block's silk-touch drops. + let stacks = horizon_client::mobs::enderman::carried_block_drops( + world_items!(self), + self.pack.assets.registry, + state, + &mut self.world.random, + Some((&mut self.rand_sequences, self.level.seed)), + ); + for item in stacks { + self.spawn_item(item, pos, dispenser::new_item_motion(), 10); + } + } drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32), drops::Drop::Orb(_) => {} } @@ -1088,6 +1158,9 @@ impl Game<'_> { } } } + if hurt { + self.resolve_player_responsible(target, source); + } if infested { self.mob_infested(target); } @@ -1110,6 +1183,49 @@ impl Game<'_> { } hurt } + /// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a + /// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`), + /// so the kill drops experience and player-only loot and the owner, when + /// in the world, gets the credit (`getKillCredit`); a tame wolf without an + /// owner clears it. A player's own hits are recorded where they land. + pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) { + let Some(Who::Mob(attacker)) = source.attacker else { + return; + }; + let Some(owner) = self + .mobs + .iter() + .find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame) + .map(|m| m.extra.owner) + else { + return; + }; + let player = owner.and_then(|uuid| self.player_with_uuid(uuid)); + let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else { + return; + }; + m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 }; + match player { + Some(who) => self.last_hurt_by_player.insert(victim, who), + None => self.last_hurt_by_player.remove(&victim), + }; + } + /// The player in the world with `uuid` (`EntityReference.getPlayer`), as + /// `last_hurt_by_player` keeps them: None for the player in place, else the + /// guest's connection. + fn player_with_uuid(&self, uuid: [i32; 4]) -> Option> { + if self.player.is_some() + && horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid()) + { + return Some(self.lan_acting); + } + let uuid = horizon_client::lan::playerdata::ints_uuid(uuid); + self.lan + .iter() + .flat_map(|h| h.hub.guests.iter()) + .find(|g| g.placed && g.uuid == uuid) + .map(|g| Some(g.conn)) + } /// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up. pub(super) fn mob_infested(&mut self, mob: u32) { if let Some(m) = self.mobs.iter().find(|m| m.id == mob) { @@ -1361,6 +1477,31 @@ impl Game<'_> { } => { self.mob_hits_mob(target, amount, source); } + MobEvent::CubeHurtMob { + cube, + target, + amount, + source, + } => { + // AbstractCubeMob.dealDamage: the attack sound (and its + // two pitch draws, made even for a silent cube, whose + // Entity.playSound is then skipped) only follows a hit + // that landed. + if self.mob_hits_mob(target, amount, source) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube) + { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + let at = m.center(); + if !m.silent { + self.handle_mob_events(vec![MobEvent::Sound( + "entity.slime.attack", + at, + 1.0, + pitch, + )])?; + } + } + } MobEvent::SonicBoom { target, attacker, @@ -1503,6 +1644,9 @@ impl Game<'_> { self.apply_setup(id, &setup); } } + MobEvent::Convert { from, kind, params } => { + self.convert_to(&from, kind, params); + } MobEvent::DropItem { item, count, at } => self.spawn_item( ItemStack { item, @@ -2113,6 +2257,8 @@ impl Game<'_> { && let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) { child.extra.age = -24000; + // No Mob.finalizeSpawn either: no follow range bonus, right-handed. + spawning::bred_offspring(child); // getBreedOffspring creates the baby without finalizeSpawn: it keeps // the default (`classic`) sound variant. child.extra.sound_variant = None; @@ -2645,7 +2791,7 @@ impl Game<'_> { let mut keep_flags = vec![true; projectiles.len()]; for i in 0..projectiles.len() { let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) { - wind_charge_hitboxes(&projectiles, i) + wind_charge_hitboxes(&projectiles, &keep_flags, i) } else { Vec::new() }; @@ -2754,6 +2900,16 @@ impl Game<'_> { hits.push((p.clone(), hit)); } keep_flags[i] = keep; + // Projectile.onHit: the wind charge it hit turns before its own tick. + if let Hit::Entity { + who: Who::Mob(id), .. + } = hit + && let Some(j) = wind_charge_hit(id) + { + let (owner, guest) = (p.owner, p.guest); + let look = self.owner_look(owner, guest); + super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest); + } } let mut idx = 0; projectiles.retain(|_| { @@ -2863,11 +3019,12 @@ impl Game<'_> { } } } - /// `onProjectileHit` of chorus flowers and decorated pots: an impact - /// projectile (`#impact_projectiles`) breaks the block when - /// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes - /// the `projectiles_can_break_blocks` rule and a shooter that may break - /// blocks (`canBreakBlockInAdventureMode`: never a mob, never an + /// `onProjectileHit` of chorus flowers, decorated pots and speleothems + /// (pointed dripstone, sulfur spikes): an impact projectile + /// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract` + /// and `Projectile.mayBreak` allow it, which takes the + /// `projectiles_can_break_blocks` rule and a shooter that may break blocks + /// (`canBreakBlockInAdventureMode`: never a mob, never an /// adventure player). A pot is cracked first (flags 260), so it shatters. fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) { use horizon_client::command::args::GameMode; @@ -2895,6 +3052,11 @@ impl Game<'_> { let at = BlockPos::new(pos[0], pos[1], pos[2]); match &*self.res.facts().get(state).name { "minecraft:chorus_flower" => {} + // SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a + // thrown trident faster than 0.6 breaks it. + "minecraft:pointed_dripstone" | "minecraft:sulfur_spike" + if matches!(p.kind, ProjectileKind::Trident) + && p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {} "minecraft:decorated_pot" => { let registry = &self.pack.assets.registry; let Some(mut pot) = registry.state(state).cloned() else { @@ -4801,6 +4963,11 @@ impl Game<'_> { if mob.invisible && outline.is_none() { continue; } + // LevelRenderer: the entity the view looks out of is not drawn + // in first person. + if self.hides_as_camera(mob.id) { + continue; + } let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera; // Entity.shouldRenderAtSqrDistance: 64 times the bounding box's // mean edge, scaled by the view scale. RemotePlayer's box counts @@ -5123,12 +5290,12 @@ impl Game<'_> { let Some(r) = atlas.sprites.get(name).copied() else { continue; }; - let s = 0.25 * a.sprite_scale(); - for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + let lift = is_item_billboard(&a.kind); + let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift); + for (v, uv) in quad .iter() .zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]]) { - let v = c + right * k.0 * s + up * k.1 * s; out.push(Vertex { normal: [0; 4], pos: v.to_array(), @@ -5158,6 +5325,36 @@ fn thrown_item_axes( let orientation = super::camera_rotation(camera_type, yaw, pitch); (orientation * glam::Vec3::X, orientation * glam::Vec3::Y) } +/// The four corners of the item billboard, in the order its UVs are laid out. +/// `ThrownItemRenderer` only scales and billboards it; the lift is +/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16 +/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`. +fn thrown_item_quad( + at: glam::Vec3, + right: glam::Vec3, + up: glam::Vec3, + scale: f32, + lift: bool, +) -> [glam::Vec3; 4] { + let s = 0.25 * scale; + let rise = if lift { 0.5 * s } else { 0.0 }; + let centre = at + up * rise; + [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + .map(|k| centre + right * k.0 * s + up * k.1 * s) +} +/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the +/// only ones that take that lift. Llama spit shares this quad but +/// `LlamaSpitRenderer` is a model, with its own offset. +fn is_item_billboard(kind: &ProjectileKind) -> bool { + matches!( + kind, + ProjectileKind::Snowball + | ProjectileKind::Egg + | ProjectileKind::EnderPearl + | ProjectileKind::SmallFireball + | ProjectileKind::LargeFireball { .. } + ) +} /// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water /// bucket picks up. fn bucketable(species: Species) -> Option<(&'static str, &'static str)> { @@ -5319,6 +5516,55 @@ impl Game<'_> { mod tests { use super::super::perspective::CameraType; use super::thrown_item_axes; + use super::thrown_item_quad; + use super::{ProjectileKind, is_item_billboard}; + + /// The component-wise lowest and highest corner of the quad. + fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) { + ( + corners.iter().copied().reduce(glam::Vec3::min).unwrap(), + corners.iter().copied().reduce(glam::Vec3::max).unwrap(), + ) + } + + #[test] + fn thrown_item_billboard_lifts_by_the_generated_ground_transform() { + // The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones. + assert!(is_item_billboard(&ProjectileKind::Snowball)); + assert!(is_item_billboard(&ProjectileKind::Egg)); + assert!(is_item_billboard(&ProjectileKind::EnderPearl)); + assert!(is_item_billboard(&ProjectileKind::SmallFireball)); + let large = ProjectileKind::LargeFireball { power: 1 }; + assert!(is_item_billboard(&large)); + // A projectile at y = 100 with right = +X, up = +Y: the sprite sits + // 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and + // stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375. + let at = glam::Vec3::Y * 100.0; + let (right, up) = (glam::Vec3::X, glam::Vec3::Y); + let lifted = is_item_billboard(&ProjectileKind::Snowball); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.y, hi.y), (99.875, 100.375)); + assert_eq!((lo.x, hi.x), (-0.25, 0.25)); + // A small fireball's 0.75 scale: the same quad scaled down, lifted by + // 0.09375, half extent 0.1875. + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted)); + assert_eq!((lo.y, hi.y), (99.90625, 100.28125)); + // A large fireball's 3.0: lift 0.375, half extent 0.75. + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted)); + assert_eq!((lo.y, hi.y), (99.625, 101.125)); + // Llama spit shares the quad here but is a model in vanilla + // (`LlamaSpitRenderer`): no lift at all. + let lifted = is_item_billboard(&ProjectileKind::LlamaSpit); + assert!(!lifted); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.y, hi.y), (99.75, 100.25)); + // The rise follows the billboard's own up axis, not world up. + let (right, up) = (glam::Vec3::Z, glam::Vec3::X); + let lifted = is_item_billboard(&ProjectileKind::Egg); + let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted)); + assert_eq!((lo.x, hi.x), (-0.125, 0.375)); + assert_eq!((lo.y, hi.y), (100.0, 100.0)); + } #[test] fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() { @@ -5349,6 +5595,13 @@ mod tests { } } +/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a +/// mob, such as a tame wolf whose owner gets the kill credit. +fn killed_by_mob(m: &Mob) -> bool { + m.last_damage + .is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_)))) +} + /// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the /// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)` /// is the host, `Some(Some(conn))` a guest. Both animals are spent. @@ -5379,3 +5632,128 @@ mod breeding_tests { assert_eq!(breeding_credit(&mut love, 1, 2), None); } } + +#[cfg(test)] +mod pearl_tests { + use super::*; + + /// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the + /// last `updateFluidInteraction`, water whose surface reaches its 0.25 + /// box (#3072), not the block at its feet. + #[test] + fn a_pearl_feels_the_water_its_box_touches() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("entity-types.json").exists() + || !root.join("movement-properties.nbt").exists() + { + return; + } + let assets = horizon_client::assets::Assets::load(&root).unwrap(); + let render = assets.reload(&root).unwrap(); + let registry = assets.registry; + let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap(); + let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap(); + let data = horizon_client::mobs::MobData::load(&root, &facts); + let biomes = horizon_client::biome::Biomes::load(&root).unwrap(); + let items = horizon_client::items::Items::load( + &root, + registry, + &facts, + &horizon_world::loot::PackOverlay::NONE, + ) + .unwrap(); + let collision = horizon_client::collisions::load(&root, registry).unwrap(); + let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap(); + // A pool of still water one block deep at y = 4, from x = 20 on (clear of the + // scene's pyramid): its + // surface is at 4 + 8/9. + let water = registry.default_state("minecraft:water").unwrap(); + for x in 20..=40 { + for z in 0..=2 { + world.set(x, 4, z, water).unwrap(); + } + } + let lid_openers = std::collections::HashMap::new(); + let w = MobWorld { + teams: Default::default(), + world: &world, + registry, + facts: &facts, + fluid_tables: &motion.fluids, + movement: &motion.blocks, + collision: &collision, + data: &data, + player: None, + nearest_player: None, + others: &[], + others_grid: None, + others_index: None, + daylight: true, + monsters_burn: false, + game_time: 1000, + day_time: 1000, + raining: false, + thundering: false, + nether: false, + block_tags: &items.block_tags, + biomes: &biomes, + mob_griefing: true, + mob_drops: true, + max_entity_cramming: 24, + difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0), + end: false, + world_seed: 0, + sea_level: 63, + items: Some(&items), + lid_openers: &lid_openers, + }; + let pw = ProjWorld { + w: &w, + targets: Vec::new(), + }; + let pearl = |x: f64, y: f64, velocity: Vec3| { + let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None); + p.velocity = velocity; + p + }; + // Skimming the pool: its feet are in the water block, its box above + // the surface. ThrowableProjectile.applyInertia keeps the air drag. + let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0)); + p.tick(&pw); + assert!(p.position.y > 4.9, "the pearl dipped: {p:?}"); + p.tick(&pw); + assert!( + (p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9, + "a pearl above the surface slowed as in water: {p:?}" + ); + // At the pool's edge: its feet over the dry block at x = 19, a side + // of its box in the water at x = 20. The water drag after the first + // tick, which still reads `wasTouchingWater` as false. + let mut p = pearl(19.95, 4.3, Vec3::ZERO); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, + "the first tick has the air drag: {p:?}" + ); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9, + "a pearl whose box is in the water kept the air drag: {p:?}" + ); + // Thrown under water, a pearl moves with the air drag on its first + // tick and the water drag after it. + let mut p = pearl(30.5, 4.2, Vec3::ZERO); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9, + "the first tick under water has the air drag: {p:?}" + ); + p.tick(&pw); + assert!( + (p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9, + "the second tick under water has the water drag: {p:?}" + ); + } +} diff --git a/crates/horizon-client/src/game/debug_keys.rs b/crates/horizon-client/src/game/debug_keys.rs index 6e8cd9fb8c..2fa97d7282 100644 --- a/crates/horizon-client/src/game/debug_keys.rs +++ b/crates/horizon-client/src/game/debug_keys.rs @@ -14,6 +14,8 @@ use horizon_client::{ text::Text, }; use horizon_core::geometry::{Axis, Vec3}; +use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound}; +use horizon_world::nbt::Tag; use winit::keyboard::KeyCode; /// `ChatFormatting.YELLOW` and `RED`. @@ -217,7 +219,7 @@ impl Game<'_> { self.debug_charts.log_packets([sent, received]); } if shown { - use horizon_protocol::packets::{play::Serverbound, status::Ping}; + use horizon_protocol::packets::status::Ping; let time = self.debug_millis(); self.send(Serverbound::PingRequest(Ping { time })); } @@ -682,7 +684,7 @@ impl Game<'_> { /// `GameModeCommand.setGameMode` with its feedback. pub(super) fn change_game_mode(&mut self, mode: GameMode) { if self.remote.is_some() { - use horizon_protocol::packets::play::{ChangeGameMode, Serverbound}; + use horizon_protocol::packets::play::ChangeGameMode; let mode = match mode { GameMode::Survival => 0, GameMode::Creative => 1, @@ -698,10 +700,11 @@ impl Game<'_> { } } /// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a - /// `/setblock` or `/summon` command. In a single-player world the - /// server's data (no Shift) is the game's own. + /// `/setblock` or `/summon` command. The server's data (no Shift) comes + /// from the server the game is connected to, the integrated one included + /// (`DebugQueryHandler`); without one, the game's own data is the server's. fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) { - use horizon_client::java_text::format_fixed2 as f2; + let query = add_nbt && from_server && self.remote.is_some(); let side = if add_nbt && from_server { "server" } else { @@ -710,45 +713,135 @@ impl Game<'_> { if self.entity_targeted() && let Some((index, _)) = self.mob_hit() { - let (id, pos, mob) = { + let (kind, position, id) = { let m = &self.mobs[index]; (m.kind.id.clone(), m.position, m.id) }; - let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z)); - if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) { - if let horizon_world::nbt::Tag::Compound(c) = &mut tag { - for key in ["id", "UUID", "Pos"] { - c.remove(&key.into()); - } + if query { + // DebugQueryHandler.queryEntityTag: the answer copies it. + let reply = QueryReply::Entity { id, kind, position }; + if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) + { + self.send(Serverbound::EntityTagQuery(EntityTagQuery { + transaction, + entity: id as i32, + })); } - command.push(' '); - command.push_str(&horizon_client::snbt::pretty(&tag).plain()); + return; } - self.set_clipboard(&command); + // Entity.saveWithoutId. + let tag = if add_nbt { + self.entity_nbt(super::commands::Target::Mob(id)) + .map(|mut tag| { + if let Tag::Compound(c) = &mut tag { + c.remove(&"id".into()); + } + tag + }) + } else { + None + }; + self.copy_create_entity_command(&kind, position, tag); self.debug_translated(&format!("debug.inspect.{side}.entity")); return; } let Some(hit) = self.pick() else { return; }; - let p = hit.pos; - let mut description = self.state_string(self.world.block(p[0], p[1], p[2])); - if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) { - // BlockEntity.saveWithoutMetadata: no id or position. - let name = self.data_block_name(p); - let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name); - if let horizon_world::nbt::Tag::Compound(c) = &mut tag { - for key in ["id", "x", "y", "z", "keepPacked"] { - c.remove(&key.into()); - } + let pos = hit.pos; + let state = self.state_string(self.world.block(pos[0], pos[1], pos[2])); + if query { + // DebugQueryHandler.queryBlockEntityTag: the answer copies it. + let reply = QueryReply::Block { pos, state }; + if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) { + self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery { + transaction, + pos, + })); } - description.push_str(&horizon_client::snbt::to_snbt(&tag)); + return; + } + let tag = if add_nbt { + self.block_entity_tag(pos) + } else { + None + }; + self.copy_create_block_command(pos, &state, tag.as_ref()); + self.debug_translated(&format!("debug.inspect.{side}.block")); + } + /// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if + /// there is one: its data without the id or the position. + pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option { + let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?; + let name = self.data_block_name(pos); + let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name); + if let Tag::Compound(c) = &mut tag { + for key in ["id", "x", "y", "z", "keepPacked"] { + c.remove(&key.into()); + } + } + Some(tag) + } + /// `ClientPacketListener.handleTagQueryPacket`: the server's answer to + /// the last debug query (`DebugQueryHandler.handleResponse`) completes + /// it; an answer to an older query is ignored. + pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) { + let Some(reply) = self + .remote + .as_mut() + .and_then(|r| r.debug_query.answer(transaction)) + else { + return; + }; + // FriendlyByteBuf.readNbt: an End tag is no compound. + let tag = match tag { + Tag::End => None, + tag => Some(tag), + }; + match reply { + QueryReply::Block { pos, state } => { + self.copy_create_block_command(pos, &state, tag.as_ref()); + self.debug_translated("debug.inspect.server.block"); + } + QueryReply::Entity { id, kind, position } => { + // Entity.position() when the answer comes: where it is now, + // else where it was last. + let position = self + .mobs + .iter() + .find(|m| m.id == id) + .map_or(position, |m| m.position); + self.copy_create_entity_command(&kind, position, tag); + self.debug_translated("debug.inspect.server.entity"); + } + } + } + /// `KeyboardHandler.copyCreateBlockCommand`. + fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) { + let mut description = state.to_owned(); + if let Some(tag) = tag { + description.push_str(&horizon_client::snbt::to_snbt(tag)); } self.set_clipboard(&format!( "/setblock {} {} {} {description}", - p[0], p[1], p[2] + pos[0], pos[1], pos[2] )); - self.debug_translated(&format!("debug.inspect.{side}.block")); + } + /// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID + /// or position. + fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option) { + use horizon_client::java_text::format_fixed2 as f2; + let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z)); + if let Some(mut tag) = tag { + if let Tag::Compound(c) = &mut tag { + for key in ["UUID", "Pos"] { + c.remove(&key.into()); + } + } + command.push(' '); + command.push_str(&horizon_client::snbt::pretty(&tag).plain()); + } + self.set_clipboard(&command); } /// `KeyboardHandler.setClipboard`. fn set_clipboard(&mut self, text: &str) { @@ -766,8 +859,8 @@ impl Game<'_> { p.position.y.floor() as i32, p.position.z.floor() as i32, ]; - let min_y = horizon_client::chunk::MIN_Y; - g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1); + let min_y = self.world.bottom_y(); + g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1); } if !self.show_hitboxes { return g; @@ -1010,3 +1103,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) { (-x.atan2(z)).to_degrees() as f32, ) } + +/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server +/// and what its answer completes. Only the newest query is answered. +#[derive(Debug)] +pub(super) struct DebugQuery { + transaction: i32, + pending: Option, +} +impl Default for DebugQuery { + fn default() -> Self { + Self { + transaction: -1, + pending: None, + } + } +} +/// What a debug query's answer is copied into: the block command +/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`). +#[derive(Clone, Debug, PartialEq)] +pub(super) enum QueryReply { + Block { + pos: [i32; 3], + state: String, + }, + Entity { + id: u32, + kind: String, + position: Vec3, + }, +} +impl DebugQuery { + /// `DebugQueryHandler.startTransaction`: the next transaction id, which + /// replaces any query still waiting. + fn start(&mut self, reply: QueryReply) -> i32 { + self.pending = Some(reply); + self.transaction = self.transaction.wrapping_add(1); + self.transaction + } + /// `DebugQueryHandler.handleResponse`: what the answer to `transaction` + /// completes, once, when it answers the newest query. + fn answer(&mut self, transaction: i32) -> Option { + if transaction == self.transaction { + self.pending.take() + } else { + None + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn block(x: i32) -> QueryReply { + QueryReply::Block { + pos: [x, 64, 0], + state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(), + } + } + + #[test] + fn a_debug_query_is_answered_once_and_only_the_newest() { + let mut q = DebugQuery::default(); + // The first transaction is 0 (the handler starts at -1). + assert_eq!(q.start(block(1)), 0); + assert_eq!(q.answer(0), Some(block(1))); + assert_eq!(q.answer(0), None); + // A second press before the first answer: the older answer is ignored + // and the newer one copies the newer target. + assert_eq!(q.start(block(2)), 1); + assert_eq!(q.start(block(3)), 2); + assert_eq!(q.answer(1), None); + assert_eq!(q.answer(2), Some(block(3))); + } +} diff --git a/crates/horizon-client/src/game/deflect.rs b/crates/horizon-client/src/game/deflect.rs index 2d1f728a28..68db7ae4bf 100644 --- a/crates/horizon-client/src/game/deflect.rs +++ b/crates/horizon-client/src/game/deflect.rs @@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool { ) } +/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns +/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`. +/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the +/// owner is gone), is that owner's now, and its acceleration power is back +/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack). +pub(super) fn aim_deflect( + p: &mut Projectile, + look: Option, + owner: Option, + guest: Option, +) { + if let Some(look) = look { + p.velocity = look; + p.needs_sync = true; + } + p.owner = owner; + p.guest = guest; + 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. fn pick_box(p: &Projectile) -> Aabb { @@ -106,6 +126,27 @@ impl Game<'_> { d2 < range * range } + /// `Entity.getLookAngle` of a projectile's owner: the host, the guest who + /// threw it, or a mob; none when the owner is not here. + pub(super) fn owner_look(&self, owner: Option, guest: Option) -> Option { + let (pitch, yaw) = match (owner?, guest) { + (Who::Player, Some(conn)) => { + let lan = self.lan.as_ref()?; + let g = lan.hub.guests.iter().find(|g| g.conn == conn)?; + (g.pitch, g.yaw) + } + (Who::Player, None) => { + self.player.as_ref()?; + (self.pitch, self.yaw) + } + (Who::Mob(id), _) => { + let m = self.mobs.iter().find(|m| m.id == id)?; + (m.head_pitch, m.yaw) + } + }; + Some(horizon_world::view_direction::view_vector(pitch, yaw)) + } + /// `Projectile.deflect(AIM_DEFLECT, player, player, true)`. pub(super) fn deflect(&mut self, i: usize) { let dir = view_vector(self.yaw, self.pitch); @@ -156,4 +197,36 @@ mod tests { assert_eq!(b.min_position().x, -1.5); assert_eq!(b.max_position().y, 66.0); } + + fn wind_charge() -> Projectile { + let mut p = Projectile::new( + ProjectileKind::WindCharge, + Vec3::new(0.0, 70.0, 0.0), + Some(Who::Mob(7)), + ); + p.velocity = Vec3::new(0.0, 1.5, 0.0); + p + } + + #[test] + fn a_redirected_wind_charge_flies_along_the_new_owners_look() { + let mut p = wind_charge(); + let look = Vec3::new(0.0, -1.0, 0.0); + aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3)); + assert_eq!(p.velocity, look); + assert_eq!(p.owner, Some(Who::Player)); + assert_eq!(p.guest, Some(3)); + assert_eq!(p.acceleration, 0.1); + assert!(p.needs_sync); + } + + #[test] + fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() { + let mut p = wind_charge(); + aim_deflect(&mut p, None, None, None); + assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0)); + assert_eq!(p.owner, None); + assert_eq!(p.acceleration, 0.1); + assert!(!p.needs_sync); + } } diff --git a/crates/horizon-client/src/game/dispense_fixture.rs b/crates/horizon-client/src/game/dispense_fixture.rs index 0cfb596d04..d9fccc64c0 100644 --- a/crates/horizon-client/src/game/dispense_fixture.rs +++ b/crates/horizon-client/src/game/dispense_fixture.rs @@ -186,6 +186,8 @@ impl Game<'_> { Some("end") => self.dim = super::Dim::End, _ => {} } + // The level's build range follows its dimension, as `enter_dimension` sets it. + self.world.shape = self.dim.shape(); // The oracle level's clock stands at day time 6000. self.world.day_time = 6000; if let Some(rules) = case["rules"].as_object() { diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index bdc9265dfd..4a5346f39e 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -1414,7 +1414,7 @@ impl Game<'_> { ) -> Result { let target = offset(pos, facing); let free = self.res.facts().is_air(self.block_at(target)) - && target.1 >= horizon_client::chunk::MIN_Y + 2 + && target.1 >= self.world.bottom_y() + 2 && self.ui.difficulty != 0 && self.can_build_wither(target); if free { diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index a654c2ba9e..8dc2476bda 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2887,6 +2887,7 @@ impl Game<'_> { precise: false, needs_sync: false, leash: None, + spectator: false, }) } } diff --git a/crates/horizon-client/src/game/enchant_effects.rs b/crates/horizon-client/src/game/enchant_effects.rs index ab17610b17..a51f6d2d08 100644 --- a/crates/horizon-client/src/game/enchant_effects.rs +++ b/crates/horizon-client/src/game/enchant_effects.rs @@ -25,6 +25,12 @@ enum MendSlot { Armor(usize), } +/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player +/// without the attribute keeps the vanilla default of 1.0. +fn block_break_speed_factor(value: Option) -> f32 { + value.map_or(1.0, |value| value as f32) +} + impl Game<'_> { /// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste /// and mining fatigue. @@ -39,6 +45,15 @@ impl Game<'_> { } speed * self.potions.effects.mining_speed_factor() } + /// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor: + /// 1.0 by default, so a missing player or attribute changes nothing. + pub(super) fn block_break_speed(&mut self) -> f32 { + block_break_speed_factor( + self.player + .as_mut() + .and_then(|p| p.attribute("minecraft:block_break_speed").ok()), + ) + } /// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity). pub(super) fn submerged_mining_speed(&self) -> f32 { let mut total = 0.0; @@ -1012,3 +1027,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool { _ => false, } } + +#[cfg(test)] +mod tests { + use super::block_break_speed_factor; + + /// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed. + #[test] + fn block_break_speed_is_a_destroy_speed_multiplier() { + assert_eq!(block_break_speed_factor(None), 1.0); + assert_eq!(block_break_speed_factor(Some(1.0)), 1.0); + assert_eq!(block_break_speed_factor(Some(0.5)), 0.5); + assert_eq!(block_break_speed_factor(Some(2.0)), 2.0); + assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0); + assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0); + } +} diff --git a/crates/horizon-client/src/game/end.rs b/crates/horizon-client/src/game/end.rs index 60e98abfac..7f9d2a5d86 100644 --- a/crates/horizon-client/src/game/end.rs +++ b/crates/horizon-client/src/game/end.rs @@ -10,8 +10,10 @@ use horizon_client::{ environment::Environment, items::ItemStack, mesher::Vertex, + mobs::Who, motion::PlayerMotion, particles::{ParticleOptions, ParticleType}, + projectiles::{Projectile, ProjectileKind}, vanillagen::VanillaTerrain, world_spawn::PlayerSpawnFinder, }; @@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag { Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect()) } +/// `TicketType.PORTAL`'s timeout, in ticks. +const PORTAL_TICKET: u32 = 300; + /// ServerLevel.END_SPAWN_POINT. pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0]; /// EnderDragonFight gateway ring radius and height. @@ -47,18 +52,27 @@ pub(super) enum GatewayJob { Known { exit: [i32; 3], exact: bool }, } -/// An entity travelling through an End gateway: its saved compound and the -/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`). +/// An entity travelling through an End gateway: the entity and the job that +/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`). #[derive(Clone, Debug)] struct GatewayArrival { - /// The entity's compound, as it waits for the exit's chunks. - tag: Tag, + /// The entity, as it waits for the exit's chunks. + traveller: Arriving, /// The gateway's exit, or the search for one. job: GatewayJob, /// The spot to load before the job is resolved. target: Vec3, } +/// What waits at a gateway's exit. +#[derive(Clone, Debug)] +enum Arriving { + /// An item or a mob, as its saved compound. + Saved(Tag), + /// A thrown ender pearl (projectiles are not saved). + Pearl(Box), +} + impl GatewayJob { /// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks. fn for_gateway( @@ -120,6 +134,10 @@ pub(super) struct EndState { arrivals: Vec, /// The chunks kept loaded for those arrivals. areas: Vec<(i32, i32)>, + /// `Entity.placePortalTicket` of the pearls that came out of a gateway: + /// the chunk each landed in, kept loaded for the ticks left of its + /// `TicketType.PORTAL`, so that it can come down and take its owner there. + tickets: Vec<((i32, i32), u32)>, /// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure). locator: std::sync::Mutex>>, /// Thrown eyes of ender in flight. @@ -1462,13 +1480,17 @@ impl Game<'_> { // ---- End gateway travel for the entities ---- - /// `EndGatewayBlock.entityInside` for the items and mobs in the End: the - /// gateway blocks their box swept through. A gateway that is cooling down - /// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in. + /// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender + /// pearls in the End: the gateway blocks their box swept through. A + /// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`) + /// takes nobody in. pub(super) fn find_end_gateway_portals(&mut self) { if self.remote.is_some() || self.dim != Dim::End { return; } + let is_gateway = |game: &Self, p: BlockPos| { + &*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway" + }; let items: Vec<(u32, Vec3, f32, f32)> = self .dropped .iter() @@ -1484,13 +1506,28 @@ impl Game<'_> { for (id, to, width, height) in items.into_iter().chain(mobs) { let from = self.portals.before.get(&id).copied().unwrap_or(to); let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| { - &*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name - == "minecraft:end_gateway" + is_gateway(self, p) }); if let Some(pos) = found { inside.push((id, pos)); } } + // A pearl's move this tick, from where it started it. + let pearls: Vec<(u32, Option, Option, BlockPos)> = self + .creatures + .projectiles + .iter() + .filter_map(|p| { + let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?; + Some((p.id, p.owner, p.guest, pos)) + }) + .collect(); + for (id, owner, guest, pos) in pearls { + if owner == Some(Who::Player) { + self.pearl_owner_in_gateway(guest, pos); + } + inside.push((id, pos)); + } for (id, pos) in inside { // One entity at a time: the gateway cools down for 40 ticks. if !self.gateway_enter([pos.x, pos.y, pos.z]) { @@ -1504,6 +1541,22 @@ impl Game<'_> { } } + /// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's + /// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had + /// walked in. A guest's pearl triggers theirs; one whose guest has left + /// has no owner. + fn pearl_owner_in_gateway(&mut self, guest: Option, pos: BlockPos) { + let state = self.world.block(pos.x, pos.y, pos.z); + let Some(conn) = guest else { + self.trigger_enter_block(state); + return; + }; + if let Some(mut host) = self.lan.take() { + self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state)); + self.lan = Some(host); + } + } + /// `Entity.handlePortal` for the entities in an End gateway: they go /// through to the gateway's exit, where they wait for its chunks. pub(super) fn handle_end_gateway_portals(&mut self) { @@ -1515,8 +1568,20 @@ impl Game<'_> { .iter() .map(|e| (e.id, true)) .chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m)))) + .chain( + self.creatures + .projectiles + .iter() + .filter(|p| matches!(p.kind, ProjectileKind::EnderPearl)) + .map(|p| (p.id, true)), + ) .collect(); - let present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); + let mut present: std::collections::HashSet = ids.iter().map(|(id, _)| *id).collect(); + // A pearl waiting for its exit keeps its portal cooldown. + present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller { + Arriving::Pearl(p) => Some(p.id), + Arriving::Saved(_) => None, + })); self.end.gateways.retain(|id, _| present.contains(id)); let mut leaving: Vec<(u32, BlockPos)> = Vec::new(); for (id, allowed) in ids { @@ -1573,11 +1638,28 @@ impl Game<'_> { self.forget_entity(g); } tag + } else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) { + let pearl = Box::new(self.creatures.projectiles.remove(i)); + let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]); + let arrival = GatewayArrival { + traveller: Arriving::Pearl(pearl), + job, + target, + }; + // It is at the exit before it moves this tick, once that has loaded. + if let Some(waiting) = self.arrive_through_gateway(arrival) { + self.end.arrivals.push(waiting); + } + return; } else { return; }; let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]); - self.end.arrivals.push(GatewayArrival { tag, job, target }); + self.end.arrivals.push(GatewayArrival { + traveller: Arriving::Saved(tag), + job, + target, + }); } /// The entities that reached a gateway's exit: they land where the @@ -1586,47 +1668,60 @@ impl Game<'_> { if self.remote.is_some() || self.dim != Dim::End { return Ok(()); } - let mut i = 0; - while i < self.end.arrivals.len() { - let (cx, cz) = ( - (self.end.arrivals[i].target.x.floor() as i32) >> 4, - (self.end.arrivals[i].target.z.floor() as i32) >> 4, - ); - if !self.world.chunks.contains_key(&(cx, cz)) { - i += 1; - continue; - } - let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target); - let at = match self.resolve_gateway(job, target) { - Ok(at) => at, - Err(e) => { - // The traveller waits for the exit: the failure is retried. - log::warn!("End gateway: {e}"); - i += 1; - continue; - } - }; - let mut tag = self.end.arrivals.remove(i).tag; - if let Tag::Compound(c) = &mut tag { - c.insert("Pos".into(), doubles([at.x, at.y, at.z])); - c.insert("Motion".into(), doubles([0.0, 0.0, 0.0])); - // setPortalCooldown before the teleport. - c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY)); - } - let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4); - match self - .world - .loaded_entities - .iter_mut() - .find(|(k, _)| *k == key) - { - Some((_, tags)) => tags.push(tag), - None => self.world.loaded_entities.push((key, vec![tag])), + for arrival in std::mem::take(&mut self.end.arrivals) { + if let Some(waiting) = self.arrive_through_gateway(arrival) { + self.end.arrivals.push(waiting); } } Ok(()) } + /// `Entity.teleport` of a gateway traveller once its exit's chunk has + /// loaded: an item or a mob joins the chunk it lands in, a pearl flies + /// 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)) { + return Some(arrival); + } + let at = match self.resolve_gateway(arrival.job, arrival.target) { + Ok(at) => at, + Err(e) => { + // The traveller waits for the exit: the failure is retried. + log::warn!("End gateway: {e}"); + return Some(arrival); + } + }; + let key = chunk(at); + match arrival.traveller { + Arriving::Saved(mut tag) => { + if let Tag::Compound(c) = &mut tag { + c.insert("Pos".into(), doubles([at.x, at.y, at.z])); + c.insert("Motion".into(), doubles([0.0, 0.0, 0.0])); + // setPortalCooldown before the teleport. + c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY)); + } + match self + .world + .loaded_entities + .iter_mut() + .find(|(k, _)| *k == key) + { + Some((_, tags)) => tags.push(tag), + None => self.world.loaded_entities.push((key, vec![tag])), + } + } + Arriving::Pearl(mut pearl) => { + horizon_client::end_gateway::place_pearl(&mut pearl, at); + // ThrownEnderpearl.teleport: placePortalTicket where it came out. + self.end.tickets.retain(|(c, _)| *c != key); + self.end.tickets.push((key, PORTAL_TICKET)); + self.add_projectile(*pearl); + } + } + None + } + /// The chunks the entities waiting to arrive need, kept loaded like the /// areas a server keeps around its players. pub(super) fn keep_gateway_destinations(&mut self) { @@ -1634,12 +1729,24 @@ impl Game<'_> { self.streaming .extra .retain(|&(c, r)| !(r == 1 && areas.contains(&c))); + // The pearls' portal tickets hold End chunks, and run out there + // (TicketType.PORTAL's timeout). + let in_end = self.dim == Dim::End; + if in_end { + self.end.tickets.retain_mut(|(_, ticks)| { + *ticks = ticks.saturating_sub(1); + *ticks > 0 + }); + } let mut areas = Vec::new(); - for a in &self.end.arrivals { - let c = ( + let arrivals = self.end.arrivals.iter().map(|a| { + ( (a.target.x.floor() as i32) >> 4, (a.target.z.floor() as i32) >> 4, - ); + ) + }); + let tickets = self.end.tickets.iter().filter(|_| in_end); + for c in arrivals.chain(tickets.map(|&(c, _)| c)) { if !areas.contains(&c) { areas.push(c); self.streaming.extra.push((c, 1)); diff --git a/crates/horizon-client/src/game/entity_culling.rs b/crates/horizon-client/src/game/entity_culling.rs index 8110d93c73..aa83475de3 100644 --- a/crates/horizon-client/src/game/entity_culling.rs +++ b/crates/horizon-client/src/game/entity_culling.rs @@ -1,6 +1,5 @@ //! Entity occlusion affects mesh submission, never entity ticking or interaction. use super::{Game, Vertex}; -use horizon_client::chunk::MIN_Y; use horizon_client::{ blocks::{RenderShape, StateFacts}, entity_culling::Culler, @@ -41,7 +40,7 @@ impl Game<'_> { if !opaque(self.res.facts().get(state)) { return false; } - let Some(y) = pos[1].checked_sub(MIN_Y) else { + let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else { return false; }; let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4); diff --git a/crates/horizon-client/src/game/explosion.rs b/crates/horizon-client/src/game/explosion.rs index 12e136ae11..5945ad1f49 100644 --- a/crates/horizon-client/src/game/explosion.rs +++ b/crates/horizon-client/src/game/explosion.rs @@ -89,6 +89,29 @@ pub(super) enum BlockInteraction { DestroyWithDecay, } +/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is +/// within the blast's diameter of the centre. +pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool { + let d = + ((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2)) + .sqrt(); + d / diameter <= 1.0 +} + +/// Whether a blast leaves item frames, paintings, cushions and armour stands +/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored +/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their +/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a +/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and +/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`). +pub(super) fn blast_spares_decorations( + mob_griefing: bool, + interaction: BlockInteraction, + by_mob: bool, +) -> bool { + !mob_griefing && (interaction == BlockInteraction::Keep || by_mob) +} + thread_local! { /// Client-side randomness (explosion sound pitch), separate from the level random. static CLIENT_RANDOM: std::cell::RefCell = std::cell::RefCell::new(LegacyRandom::new(0x5eed)); @@ -446,7 +469,6 @@ impl Game<'_> { ); } self.mobs = mobs; - self.explosion_breaks_frames(center, diameter, interaction); // Dropped items: 5 health, destroyed by the explosion damage (nether stars resist). let mut items = std::mem::take(&mut self.dropped); items.retain_mut(|item| { @@ -525,6 +547,73 @@ impl Game<'_> { c.velocity.z + dir[2] * power, ); } + // Frames, paintings, cushions and armour stands last: vanilla lists + // the entities in reach before hurting any, so the items they drop + // here are neither destroyed nor pushed by the same blast. + self.explosion_breaks_frames(center, diameter, interaction); + self.explosion_breaks_decorations(center, diameter, interaction); + } + /// `blast_spares_decorations` for the blast going off now: the mob that + /// owns its projectile or explodes itself is the source's entity. + pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool { + let by_mob = self + .combat + .explosion_owner + .or(self.combat.explosion_source) + .is_some(); + blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob) + } + /// `ServerExplosion.hurtEntities` for paintings, cushions and armour + /// stands (item frames: `explosion_breaks_frames`). A painting or cushion + /// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`, + /// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is + /// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with + /// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops, + /// the stand itself does not. + /// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings + /// and cushions when the exploding entity is in water. + fn explosion_breaks_decorations( + &mut self, + center: [f64; 3], + diameter: f64, + interaction: BlockInteraction, + ) { + if self.blast_spares_decorations(interaction) { + return; + } + let mut i = 0; + while i < self.paintings.len() { + // HangingEntity.recalculateBoundingBox: a painting stands at the + // centre of its box. + let (lo, hi) = self.paintings[i].bounds(); + let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0); + if blast_reaches(center, diameter, at) { + self.break_painting(i, false); + } else { + i += 1; + } + } + let mut i = 0; + while i < self.cushions.len() { + let p = self.cushions[i].position; + if blast_reaches(center, diameter, [p.x, p.y, p.z]) { + self.break_cushion(i, false); + } else { + i += 1; + } + } + let mut i = 0; + while i < self.armor_stands.len() { + let a = &self.armor_stands[i]; + let p = a.position; + if !super::armor_stands::spared_by_blast(a) + && blast_reaches(center, diameter, [p.x, p.y, p.z]) + { + self.break_armor_stand_by_anything(i); + } else { + i += 1; + } + } } /// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying /// and plain destruction. @@ -758,6 +847,29 @@ fn add_or_append_stack( mod tests { use super::*; + #[test] + fn blasts_reach_entities_within_their_diameter() { + // A TNT blast: radius 4, so the entity pass reaches 8 blocks out. + let center = [0.5, 64.0, 0.5]; + assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5])); + assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5])); + assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5])); + } + + #[test] + fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() { + use BlockInteraction::{Destroy, DestroyWithDecay, Keep}; + // mobGriefing on: every blast breaks them, a creeper's included. + assert!(!blast_spares_decorations(true, DestroyWithDecay, true)); + assert!(!blast_spares_decorations(true, Keep, true)); + // Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks). + assert!(!blast_spares_decorations(false, Destroy, false)); + assert!(!blast_spares_decorations(false, DestroyWithDecay, false)); + // Off: a blast that keeps blocks, or one a mob causes, does not. + assert!(blast_spares_decorations(false, Keep, false)); + assert!(blast_spares_decorations(false, Destroy, true)); + } + #[test] fn block_pos_hash_matches_vec3i() { // Vec3i.hashCode: (y + z * 31) * 31 + x diff --git a/crates/horizon-client/src/game/fov.rs b/crates/horizon-client/src/game/fov.rs index 40ee2f05f4..1223399b6d 100644 --- a/crates/horizon-client/src/game/fov.rs +++ b/crates/horizon-client/src/game/fov.rs @@ -79,6 +79,14 @@ impl Game<'_> { .fov_player() .map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect)); let target = self.zoomed_fov_target(target); + // A server's spectator camera on a mob: its eye height. Its modifier + // stays 1 (another player's own modifier is not modelled here); the + // zoom extra still narrows it. + if let Some(m) = self.camera_mob() { + let eye = m.eye_height() as f32; + self.view.tick(Some(eye), self.zoomed_fov_target(1.0)); + return; + } let eye = self.player.as_ref().map(|p| { if self.sleeping.is_some() { 0.2 diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index aa108c01a7..db9e4a4998 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -298,9 +298,8 @@ impl Game<'_> { } /// `ServerExplosion.hurtEntities` for item frames: a frame within the blast /// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`). - /// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast - /// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through - /// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames. + /// A fixed frame is not hurt by an explosion, and `blast_spares_decorations` + /// keeps every frame without `mobGriefing`. /// Not ported: `ignoreExplosion` also spares frames when the exploding /// entity is in water. pub(super) fn explosion_breaks_frames( @@ -309,10 +308,7 @@ impl Game<'_> { diameter: f64, interaction: super::explosion::BlockInteraction, ) { - if !self.ui.rules.bool("mob_griefing") - && (interaction == super::explosion::BlockInteraction::Keep - || self.combat.explosion_source.is_some()) - { + if self.blast_spares_decorations(interaction) { return; } let mut i = 0; diff --git a/crates/horizon-client/src/game/hand.rs b/crates/horizon-client/src/game/hand.rs index 0e3c2da384..e6a47fb9d4 100644 --- a/crates/horizon-client/src/game/hand.rs +++ b/crates/horizon-client/src/game/hand.rs @@ -58,7 +58,53 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo distance >= f64::from(min - margin) && distance <= f64::from(max + margin) } +/// `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem`'s +/// `ItemStack.matchesIgnoringComponents(visible, held, +/// DataComponentType::ignoreSwapAnimation)` for one hand (every item plays +/// the swap animation, `ItemModelResolver.shouldPlaySwapAnimation`): the same +/// stack object, or an equal one, whose damage (the one component built +/// with `ignoreSwapAnimation`) may differ. +/// +/// A stack that stays in its slot with the same item is the same object, +/// whatever its count or damage (vanilla shrinks, grows and damages it in +/// place), unless the slot was `replaced` by one the hand doesn't show yet: +/// swapping the hands (`setItemInHand`, and the server's slot updates that +/// answer it) puts another object in both (#2866). +fn shows_held_stack( + visible: &(usize, Option), + held: &(usize, Option), + replaced: bool, +) -> bool { + let same_object = visible.0 == held.0 && !replaced; + match (&visible.1, &held.1) { + (Some(a), Some(b)) if same_object => a.item == b.item, + (a, b) => matches_for_swap(a.as_ref(), b.as_ref()), + } +} + +/// `ItemStack.matchesIgnoringComponents(a, b, +/// DataComponentType::ignoreSwapAnimation)`: the same count, item and +/// components but damage. +pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) -> bool { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => a.count == b.count && a.item == b.item && a.extra == b.extra, + _ => false, + } +} + impl Game<'_> { + /// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to + /// a new stack: the hand holding it compares it as another `ItemStack` + /// (`shows_held_stack`) until the hand shows the held stack again. + pub(super) fn hand_stack_replaced(&mut self, slot: usize) { + if slot == self.selected { + self.hands_replaced[0] = true; + } + if slot == super::shield::OFFHAND_SLOT { + self.hands_replaced[1] = true; + } + } /// Opening a screen stops breaking before the next game tick (which may pause). pub(super) fn stop_mining(&mut self) { if self.clicks.destroying.active { @@ -155,7 +201,8 @@ impl Game<'_> { Ok(()) } /// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`. - pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 { + pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 { + let block_break_speed = self.block_break_speed(); let state = self.world.block(pos[0], pos[1], pos[2]); let facts = self.res.facts().get(state); if facts.hardness == -1.0 { @@ -164,6 +211,7 @@ impl Game<'_> { let held = self.held(); let mut speed = self.items().mining_speed(held, &facts.name); speed = self.modify_destroy_speed(speed); + speed *= block_break_speed; if let Some(eye) = self.eye_position() && self .res @@ -266,22 +314,13 @@ impl Game<'_> { } self.attack_item = main.as_ref().map(|s| s.item.clone()); let next = [(self.selected, main), (OFFHAND, self.offhand.clone())]; - // shouldInstantlyReplaceVisibleItem: the same stack, or an equal one - // (matchesIgnoringComponents; every item plays the swap animation). - // A stack that stays in its slot with the same item is the same - // stack, whatever its count (vanilla shrinks and grows it in place). let mut changed = [false; 2]; for h in 0..2 { - let same = match (&self.visible_hands[h], &next[h]) { - ((_, None), (_, None)) => true, - ((slot, Some(a)), (next_slot, Some(b))) => { - a.item == b.item - && (slot == next_slot || (a.count == b.count && a.extra == b.extra)) - } - _ => false, - }; + let replaced = self.hands_replaced[h]; + let same = shows_held_stack(&self.visible_hands[h], &next[h], replaced); if same { self.visible_hands[h] = next[h].clone(); + self.hands_replaced[h] = false; } changed[h] = !same; } @@ -294,6 +333,7 @@ impl Game<'_> { for h in 0..2 { if replace[h] { self.visible_hands[h] = next[h].clone(); + self.hands_replaced[h] = false; } } } @@ -418,7 +458,21 @@ impl Game<'_> { /// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to /// the attribute's range. pub(super) fn main_hand_attack_speed(&self) -> f64 { - let value = horizon_client::attack_strength::BASE_ATTACK_SPEED + // The base comes from the player's attribute map so `/attribute ... base set` applies. + let base = self + .player + .as_ref() + .and_then(|player| { + let key = player.attributes.key("minecraft:attack_speed")?; + let handle = player.attributes.lookup(Some(&key))?; + player + .attributes + .instance(&handle) + .ok() + .map(|value| value.base_value()) + }) + .unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED); + let value = base + self .attack_item_info() .map_or(0.0, |i| f64::from(i.attack_speed)); @@ -569,6 +623,8 @@ impl HandWorld for GameHands<'_, '_> { true } fn attack_block(&mut self, pos: [i32; 3]) { + // RedStoneOreBlock.interact: the dust comes whether or not the ore was lit. + self.game.redstone_ore_particles(pos); let result = self.game.attack_block(pos); self.keep(result); } @@ -661,6 +717,52 @@ mod tests { assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None); } + fn stack(item: &str, count: u32, damage: u32) -> Option { + Some(ItemStack { + item: item.into(), + count, + damage, + extra: None, + }) + } + + #[test] + fn a_swapped_in_stack_of_the_same_item_re_equips() { + // #2866: F swaps 64 torches into the off hand that held 32. The off + // hand's slot stays, but the server's slot update puts another stack + // object there; a different count fails matchesIgnoringComponents, + // so the hand plays the swap animation. + let visible = (OFFHAND, stack("minecraft:torch", 32, 0)); + let held = (OFFHAND, stack("minecraft:torch", 64, 0)); + assert!(!shows_held_stack(&visible, &held, true)); + // The same stack shrunk in place (a torch placed): no animation. + assert!(shows_held_stack(&visible, &held, false)); + // An equal stack replaced it: shown at once. + assert!(shows_held_stack(&visible, &visible.clone(), true)); + // DataComponents.DAMAGE ignores the swap animation: a sword with + // other damage swapped in shows at once. + let sword = (OFFHAND, stack("minecraft:iron_sword", 1, 3)); + let worn = (OFFHAND, stack("minecraft:iron_sword", 1, 40)); + assert!(shows_held_stack(&sword, &worn, true)); + // Another component (an anvil's repair cost) differs. + let mut named = worn.clone(); + named.1.as_mut().unwrap().extra = Some(Box::new(horizon_client::item_data::StackData { + repair_cost: 1, + ..Default::default() + })); + assert!(!shows_held_stack(&sword, &named, true)); + // The main hand changing hotbar slot to an equal stack: instant; to + // a different count: animation. + let (a, b) = ( + (0, stack("minecraft:torch", 32, 0)), + (1, stack("minecraft:torch", 32, 0)), + ); + assert!(shows_held_stack(&a, &b, false)); + let c = (1, stack("minecraft:torch", 5, 0)); + assert!(!shows_held_stack(&a, &c, false)); + assert!(shows_held_stack(&(0, None), &(OFFHAND, None), true)); + } + #[test] fn attack_range_uses_inclusive_float_bounds() { let range = serde_json::json!({ diff --git a/crates/horizon-client/src/game/initial_spawn.rs b/crates/horizon-client/src/game/initial_spawn.rs index 5f68949454..81b048809d 100644 --- a/crates/horizon-client/src/game/initial_spawn.rs +++ b/crates/horizon-client/src/game/initial_spawn.rs @@ -6,7 +6,6 @@ use super::{Game, dispenser::entity_random}; use horizon_client::{ Result, assets::Properties, - chunk::{HEIGHT, MIN_Y}, collisions::{SceneCollisions, no_collision_or_liquid}, world::World, world_spawn::{ @@ -32,13 +31,13 @@ struct Columns<'a, 'r> { } impl SpawnLevel for Columns<'_, '_> { fn min_y(&self) -> i32 { - MIN_Y + self.world.bottom_y() } fn height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 { - (MIN_Y..MIN_Y + HEIGHT) + (self.world.bottom_y()..self.world.top_y()) .rev() .find(|&y| kind.is_opaque(self.properties.heightmap_state(self.world.block(x, y, z)))) - .map_or(MIN_Y, |y| y + 1) + .map_or(self.world.bottom_y(), |y| y + 1) } fn has_fluid(&self, x: i32, y: i32, z: i32) -> bool { use horizon_world::heightmap::HeightmapState; @@ -347,11 +346,11 @@ impl Game<'_> { /// down onto the ground. fn fixup_spawn_height(&self, pos: [i32; 3]) -> Vec3 { let [x, mut y, z] = pos; - while !self.player_fits([x, y, z]) && y < MIN_Y + HEIGHT - 1 { + while !self.player_fits([x, y, z]) && y < self.world.top_y() - 1 { y += 1; } y -= 1; - while self.player_fits([x, y, z]) && y > MIN_Y { + while self.player_fits([x, y, z]) && y > self.world.bottom_y() { y -= 1; } bottom_center([x, y + 1, z]) diff --git a/crates/horizon-client/src/game/item_draw.rs b/crates/horizon-client/src/game/item_draw.rs index 38821a0e57..883d0320f6 100644 --- a/crates/horizon-client/src/game/item_draw.rs +++ b/crates/horizon-client/src/game/item_draw.rs @@ -309,32 +309,28 @@ impl Game<'_> { batch, x, y, - self.started.elapsed().as_secs_f32(), - self.settings.file.double("glintSpeed") as f32, - self.settings.file.double("glintStrength") as f32, + self.glint_frame.millis / 1000.0, + self.glint_frame.speed, + self.glint_frame.alpha, ); } - /// The `GLINT_TEXTURING` inputs the atlas and options provide. - pub(super) fn glint(&self) -> Option { - self.gui - .atlas - .sprites - .get("misc/enchanted_glint_item") - .copied() - .map(|_| item_render::Glint { - speed: self.settings.file.double("glintSpeed") as f32, - alpha: self.settings.file.double("glintStrength") as f32, - millis: self.started.elapsed().as_secs_f32() * 1000.0, - scale: 0.5, - }) + /// The glint clock, options and sprite for this frame: read once per + /// frame, not per item (`GameRenderer.extractOptions`). + pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame { + item_render::GlintFrame { + millis: self.started.elapsed().as_secs_f32() * 1000.0, + speed: self.settings.file.double("glintSpeed") as f32, + alpha: self.settings.file.double("glintStrength") as f32, + sprite: self + .gui + .atlas + .sprites + .contains_key("misc/enchanted_glint_item"), + } } - /// The glint scroll phase shared by world items and armour - /// (`item_render::Glint::scroll_ticks`). - pub(super) fn glint_ticks(&self) -> f32 { - item_render::Glint::scroll_ticks( - self.started.elapsed().as_secs_f32() * 1000.0, - self.settings.file.double("glintSpeed") as f32, - ) + /// The `ENTITY_GLINT_TEXTURING` inputs of this frame. + pub(super) fn glint(&self) -> Option { + self.glint_frame.glint(0.5) } /// Submit a resolved item under `pose` (camera-relative) at `light` /// (packed lightmap coordinates). diff --git a/crates/horizon-client/src/game/item_particles.rs b/crates/horizon-client/src/game/item_particles.rs index 2122ae8e1e..caa3a685eb 100644 --- a/crates/horizon-client/src/game/item_particles.rs +++ b/crates/horizon-client/src/game/item_particles.rs @@ -3,9 +3,13 @@ use super::Game; use horizon_assets::item_model::DisplayContext; use horizon_client::{ + item_render::stack_view, items::ItemStack, particles::{self, ParticleOptions, ParticleType}, }; +use horizon_core::random::RandomSource; +use horizon_world::player_inventory::EquipmentSlot; +use serde_json::Value; impl Game<'_> { /// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the @@ -36,6 +40,75 @@ impl Game<'_> { self.particles.add(&options, p, v); } } + /// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server: + /// the entity's item in `slot` broke, so `breakItem` the stack this + /// client still has there (the slot is emptied by a later update). + pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) { + if self.remote.as_ref().is_some_and(|r| r.entity_id == id) { + let stack = match slot { + EquipmentSlot::MainHand => self.held().cloned(), + EquipmentSlot::OffHand => self.offhand.clone(), + EquipmentSlot::Head => self.armor[0].clone(), + EquipmentSlot::Chest => self.armor[1].clone(), + EquipmentSlot::Legs => self.armor[2].clone(), + EquipmentSlot::Feet => self.armor[3].clone(), + EquipmentSlot::Body | EquipmentSlot::Saddle => None, + }; + let Some(stack) = stack else { + return; + }; + if let Some(p) = self.player.as_ref().map(|p| p.position) { + self.break_sound_at(&stack, [p.x, p.y, p.z]); + } + self.eat_particles(&stack, 5); + return; + } + let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else { + return; + }; + let Some(stack) = m.item_by_slot(slot) else { + return; + }; + let at = [m.position.x, m.position.y, m.position.z]; + let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent); + if !silent { + self.break_sound_at(&stack, at); + } + let options = self.item_particle(&stack); + let mut spawned = Vec::new(); + particles::item_use_particles( + &mut self.world.random, + pitch, + yaw, + at, + at[1] + eye, + 5, + |p, v| spawned.push((p, v)), + ); + for (p, v) in spawned { + self.particles.add(&options, p, v); + } + } + /// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when + /// 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]) { + let models = &self.pack.item_renderer.models; + let sound = match models + .components(&stack_view(stack)) + .get("minecraft:break_sound") + { + Some(Value::String(id)) => id.clone(), + Some(Value::Object(inline)) => match inline.get("sound_id") { + Some(Value::String(id)) => id.clone(), + _ => return, + }, + _ => return, + }; + let pitch = 0.8 + self.world.random.next_float() * 0.4; + self.audio.play_unshared(&sound, Some(at), 0.8, pitch); + } /// The slime ball, snowball and cobweb models behind `item_slime`, /// `item_snowball` and `item_cobweb`, resolved on first use. pub(super) fn ensure_item_particle_sprites(&mut self) { diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 0cfca976ac..b64abb438d 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -183,7 +183,8 @@ pub(super) fn baby_index(kind: &str) -> Option { | "minecraft:axolotl" | "minecraft:dolphin" | "minecraft:squid" - | "minecraft:glow_squid" => Some(16), + | "minecraft:glow_squid" + | "minecraft:sulfur_cube" => Some(16), _ => None, } } @@ -627,10 +628,11 @@ fn mob_meta( } } // AbstractCubeMob.ID_SIZE. - "minecraft:slime" | "minecraft:magma_cube" => { + "minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => { if e.size > 1 { meta.push((18, serializer::INT, Value::Int(e.size))); } + defaults.push((18, serializer::INT, Value::Int(1))); } // Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the // guest swells the creeper from them. The direction always goes in, @@ -1613,6 +1615,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.movement_checks = ( self.ui.rules.bool("player_movement_check"), @@ -1869,6 +1872,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.place_new_player(conn, &mut ctx); if elsewhere && let Err(e) = self.enter_level(Some(host), back) { diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 4f574acd36..0bfbbab731 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -1583,6 +1583,33 @@ impl Game<'_> { self.send_admin_feedback(host, Some(conn)); } } + // handleEntityTagQuery: a game master's F3+I on an entity of their + // level gets its Entity.saveWithoutId. + S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => { + if let Some(tag) = self.level_entity_tag(host, p.entity) { + host.hub.send_to( + conn, + &C::TagQuery(play::TagQuery { + transaction: p.transaction, + tag, + }), + ); + } + } + // handleBlockEntityTagQuery: BlockEntity.saveWithoutMetadata, or + // null where there is no block entity. + S::BlockEntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => { + let tag = self + .block_entity_tag(p.pos) + .unwrap_or(horizon_world::nbt::Tag::End); + host.hub.send_to( + conn, + &C::TagQuery(play::TagQuery { + transaction: p.transaction, + tag, + }), + ); + } S::Unknown(id, payload) if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION => { @@ -1638,6 +1665,31 @@ impl Game<'_> { } } + /// `ServerLevel.getEntity(id)`'s `Entity.saveWithoutId`: the player or + /// other entity of the level in place with that network id. + fn level_entity_tag(&mut self, host: &mut LanHost, id: i32) -> Option { + use super::commands::Target; + let player = host + .hub + .guests + .iter() + .find(|g| g.entity_id == id && host.hub.here(g)) + .map(|g| g.conn); + let mut tag = match player { + Some(player) => self + .as_guest(host, player, |g| g.entity_nbt(Target::Player)) + .flatten()?, + None => { + let target = self.level_entity(id)?; + self.entity_nbt(target)? + } + }; + if let horizon_world::nbt::Tag::Compound(c) = &mut tag { + c.remove(&"id".into()); + } + Some(tag) + } + /// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack /// is selected, else a creative player gets the item; the held slot is sent /// back and the changed slots follow with the tick's inventory changes. @@ -2232,6 +2284,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), }; host.hub.respawn(i, spawn, &ctx); // ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's @@ -2450,7 +2503,7 @@ impl Game<'_> { let me = self.player_text(&name, uuid); let text = match cause { Some(key) => horizon_client::text::Text::translate(&key, &[me]), - None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => { + None if at[1] < f64::from(self.world.bottom_y() - 64) => { horizon_client::text::Text::translate("death.attack.outOfWorld", &[me]) } None => self.guest_death_message(conn, me), @@ -2719,9 +2772,10 @@ impl Game<'_> { for (moved, on_ground) in moves { let movement = Vec3::new(moved[0], moved[1], moved[2]); if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 { - let sprinting = w.player.sprinting; - w.player - .cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 }); + // ServerGamePacketListenerImpl.handlePlayerPositionChange: + // the server's copy jumps with the player, so the velocity + // it sends them keeps the jump. + let _ = w.jump_from_ground(); } let landed = (|| -> horizon_client::Result<()> { w.set_on_ground_with_movement(on_ground, false, movement)?; diff --git a/crates/horizon-client/src/game/lan_attributes.rs b/crates/horizon-client/src/game/lan_attributes.rs index f1ee85c8f3..c00eaec404 100644 --- a/crates/horizon-client/src/game/lan_attributes.rs +++ b/crates/horizon-client/src/game/lan_attributes.rs @@ -8,7 +8,7 @@ use horizon_client::{ effects, lan::attributes::{add_modifier, generic_syncable, instance, operation_named}, mobs::{ - Mob, + Mob, Species, finalize::{self, Attr, Op}, }, }; @@ -147,6 +147,25 @@ impl Game<'_> { } } } + // AbstractHorse.randomizeAttributes rolls a horse's jump strength as + // its base (kept in `vector[0]`), so the instance is synced: its + // rider's client jumps it (`getCustomJump`). + let rolled = matches!( + m.species(), + Species::Horse | Species::SkeletonHorse | Species::ZombieHorse + ); + let jump_base = m + .extra + .attributes + .as_deref() + .is_some_and(|s| s.bases.iter().any(|(a, _)| *a == Attr::JumpStrength)); + if rolled + && !jump_base + && m.extra.vector[0] > 0.0 + && let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength") + { + instance(out, a, m.extra.vector[0]).base = m.extra.vector[0]; + } if let (Some(a), Some((amount, Op::AddMultipliedBase))) = ( self.synced_attribute(kind, "minecraft:movement_speed"), finalize::transient(m, Attr::MovementSpeed), diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 285639113d..fc37b9f824 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> precise: false, needs_sync: false, leash: None, + spectator: false, } } diff --git a/crates/horizon-client/src/game/lan_vehicles.rs b/crates/horizon-client/src/game/lan_vehicles.rs index 45ec936241..ca4cf390d4 100644 --- a/crates/horizon-client/src/game/lan_vehicles.rs +++ b/crates/horizon-client/src/game/lan_vehicles.rs @@ -11,6 +11,7 @@ //! is teleported where the player lands (`ServerPlayer.dismountTo`). use super::{Game, Keys, lan::LanHost, minecarts::SEAT}; use horizon_client::collisions::SceneCollisions; +use horizon_client::mobs::equine; use horizon_core::geometry::{Aabb, Vec3}; use horizon_protocol::{ packets::play::{self, Clientbound as C}, @@ -225,9 +226,10 @@ impl Game<'_> { } } - /// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider - /// drives goes where the client says, unless it moved too quickly or - /// wrongly; then it stays, and the client is told where it is. + /// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat or the + /// saddled horse its rider drives goes where the client says, unless it + /// moved too quickly or wrongly; then it stays, and the client is told + /// where it is. pub(super) fn guest_move_vehicle( &mut self, host: &mut LanHost, @@ -264,9 +266,41 @@ impl Game<'_> { }; host.hub.send_to(conn, &C::MoveVehicle(packet)); }; - // Only a boat is driven by its rider (getControllingPassenger); a - // minecart that is elsewhere on the client is put back. - let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else { + // Only a boat, and a horse its rider's client moves, are driven by + // their rider (getControllingPassenger); a minecart that is elsewhere + // on the client is put back. + let boat = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)); + let mount = vehicle.and_then(|v| { + self.mobs + .iter() + .position(|m| m.id == v && equine::moved_by_client(m)) + }); + // The vehicle's id, position, rotation, motion, box, step height + // and ground. + let (id, old, yaw, velocity, bounds, step, on_ground) = if let Some(i) = boat { + let b = &self.boats[i]; + ( + b.id, + b.position, + b.yaw, + b.velocity, + b.bounds(), + 0.0, + b.on_ground, + ) + } else if let Some(i) = mount { + let m = &self.mobs[i]; + let step = m.kind.step_height as f32; + ( + m.id, + m.position, + m.yaw, + m.velocity, + m.bounds(), + step, + m.on_ground, + ) + } else { if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v)) && c.position.distance_squared(Vec3::new(x, y, z)) > 1.0 { @@ -274,8 +308,6 @@ impl Game<'_> { } return; }; - let b = &self.boats[i]; - let old = b.position; let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2])); let target = Vec3::new( x.clamp(-3.0E7, 3.0E7), @@ -283,16 +315,15 @@ impl Game<'_> { z.clamp(-3.0E7, 3.0E7), ); // Moved too quickly: more than 10 blocks past its own motion. - let moved = target.distance_squared(old) - b.velocity.length_squared(); + let moved = target.distance_squared(old) - velocity.length_squared(); if moved > 100.0 && !owner { - log::warn!("boat {} moved too quickly! {moved}", b.id); - back(host, old, b.yaw); + log::warn!("vehicle {id} moved too quickly! {moved}"); + back(host, old, yaw); return; } - // vehicle.move(PLAYER, target - lastGood) with the boat's collisions: - // where it can't follow the client (a quarter block off, the height - // aside), it moved wrongly. - let bounds = b.bounds(); + // vehicle.move(PLAYER, target - lastGood) with the vehicle's + // collisions: where it can't follow the client (a quarter block off, + // the height aside), it moved wrongly. let mut scene = SceneCollisions { world: &self.world, binding: self.collision, @@ -300,7 +331,7 @@ impl Game<'_> { }; let wanted = target.subtract(good); let resolved = scene - .resolve(bounds, wanted, 0.0, b.on_ground) + .resolve(bounds, wanted, step, on_ground) .unwrap_or(wanted); let reached = old.add_vector(resolved); let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625; @@ -311,10 +342,24 @@ impl Game<'_> { .unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5))) .unwrap_or(true); if free_before && (wrongly || !free_after) { - log::warn!("boat {} moved wrongly", b.id); - back(host, old, b.yaw); + log::warn!("vehicle {id} moved wrongly"); + back(host, old, yaw); return; } + host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]); + if let Some(i) = mount { + // absSnapTo: where and how its rider's client turned it; the + // rider sits on it from its own ride tick. + let m = &mut self.mobs[i]; + m.position = target; + m.yaw = p.yaw; + m.head_pitch = p.pitch; + m.on_ground = p.on_ground; + return; + } + let Some(i) = boat else { + return; + }; let b = &mut self.boats[i]; b.previous = old; b.velocity = target.subtract(old); @@ -324,7 +369,6 @@ impl Game<'_> { b.on_ground = p.on_ground; let seat = self.player_boat_seat(i); let g = &mut host.hub.guests[gi]; - g.vehicle_good = Some([target.x, target.y, target.z]); g.previous_position = g.position; g.position = [seat.x, seat.y, seat.z]; } diff --git a/crates/horizon-client/src/game/launch_api.rs b/crates/horizon-client/src/game/launch_api.rs new file mode 100644 index 0000000000..3c305b2627 --- /dev/null +++ b/crates/horizon-client/src/game/launch_api.rs @@ -0,0 +1,266 @@ +//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass, +//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them. +//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`. + +/// The Launch API's version (docs/api/stability.md): an addition makes the next patch. +pub const VERSION: &str = "0.1.0"; + +/// One option of the contract: its name, its value's placeholder (none for a flag), what it does. +pub struct LaunchOption { + pub name: &'static str, + pub value: Option<&'static str>, + /// For the reference only. + #[cfg_attr(not(test), allow(dead_code))] + pub what: &'static str, +} + +const fn option( + name: &'static str, + value: Option<&'static str>, + what: &'static str, +) -> LaunchOption { + LaunchOption { name, value, what } +} + +/// What a launcher may pass. The game takes more (`--help` lists them), but only these are +/// promised: they keep their meaning within a line of this API. +pub const OPTIONS: &[LaunchOption] = &[ + option( + "--resources", + Some("DIR"), + "The prepared resources (horizon-prepare's output). Default: `resources` beside the executable, else `.local/horizon-client/26.3`.", + ), + option( + "--game-dir", + Some("DIR"), + "The game folder: `saves/`, `options.txt`, `resourcepacks/`, `horizon-mods/`, `screenshots/`. Created when missing. Default: the working directory. `--gameDir` is the same.", + ), + option( + "--home", + Some("DIR"), + "Logs (`logs/latest.log`) and crash reports (`crash-reports/`). Default: `HORIZON_HOME`, else `horizon` in the platform data folder.", + ), + option( + "--width", + Some("N"), + "The window's starting width (default 1280).", + ), + option( + "--height", + Some("N"), + "The window's starting height (default 720).", + ), + option( + "--fullscreen", + None, + "Opens the window fullscreen, whatever `options.txt` says; `options.txt` is not changed.", + ), + option( + "--session-file", + Some("PATH"), + "The signed-in account, as the session JSON below. Read once, then deleted.", + ), + option( + "--session-stdin", + None, + "The same JSON on standard input, so it never touches the disk.", + ), + option( + "--quickPlaySingleplayer", + Some("WORLD"), + "Opens this world of `saves/` at start, only if it exists (else the title screen). WORLD is one folder name: a path is refused.", + ), + option( + "--quickPlayMultiplayer", + Some("HOST[:PORT]"), + "Joins this server at start.", + ), + option( + "--launch-api", + None, + "Prints the reply below and exits 0, before anything else runs.", + ), +]; + +/// How the game ends, for the launcher that started it. +pub mod exit_code { + /// The player quit, or `--help` and `--launch-api` printed. + pub const OK: i32 = 0; + /// The game stopped on an error; a crash report was saved. + pub const ERROR: i32 = 1; + /// The command line was refused (an unknown option, a missing or bad value); no crash report. + pub const USAGE: i32 = 2; + /// A panic; a crash report was saved. + pub const PANIC: i32 = 101; +} + +const EXIT_CODES: &[(i32, &str)] = &[ + ( + exit_code::OK, + "The player quit, or `--help` or `--launch-api` printed.", + ), + ( + exit_code::ERROR, + "The game stopped on an error. A crash report was saved.", + ), + ( + exit_code::USAGE, + "The command line was refused: an unknown option, a missing or bad value. No crash report.", + ), + ( + exit_code::PANIC, + "The game panicked. A crash report was saved.", + ), +]; + +/// A command line the game refuses: exit code 2, no crash report. +#[derive(Debug)] +pub struct UsageError(pub String); + +impl std::fmt::Display for UsageError { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl std::error::Error for UsageError {} + +/// `--launch-api`'s reply for a build of this `horizon` version. +pub fn reply(horizon: &str) -> String { + let options: Vec<_> = OPTIONS + .iter() + .map(|o| serde_json::json!({ "name": o.name, "value": o.value })) + .collect(); + serde_json::to_string_pretty(&serde_json::json!({ + "launch_api": VERSION, + "horizon": horizon, + "minecraft": horizon_core::VANILLA_VERSION, + "options": options, + "exit_codes": EXIT_CODES.iter().map(|(code, _)| code).collect::>(), + "crash_report_line": horizon_client::crash::SAVED_LINE, + })) + .expect("a JSON value") +} + +/// docs/api/launch-reference.md. +#[cfg(test)] +pub fn markdown() -> String { + use std::fmt::Write; + let mut out = String::new(); + let w = &mut out; + let _ = writeln!( + w, + "" + ); + let _ = writeln!(w, "# Launch API reference\n"); + let _ = writeln!( + w, + "What `horizon-client` promises a launcher that starts it, Launch API {VERSION}. The guide: \ + [launch.md](launch.md); stability and versions: [stability.md](stability.md).\n" + ); + let _ = writeln!(w, "## Options\n"); + let _ = writeln!( + w, + "Any other option is refused with exit code 2, so ask `--launch-api` before passing one \ + a build may not know.\n" + ); + let _ = writeln!(w, "| Option | What it does |\n| --- | --- |"); + for o in OPTIONS { + let usage = match o.value { + Some(value) => format!("`{} {value}`", o.name), + None => format!("`{}`", o.name), + }; + let _ = writeln!(w, "| {usage} | {} |", o.what); + } + let _ = writeln!(w, "\n## `--launch-api`\n"); + let _ = writeln!( + w, + "Prints one JSON object on stdout and exits 0. `horizon` is the build's version; readers \ + ignore fields they don't know.\n" + ); + let _ = writeln!(w, "```json\n{}\n```\n", reply("")); + let _ = writeln!(w, "## Exit codes\n\n| Code | Meaning |\n| --- | --- |"); + for (code, what) in EXIT_CODES { + let _ = writeln!(w, "| {code} | {what} |"); + } + let _ = writeln!( + w, + "\nWhen a crash report is saved, the last line on stderr is `{}` followed by the \ + report's path.", + horizon_client::crash::SAVED_LINE.trim_end() + ); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> horizon_client::Result> { + super::super::Options::parse(args.iter().map(|a| a.to_string())) + } + + /// Every promised option is one the game accepts. + #[test] + fn launch_api_options_are_accepted() { + for o in OPTIONS { + let args: Vec<&str> = std::iter::once(o.name) + .chain(o.value.map(|v| if v == "N" { "100" } else { "x" })) + .collect(); + assert!(parse(&args).is_ok(), "{} is refused", o.name); + } + } + + #[test] + fn launch_api_flags_are_read() { + let o = parse(&["--fullscreen", "--game-dir", "g", "--session-stdin"]) + .unwrap() + .unwrap(); + assert!(o.fullscreen && o.session_stdin); + assert_eq!(o.game_dir.as_deref(), Some(std::path::Path::new("g"))); + assert!(parse(&["--launch-api"]).unwrap().is_none()); + assert!(parse(&["--no-such-option"]).is_err()); + } + + /// A launcher shows the error to the player, so it names the option. + #[test] + fn launch_api_bad_value_names_its_option() { + let error = |args: &[&str]| parse(args).err().unwrap().to_string(); + assert_eq!( + error(&["--width", "wide"]), + "--width: invalid digit found in string" + ); + assert_eq!(error(&["--bogus", "x"]), "--bogus: unknown option"); + } + + /// Quick play opens one folder of saves/, as on vanilla: nothing outside it. + #[test] + fn launch_api_quick_play_is_one_folder_of_saves() { + for name in ["../x", "a/b", "/abs", ".", ".."] { + assert!( + parse(&["--quickPlaySingleplayer", name]).is_err(), + "{name} is accepted" + ); + } + assert!(parse(&["--quickPlaySingleplayer", "E2E World"]).is_ok()); + } + + /// docs/api/launch-reference.md is what `markdown` writes. + #[test] + fn launch_api_reference_is_current() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .join("docs/api/launch-reference.md"); + let want = markdown(); + if std::env::var_os("HORIZON_WRITE_DOCS").is_some() { + std::fs::write(&path, &want).unwrap(); + } + let have = std::fs::read_to_string(&path) + .unwrap_or_default() + .replace("\r\n", "\n"); + assert!( + have == want, + "docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api" + ); + } +} diff --git a/crates/horizon-client/src/game/level_particles.rs b/crates/horizon-client/src/game/level_particles.rs index 17359e4e52..4b61ebbeaf 100644 --- a/crates/horizon-client/src/game/level_particles.rs +++ b/crates/horizon-client/src/game/level_particles.rs @@ -249,10 +249,7 @@ impl Game<'_> { let mut root = None; for step in 1..11 { let y = pos.y + step; - if !(horizon_client::chunk::MIN_Y - ..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT) - .contains(&y) - { + if !(self.world.bottom_y()..self.world.top_y()).contains(&y) { break; } let state = self.world.block(pos.x, y, pos.z); diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index 6a7b88e134..cb01d64a1f 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -112,6 +112,11 @@ pub(super) struct Levels { } impl Levels { + /// A parked level, without opening or loading it. + pub(super) fn parked_world(&self, dim: Dim) -> Option<&World> { + self.parked.get(&dim).map(|level| &level.world) + } + /// The level not in place, in `dim`. pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> { self.parked.get_mut(&dim) @@ -136,6 +141,7 @@ impl Game<'_> { self.level.seed, )?; let mut world = World::new(self.res.facts().air, self.level.seed); + world.shape = dim.shape(); world.fast_lava = dim == Dim::Nether; Ok(Parked { world, @@ -365,8 +371,18 @@ impl Game<'_> { } } self.level_pass = Pass::default(); + if result.is_err() { + self.native_lifecycle.cancel_server_tick(); + } result?; - self.close_idle_levels() + if let Err(error) = self.close_idle_levels() { + self.native_lifecycle.cancel_server_tick(); + return Err(error); + } + // `ServerTickEnd`: an errored tick or an idle-close failure (at + // the `?` above) never reaches this site. + self.native_lifecycle_server_tick_end(paused); + Ok(()) } /// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a diff --git a/crates/horizon-client/src/game/lightning.rs b/crates/horizon-client/src/game/lightning.rs index 70d919b606..6448a4a57f 100644 --- a/crates/horizon-client/src/game/lightning.rs +++ b/crates/horizon-client/src/game/lightning.rs @@ -21,6 +21,9 @@ pub(super) struct Bolt { converted: Vec, /// The mobs it has struck (`hitEntities`). hit: Vec, + /// Items a cushion dropped under it, spared for 20 ticks + /// (`Cushion.dropItem`: `setInvulnerableTime(20)`). + spared: Vec, /// Blocks it set on fire (`blocksSetOnFire`). fires: i32, /// The player who channelled it (`LightningBolt.cause`): `Some(None)` for @@ -119,7 +122,7 @@ impl Game<'_> { if !section.blocks.maybe_has(&mut rod) { continue; } - let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32; + let base_y = self.world.bottom_y() + 16 * i as i32; for j in 0..4096 { if !rod(section.get(j)) { continue; @@ -219,6 +222,7 @@ impl Game<'_> { visual_only, converted: Vec::new(), hit: Vec::new(), + spared: Vec::new(), fires: 0, cause: None, }); @@ -240,6 +244,7 @@ impl Game<'_> { visual_only: true, converted: Vec::new(), hit: Vec::new(), + spared: Vec::new(), fires: 0, cause: None, }); @@ -505,6 +510,17 @@ impl Game<'_> { if !events.is_empty() { let _ = self.handle_mob_events(events); } + // Cushion.thunderHit: a cushion in the box breaks and drops its item + // (the bolt is no player, so no creative spare). Last first, since + // break_cushion removes the entry. + for i in (0..self.cushions.len()).rev() { + let p = self.cushions[i].position; + if inside([p.x, p.y, p.z]) { + let before = self.dropped.len(); + self.break_cushion(i, false); + b.spared.extend(self.dropped[before..].iter().map(|e| e.id)); + } + } // Every player in the box, the server's players too. let strike = move |g: &mut Self| { if let Some(p) = g.player.as_ref().map(|p| p.position) @@ -527,6 +543,7 @@ impl Game<'_> { self.dropped.retain(|e| { !inside([e.position.x, e.position.y, e.position.z]) || e.stack.item.contains("netherite") + || (e.age < 20 && b.spared.contains(&e.id)) }); struck.into_iter().map(|(_, view)| view).collect() } diff --git a/crates/horizon-client/src/game/maps.rs b/crates/horizon-client/src/game/maps.rs index b6d0b604ea..f164f9c203 100644 --- a/crates/horizon-client/src/game/maps.rs +++ b/crates/horizon-client/src/game/maps.rs @@ -1074,7 +1074,7 @@ impl Game<'_> { for dz in 0..scale { let (x, z) = (min_x + dx, min_z + dz); let mut y = self.world_surface(x, z); - let min_y = super::MIN_Y; + let min_y = self.world.bottom_y(); let mut color; if y <= min_y { color = 11; diff --git a/crates/horizon-client/src/game/mounts.rs b/crates/horizon-client/src/game/mounts.rs index eef359d585..b16346c86c 100644 --- a/crates/horizon-client/src/game/mounts.rs +++ b/crates/horizon-client/src/game/mounts.rs @@ -398,6 +398,9 @@ impl Game<'_> { steer, sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft), jumping: keys.jump, + // A server's player (a LAN guest, single player's window) drives + // a horse from its own client (`handleMoveVehicle`). + client: self.lan_acting.is_some(), }); true } diff --git a/crates/horizon-client/src/game/moving_pistons.rs b/crates/horizon-client/src/game/moving_pistons.rs index 738f2a5a4e..b3e2180481 100644 --- a/crates/horizon-client/src/game/moving_pistons.rs +++ b/crates/horizon-client/src/game/moving_pistons.rs @@ -58,7 +58,7 @@ impl Game<'_> { /// Whether the chunk mesh doesn't show the blocks at `p` yet (edited, /// meshing, or waiting for its upload). pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool { - let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else { + let Some(y) = p[1].checked_sub(self.world.layout().min_y()) else { return false; }; let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4); diff --git a/crates/horizon-client/src/game/names.rs b/crates/horizon-client/src/game/names.rs index 4d1d1b0f72..7ed599a906 100644 --- a/crates/horizon-client/src/game/names.rs +++ b/crates/horizon-client/src/game/names.rs @@ -77,7 +77,7 @@ impl Game<'_> { || self.camera_type.first_person() || self.sleeping.is_some() || self.spectator() - || self.potions.effects.get("invisibility").is_some() + || self.own_invisible() { return None; } diff --git a/crates/horizon-client/src/game/native_hooks.rs b/crates/horizon-client/src/game/native_hooks.rs index 4fb78b0383..7d196702a0 100644 --- a/crates/horizon-client/src/game/native_hooks.rs +++ b/crates/horizon-client/src/game/native_hooks.rs @@ -6,6 +6,14 @@ //! Horizon's own HUD extras do, and keep a mod's settings where the //! WebAssembly mod of the same id kept them. //! +//! API 7 adds block-change observations (`native_block_changed`). +//! +//! API 6 adds neighbour-change observations (`native_neighbor_changed`). +//! +//! API 5 adds read-only server block queries (`native_server_block`). +//! +//! API 4 adds authoritative server tick hooks (`native_server_tick`). +//! //! API 3 adds the world lifecycle hook (`native_world_lifecycle`). //! //! API 2 adds the item panel beside container screens and its recipe pages @@ -21,8 +29,11 @@ //! that would break such a bundle is not made. //! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with //! either (`tests::hook_points_stay_hookable`). -use super::native_lifecycle::{NativeLifecycleContext, WorldPhase}; -use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage}; +use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase}; +use super::{ + Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole, + native_panel_recipes::RecipePage, +}; use horizon_client::{ chat::ChatSource, extras::Corner, @@ -32,13 +43,44 @@ use horizon_client::{ mod_list::{self, Installed, NativePage}, text::Text, }; +use horizon_core::position::BlockPos; +use horizon_world::{level::BlockId, registry::BlockStateId}; use std::{fs, path::PathBuf, sync::Arc, time::Duration}; use winit::{event::MouseButton, keyboard::KeyCode}; /// The hook points' API version. The launcher reads this line from the /// source it builds; keep its form. #[allow(dead_code)] -pub(crate) const NATIVE_HOOKS_API: u32 = 3; +pub(crate) const NATIVE_HOOKS_API: u32 = 7; + +impl GameLevel<'_> { + /// An authoritative server committed a changed state through `Level.setBlock`. + /// The world contains `new`; block side effects and neighbour updates follow. + #[inline(always)] + pub(super) fn native_block_changed( + &self, + dimension: LevelDimension, + pos: BlockPos, + old: BlockStateId, + new: BlockStateId, + ) { + let _ = (dimension, pos, old, new); + } + + /// An authoritative server is about to deliver `neighborChanged`. + /// State and block IDs are valid in this level's registries for this call. + #[inline(always)] + pub(super) fn native_neighbor_changed( + &self, + dimension: LevelDimension, + pos: BlockPos, + state: BlockStateId, + changed: BlockId, + moved: bool, + ) { + let _ = (dimension, pos, state, changed, moved); + } +} /// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), /// eased through the field of view like the spyglass when `smooth`, and the @@ -284,6 +326,16 @@ impl Game<'_> { ) { let _ = (context, phase, error_text); } + /// The authoritative server tick's phases, read-only. A paused pass + /// fires nothing; a frozen world carries `world_running` false except + /// during a requested step. + pub(super) fn native_server_tick( + &mut self, + context: &NativeLifecycleContext, + phase: ServerTickPhase, + ) { + let _ = (context, phase); + } /// The HUD (not drawn under F1), after the minimap: place a box with /// `corners.place` (boxes stack away from their edge) and draw it with /// `native_fill` and `native_text`. Skip a corner `native_corner_shown` @@ -692,8 +744,25 @@ mod tests { .split("/// Helpers for native mods") .next() .unwrap(); + let level_section = source + .split("impl GameLevel<'_> {") + .nth(1) + .unwrap() + .split("\n}") + .next() + .unwrap(); + let level_hooks: BTreeSet<_> = level_section + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("pub(super) fn native_")? + .split('(') + .next() + }) + .collect(); let declared: BTreeSet<_> = section .lines() + .chain(level_section.lines()) .filter_map(|line| { line.trim() .strip_prefix("pub(super) fn native_")? @@ -711,9 +780,14 @@ mod tests { "invalid or duplicate hook: {name} v{version}" ); names.insert(name); + let owner = if level_hooks.contains(name) { + "GameLevel" + } else { + "Game" + }; assert_eq!( hook["target"].as_str().unwrap(), - format!("horizon_client::game::native_hooks::Game<'_>::native_{name}") + format!("horizon_client::game::native_hooks::{owner}<'_>::native_{name}") ); } assert_eq!(names, declared); diff --git a/crates/horizon-client/src/game/native_lifecycle.rs b/crates/horizon-client/src/game/native_lifecycle.rs index 623e94142c..4a9de48570 100644 --- a/crates/horizon-client/src/game/native_lifecycle.rs +++ b/crates/horizon-client/src/game/native_lifecycle.rs @@ -1,5 +1,6 @@ -//! Read-only world lifecycle observations for native mods on authoritative -//! servers. The deprecated in-frame path delivers no lifecycle events. +//! Read-only lifecycle observations for native mods on authoritative servers. +//! The deprecated in-frame path delivers no lifecycle events. A paused pass +//! observes nothing; a frozen world reports `world_running: false` except during a step. use std::sync::atomic::{AtomicU64, Ordering}; @@ -22,6 +23,10 @@ pub(super) enum NativeAuthority { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct NativeWorldEpoch(u64); +/// Non-paused authoritative server ticks since open (not game or frame time). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct NativeTickId(u64); + /// Internal state for deciding which events can fire. #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum OpenState { @@ -44,14 +49,23 @@ pub(super) enum WorldPhase { WorldCloseFailed, } +/// The tick phases carry whether the world runs, including frozen steps. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ServerTickPhase { + ServerTickStart { world_running: bool }, + ServerTickEnd { world_running: bool }, +} + /// Context for every world lifecycle phase. /// `authority` is the server role. LocalAuthoritative and RemoteClient never /// fire today. `epoch` identifies the open attempt, including failed opens; /// the same epoch identifies its ready, final-close, or failed-close event. +/// `tick` counts non-paused server ticks since the world opened. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct NativeLifecycleContext { pub(super) authority: NativeAuthority, pub(super) epoch: NativeWorldEpoch, + pub(super) tick: NativeTickId, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -59,6 +73,10 @@ struct OpenAttempt { authority: NativeAuthority, epoch: u64, session: OpenState, + ticks: u64, + /// A tick started and its end has not fired (or the tick errored). + tick_open: bool, + tick_world_running: bool, } impl OpenAttempt { @@ -66,19 +84,30 @@ impl OpenAttempt { NativeLifecycleContext { authority: self.authority, epoch: NativeWorldEpoch(self.epoch), + tick: NativeTickId(self.ticks), } } } -/// The lifecycle bookkeeping: the current attempt's epoch and session phase. -/// It only decides whether a phase fires; every hook body is empty, so with no -/// native mods nothing changes. +/// The lifecycle bookkeeping: the current attempt's epoch, session phase +/// and non-paused tick count. It only decides whether a phase fires; every +/// hook body is empty, so with no native mods nothing changes. #[derive(Default)] pub(super) struct NativeLifecycle { attempt: Option, + read_scope: bool, } impl NativeLifecycle { + /// A read is valid only during a tick hook with its current tick context. + pub(super) fn permits_read(&self, context: &NativeLifecycleContext) -> bool { + self.read_scope + && self + .attempt + .as_ref() + .is_some_and(|a| a.session == OpenState::Ready && a.context() == *context) + } + /// The open starts: a fresh process-wide epoch, never reused. pub(super) fn begin_open(&mut self, authority: NativeAuthority) { let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed); @@ -86,6 +115,9 @@ impl NativeLifecycle { authority, epoch, session: OpenState::Opening, + ticks: 0, + tick_open: false, + tick_world_running: false, }); } @@ -136,6 +168,49 @@ impl NativeLifecycle { self.attempt = None; Some(context) } + + /// Non-paused ready ticks only; a paused pass observes nothing, does not count. + pub(super) fn server_tick_start( + &mut self, + world_running: bool, + ) -> Option<(NativeLifecycleContext, ServerTickPhase)> { + let attempt = self.attempt.as_mut()?; + if attempt.session != OpenState::Ready { + return None; + } + attempt.ticks += 1; + attempt.tick_open = true; + attempt.tick_world_running = world_running; + Some(( + attempt.context(), + ServerTickPhase::ServerTickStart { world_running }, + )) + } + + /// Discard the pending end when a tick fails. + pub(super) fn cancel_server_tick(&mut self) { + if let Some(attempt) = &mut self.attempt { + attempt.tick_open = false; + } + } + + /// Pairs the last start; an errored tick never reaches it. + pub(super) fn server_tick_end( + &mut self, + paused: bool, + ) -> Option<(NativeLifecycleContext, ServerTickPhase)> { + let attempt = self.attempt.as_mut()?; + if paused || attempt.session != OpenState::Ready || !attempt.tick_open { + return None; + } + attempt.tick_open = false; + Some(( + attempt.context(), + ServerTickPhase::ServerTickEnd { + world_running: attempt.tick_world_running, + }, + )) + } } impl Game<'_> { @@ -185,12 +260,58 @@ impl Game<'_> { self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, ""); } } + + pub(super) fn native_lifecycle_server_tick_start(&mut self, world_running: bool) { + if let Some((context, phase)) = self.native_lifecycle.server_tick_start(world_running) { + self.native_lifecycle.read_scope = true; + self.native_server_tick(&context, phase); + self.native_lifecycle.read_scope = false; + } + } + + /// An errored tick returned at `result?` before this site. + pub(super) fn native_lifecycle_server_tick_end(&mut self, paused: bool) { + if let Some((context, phase)) = self.native_lifecycle.server_tick_end(paused) { + self.native_lifecycle.read_scope = true; + self.native_server_tick(&context, phase); + self.native_lifecycle.read_scope = false; + } + } } #[cfg(test)] mod tests { use super::*; + #[test] + fn frozen_steps_are_observed_without_consuming_them() { + let mut lifecycle = opening(); + lifecycle.world_open_ready(); + let mut clock = horizon_client::clock::FixedStep::default(); + clock.set_frozen(true); + clock.set_frozen_ticks_to_run(4); + let mut observed = 0; + for _ in 0..6 { + let running = clock.world_runs(); + let (_, start) = lifecycle.server_tick_start(running).unwrap(); + let (_, end) = lifecycle.server_tick_end(false).unwrap(); + assert_eq!( + start, + ServerTickPhase::ServerTickStart { + world_running: running + } + ); + assert_eq!( + end, + ServerTickPhase::ServerTickEnd { + world_running: running + } + ); + observed += usize::from(running); + } + assert_eq!(observed, 4); + } + /// A lifecycle mid-open on the server thread. fn opening() -> NativeLifecycle { let mut lifecycle = NativeLifecycle::default(); @@ -198,6 +319,27 @@ mod tests { lifecycle } + #[test] + fn block_reads_require_the_current_hook_scope() { + let mut life = NativeLifecycle::default(); + life.begin_open(NativeAuthority::IntegratedServer); + let ready = life.world_open_ready().unwrap(); + assert!(!life.permits_read(&ready)); + let (tick, _) = life.server_tick_start(true).unwrap(); + life.read_scope = true; + assert!(life.permits_read(&tick)); + assert!(!life.permits_read(&ready)); + life.read_scope = false; + assert!(!life.permits_read(&tick)); + life.server_tick_end(false).unwrap(); + life.begin_close(); + life.world_close_final().unwrap(); + life.begin_open(NativeAuthority::IntegratedServer); + life.world_open_ready().unwrap(); + life.read_scope = true; + assert!(!life.permits_read(&tick)); + } + #[test] fn world_ready_fires_once_per_epoch() { let mut lifecycle = opening(); @@ -213,6 +355,7 @@ mod tests { let context = lifecycle.world_open_failed().expect("the failure fires"); let failed_epoch = context.epoch; assert!(lifecycle.world_open_ready().is_none(), "the epoch is gone"); + assert!(lifecycle.server_tick_start(true).is_none()); lifecycle.begin_open(NativeAuthority::IntegratedServer); let context = lifecycle.world_open_ready().expect("the retry opens fresh"); assert!(context.epoch.0 > failed_epoch.0, "a fresh epoch"); @@ -229,6 +372,7 @@ mod tests { lifecycle.world_close_final().is_none(), "a repeated close request is a no-op" ); + assert!(lifecycle.server_tick_start(true).is_none()); assert!(lifecycle.world_open_ready().is_none()); } @@ -261,8 +405,8 @@ mod tests { .unwrap(); let load = source.find("self.load_world(WorldRequest::new(").unwrap(); let ready = source.find("self.native_lifecycle_world_ready();").unwrap(); - let tick_loop = source.find("self.tick_server_timed(paused)?;").unwrap(); - assert!(open < load && load < ready && ready < tick_loop); + let tick = source.find("self.tick_server_timed(paused)?;").unwrap(); + assert!(open < load && load < ready && ready < tick); let stop = source.split("fn stop_dedicated").nth(1).unwrap(); let begin = stop.find("self.native_lifecycle.begin_close();").unwrap(); @@ -272,6 +416,33 @@ mod tests { assert!(begin < save && save < close && close < release); } + #[test] + fn failed_server_ticks_cancel_before_end() { + let tick = include_str!("levels.rs") + .split("fn tick_server(&mut self, paused: bool)") + .nth(1) + .unwrap() + .split("fn tick_server_timed") + .next() + .unwrap(); + let first_cancel = tick + .find("self.native_lifecycle.cancel_server_tick();") + .unwrap(); + let result = tick.find("result?;").unwrap(); + let close = tick + .find("if let Err(error) = self.close_idle_levels()") + .unwrap(); + let second_cancel = tick + .rfind("self.native_lifecycle.cancel_server_tick();") + .unwrap(); + let end = tick + .find("self.native_lifecycle_server_tick_end(paused);") + .unwrap(); + assert!( + first_cancel < result && result < close && close < second_cancel && second_cancel < end + ); + } + #[test] fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() { let game = include_str!("../game.rs") diff --git a/crates/horizon-client/src/game/native_world_read.rs b/crates/horizon-client/src/game/native_world_read.rs new file mode 100644 index 0000000000..ef43663ba3 --- /dev/null +++ b/crates/horizon-client/src/game/native_world_read.rs @@ -0,0 +1,115 @@ +//! Read-only block queries for a native server tick hook. +use super::{Dim, Game, native_lifecycle::NativeLifecycleContext}; +use horizon_client::world::World; +use horizon_world::{chunk::BlockState, registry::BlockRegistry}; + +/// Why a server block cannot be read. +#[allow(dead_code)] +#[derive(Debug, PartialEq, Eq)] +pub(super) enum NativeBlockReadError { + /// The context is stale or the call is outside its server tick hook. + InactiveContext, + /// The dimension is unknown or is not currently open on the server. + UnavailableDimension, + OutOfBounds, + Unloaded, + InvalidState, +} + +impl Game<'_> { + /// Read a loaded block in a server tick hook, without loading a chunk. + /// The returned name and properties are owned and remain valid after the hook. + #[allow(dead_code)] + pub(super) fn native_server_block( + &self, + context: &NativeLifecycleContext, + dimension: &str, + pos: [i32; 3], + ) -> Result { + if !self.native_lifecycle.permits_read(context) { + return Err(NativeBlockReadError::InactiveContext); + } + let dim = match dimension { + "minecraft:overworld" => Dim::Overworld, + "minecraft:the_nether" => Dim::Nether, + "minecraft:the_end" => Dim::End, + _ => return Err(NativeBlockReadError::UnavailableDimension), + }; + let world = if dim == self.dim { + Some(&self.world) + } else { + self.levels.parked_world(dim) + } + .ok_or(NativeBlockReadError::UnavailableDimension)?; + read_block(world, dim, self.pack.assets.registry, pos) + } +} + +/// Resolve an existing world state, preserving unloaded and invalid states. +fn read_block( + world: &World, + dimension: Dim, + registry: &BlockRegistry, + [x, y, z]: [i32; 3], +) -> Result { + if !(-30_000_000..30_000_000).contains(&x) + || !(-30_000_000..30_000_000).contains(&z) + || !(dimension.min_y()..=dimension.max_y()).contains(&y) + { + return Err(NativeBlockReadError::OutOfBounds); + } + if !world.loaded(x, z) { + return Err(NativeBlockReadError::Unloaded); + } + registry + .state(world.block(x, y, z)) + .cloned() + .ok_or(NativeBlockReadError::InvalidState) +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::chunk::Chunk; + use horizon_world::registry::BlockStateId; + use std::sync::Arc; + + #[test] + fn reads_only_loaded_in_bounds_states() { + let facts = br#"{"defaults":{"minecraft:air":0,"test:pillar":1},"states":[ + ["air",true,false,"","minecraft:air"], + ["pillar",false,false,"",{"id":"test:pillar","properties":{"axis":"x"}}] + ]}"#; + let registry = BlockRegistry::from_facts_json(&facts[..]).unwrap(); + let mut world = World::new(BlockStateId(0), 0); + world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0))); + world.set(-1, 64, 1, BlockStateId(1)).unwrap(); + let block = read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]).unwrap(); + assert_eq!(block.name, "test:pillar"); + assert_eq!(block.properties["axis"], "x"); + assert_eq!( + read_block(&world, Dim::Overworld, ®istry, [-1, 65, 1]) + .unwrap() + .name, + "minecraft:air" + ); + assert_eq!( + read_block(&world, Dim::Overworld, ®istry, [0, 64, 1]), + Err(NativeBlockReadError::Unloaded) + ); + assert_eq!( + read_block(&world, Dim::Nether, ®istry, [-1, -1, 1]), + Err(NativeBlockReadError::OutOfBounds) + ); + assert_eq!( + read_block(&world, Dim::Overworld, ®istry, [30_000_000, 64, 1]), + Err(NativeBlockReadError::OutOfBounds) + ); + world.set(-1, 64, 1, BlockStateId(99)).unwrap(); + assert_eq!( + read_block(&world, Dim::Overworld, ®istry, [-1, 64, 1]), + Err(NativeBlockReadError::InvalidState) + ); + assert_eq!(world.chunks.len(), 1, "reads must not load chunks"); + } +} diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index d337e03107..7ad8a001e7 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -234,10 +234,10 @@ impl SpawnLevel for GameLevel<'_, '_> { } impl NaturalLevel for GameLevel<'_, '_> { fn min_y(&self) -> i32 { - horizon_client::chunk::MIN_Y + self.w.world.bottom_y() } fn max_y(&self) -> i32 { - horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1 + self.w.world.top_y() - 1 } fn ambient_light(&self) -> f32 { match self.game.dim { @@ -356,7 +356,7 @@ impl NaturalLevel for GameLevel<'_, '_> { spawn_selection::heightmap_blocks(self, kind, x, y, z) }) }) - .map_or(horizon_client::chunk::MIN_Y, |y| y + 1) + .map_or(self.w.world.bottom_y(), |y| y + 1) } fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool { self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0 diff --git a/crates/horizon-client/src/game/nether_portal.rs b/crates/horizon-client/src/game/nether_portal.rs index e0fb6874ed..ca4cb8d7a2 100644 --- a/crates/horizon-client/src/game/nether_portal.rs +++ b/crates/horizon-client/src/game/nether_portal.rs @@ -339,7 +339,7 @@ impl Game<'_> { }) { continue; } - let base = horizon_client::chunk::MIN_Y + 16 * s as i32; + let base = self.world.bottom_y() + 16 * s as i32; for y in base..base + 16 { for lz in 0..16 { for lx in 0..16 { @@ -869,21 +869,18 @@ impl Game<'_> { reach: horizon_core::geometry::Aabb, sections: &mut horizon_client::portal_sections::PortalSections, ) -> bool { - use horizon_client::{ - chunk::{MIN_Y, SECTIONS}, - section_mask::SectionMask, - }; + use horizon_client::section_mask::SectionMask; let (min, max) = (reach.min_position(), reach.max_position()); if !min.is_finite() || !max.is_finite() { return true; } let (min, max) = (BlockPos::containing(min), BlockPos::containing(max)); - let top = SECTIONS as i32 - 1; + let top = self.world.layout().sections() as i32 - 1; let (x0, x1) = (min.x >> 4, max.x >> 4); let (z0, z1) = (min.z >> 4, max.z >> 4); let (y0, y1) = ( - ((min.y - MIN_Y) >> 4).clamp(0, top), - ((max.y - MIN_Y) >> 4).clamp(0, top), + ((min.y - self.world.bottom_y()) >> 4).clamp(0, top), + ((max.y - self.world.bottom_y()) >> 4).clamp(0, top), ); let count = (i64::from(x1) - i64::from(x0) + 1) * (i64::from(y1) - i64::from(y0) + 1) diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 9c9c93ffc3..22e0e2e0d0 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -220,6 +220,12 @@ impl Game<'_> { Event::SliderDrag => { self.apply_settings(); } + // Options.createSoundSliderOptionInstance: the preview plays + // only without a level (on the title screen's options). + Event::Volume(source, volume) if self.title.is_some() => { + self.audio.preview(source, volume); + } + Event::Volume(..) => {} Event::CopyAgentDetails => { use horizon_client::clipboard::Clipboard; let text = self.agent_details(); diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 2096e8c0e7..6707270430 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -136,45 +136,94 @@ impl Game<'_> { /// models and sprites (every section is meshed again, /// `LevelRenderer.allChanged`), the shared atlas (GUI, item, entity, /// particle and font textures), item models, sounds, languages, GUI - /// metadata and the cloud and world border textures. + /// metadata and the cloud and world border textures. The loading runs on + /// a worker under the progress overlay (`LoadingOverlay` over + /// `ReloadableResourceManager.createReload`'s background executor), so + /// the window keeps drawing; `drive_pack_reload` applies it. pub(super) fn reload_packs(&mut self) { // Pack reload updates tail-owned mob metadata. Keep menus responsive // until those resources exist, then apply the latest selection once. - if self.rest_loader.is_some() || self.world_loader.is_some() { + // A reload already under its overlay finishes first (vanilla's + // `pendingReload`, which `runTick` starts once the overlay is gone). + if reload_waits( + self.rest_loader.is_some() || self.world_loader.is_some(), + self.pack_reload.is_some(), + ) { self.pending_pack_reload = true; return; } - if let Err(e) = self.reload_selected_packs() { - log::warn!("Horizon: resource reload failed: {e}"); - if self.screen != super::Screen::Options { - self.open_options_at( - self.title.is_some(), - horizon_client::options_ui::Page::Packs, - ); - } - self.ui.options.resource_error = Some(e.to_string()); + if let Err(e) = self.spawn_pack_reload(ReloadKind::Selection) { + self.selection_reload_failed(&format!("could not start the pack reload: {e}")); } } - /// The CPU half of the reload, safe on a worker (`PackReload` runs it): - /// `apply_staged_pack` finishes the GPU half and the swap. - pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result { + /// `reloadResourcePacks`' failure: the pack screen again, with the error. + fn selection_reload_failed(&mut self, error: &str) { + log::warn!("Horizon: resource reload failed: {error}"); + if self.screen != super::Screen::Options { + self.open_options_at( + self.title.is_some(), + horizon_client::options_ui::Page::Packs, + ); + } + self.ui.options.resource_error = Some(error.to_string()); + } + /// Stages the selection with the server's active packs on top on a + /// worker (`createReload`'s background executor); the frame draws its + /// progress meanwhile, and the overlay takes no input (`LoadingOverlay`), + /// so what is held is released (`KeyMapping.releaseAll`). + pub(super) fn spawn_pack_reload(&mut self, kind: ReloadKind) -> std::io::Result<()> { + self.keys.clear(); + self.physical_keys.clear(); + self.left = false; + self.right = false; let vanilla = self.vanilla_root(); if self.ui.vanilla_root.as_os_str().is_empty() { self.ui.vanilla_root = vanilla.clone(); } - stage_pack( - &vanilla, - &self.settings.resource_packs, - &self.server_packs.active, - &self.settings.mods.enabled, - &self.options.dev_mods, - &self.pack.assets, - &self.res, - &self.pack.context, - self.settings.force_unicode, - self.settings.japanese_glyphs, - &super::Progress::new(), - ) + let (selected, server, mods, dev_mods) = ( + self.settings.resource_packs.clone(), + self.server_packs.active.clone(), + self.settings.mods.enabled.clone(), + self.options.dev_mods.clone(), + ); + let (assets, res, context) = ( + self.pack.assets.clone(), + self.res.clone(), + self.pack.context.clone(), + ); + let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); + self.pack_reload = Some(PackReload::spawn(kind, move |progress| { + stage_pack( + &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode, + japanese, progress, + ) + .map_err(|e| e.to_string()) + })?); + Ok(()) + } + /// `LoadingOverlay.tick` once the reload is done: the staged pack is + /// applied on the render thread, and its outcome goes to whoever asked + /// (`reloadResourcePacks`' callback, or `PackReloadConfig.Callbacks`). + pub(super) fn drive_pack_reload(&mut self) { + let Some((kind, staged)) = self.pack_reload.as_mut().and_then(PackReload::finished) else { + return; + }; + self.pack_reload = None; + let result = + staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())); + match kind { + ReloadKind::Selection => { + if let Err(e) = result { + self.selection_reload_failed(&e); + } + } + ReloadKind::Server => { + if let Err(e) = &result { + log::warn!("Horizon: server resource pack reload failed: {e}"); + } + self.finish_pack_reload(result.is_ok()); + } + } } /// The render-thread half: the renderer on the new pack's textures, then /// the swap (`ReloadInstance`'s application listeners). @@ -257,16 +306,10 @@ impl Game<'_> { self.creative_screen.forget_items(); Ok(()) } - /// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then - /// applied (`PackReload` runs the same two phases across frames). - pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> { - let staged = self.stage_selected_packs()?; - self.apply_staged_pack(staged) - } } -/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a -/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay +/// The CPU half of a pack reload (`PackReload` runs it on a worker; +/// `apply_staged_pack` does the GPU half and the swap): the overlay /// root, the models and sprites, the menu bundle, the pack's tables and its /// CPU mip chains. pub(super) fn stage_pack( @@ -320,3 +363,127 @@ pub(super) struct StagedPack { pub pack: PackResources, pub textures: PreparedTextures, } + +/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. +const RELOAD_LINES: [&str; 4] = [ + "Preparing resources", + "Loading assets", + "Loading pack tables", + "Applying resources", +]; +/// Whether a reload asked for now waits: for the world's resources, or for +/// the reload already under its overlay (`Minecraft.reloadResourcePacks` +/// hands back `pendingReload` while a `LoadingOverlay` is up). +fn reload_waits(world_loading: bool, in_flight: bool) -> bool { + world_loading || in_flight +} +/// Who asked for a pack reload: its outcome goes back there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum ReloadKind { + /// The player's own selection (`Minecraft.reloadResourcePacks`). + Selection, + /// A server's packs (`DownloadedPackSource.startReload`). + Server, +} +type StageResult = std::result::Result; +/// A pack reload whose CPU half runs on a worker while `progress` feeds the +/// loading overlay; `Game::drive_pack_reload` applies it on the render thread. +pub(super) struct PackReload { + pub(super) progress: super::Progress, + kind: ReloadKind, + handle: Option>, +} +impl PackReload { + /// Starts `stage` on its own thread and returns at once. + fn spawn( + kind: ReloadKind, + stage: impl FnOnce(&super::Progress) -> StageResult + Send + 'static, + ) -> std::io::Result { + let progress = super::Progress::with(&RELOAD_LINES); + let worker = progress.clone(); + let handle = std::thread::Builder::new() + .name("pack-reload".into()) + .spawn(move || stage(&worker))?; + Ok(Self { + progress, + kind, + handle: Some(handle), + }) + } + pub(super) fn kind(&self) -> ReloadKind { + self.kind + } + /// The staged pack once the worker is done (taken once); none while it runs. + fn finished(&mut self) -> Option<(ReloadKind, StageResult)> { + if !self.handle.as_ref()?.is_finished() { + return None; + } + let staged = self + .handle + .take()? + .join() + .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); + Some((self.kind, staged)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::mpsc, time::Duration}; + + fn wait(reload: &mut PackReload) -> (ReloadKind, StageResult) { + for _ in 0..500 { + if let Some(done) = reload.finished() { + return done; + } + std::thread::sleep(Duration::from_millis(10)); + } + panic!("the reload worker never finished"); + } + + #[test] + fn a_reload_stages_on_a_worker_while_the_caller_goes_on() { + // The stage blocks until the caller lets it go: `spawn` returning at + // all means the window thread is free to draw the overlay. + let (release, blocked) = mpsc::channel::<()>(); + let (thread, ran_on) = mpsc::channel(); + let mut reload = PackReload::spawn(ReloadKind::Selection, move |progress| { + blocked.recv().unwrap(); + progress.set(2); + thread + .send(std::thread::current().name().map(str::to_owned)) + .unwrap(); + Err("staged".into()) + }) + .unwrap(); + assert!(reload.finished().is_none(), "still staging"); + assert_eq!(reload.progress.line(), "Preparing resources"); + release.send(()).unwrap(); + let (kind, staged) = wait(&mut reload); + assert_eq!(kind, ReloadKind::Selection); + assert_eq!(staged.err().as_deref(), Some("staged")); + assert_eq!(reload.progress.line(), "Loading pack tables"); + assert_eq!(ran_on.recv().unwrap().as_deref(), Some("pack-reload")); + assert!(reload.finished().is_none(), "the outcome is taken once"); + } + + #[test] + fn a_panicking_stage_fails_the_reload() { + let mut reload = PackReload::spawn(ReloadKind::Server, |_| panic!("bad pack")).unwrap(); + let (kind, staged) = wait(&mut reload); + assert_eq!(kind, ReloadKind::Server); + assert_eq!( + staged.err().as_deref(), + Some("the pack reload worker panicked") + ); + } + + #[test] + fn a_reload_waits_for_world_resources_and_the_one_in_flight() { + assert!(!reload_waits(false, false)); + assert!(reload_waits(true, false)); + assert!(reload_waits(false, true)); + assert!(reload_waits(true, true)); + } +} diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index b41419718c..88808facea 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -255,6 +255,8 @@ impl Game<'_> { | "minecraft:arrow" | "minecraft:fireball" | "minecraft:small_fireball" + | "minecraft:wind_charge" + | "minecraft:breeze_wind_charge" | "minecraft:falling_block" | "minecraft:end_crystal" | "minecraft:ender_dragon" @@ -294,7 +296,10 @@ impl Game<'_> { self.add_projectile(p); } } - "minecraft:fireball" | "minecraft:small_fireball" => { + "minecraft:fireball" + | "minecraft:small_fireball" + | "minecraft:wind_charge" + | "minecraft:breeze_wind_charge" => { if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) { self.add_projectile(p); } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 6655fd899f..8134866555 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -123,15 +123,18 @@ impl CameraType { } } } +/// `Entity.isInvisible` for the window's player: shared flag 5 as its server +/// sets it (`shared_flag`), with the Invisibility effect or by a plugin's +/// `setInvisible` alone, as pose plugins hide the real body under the pose +/// (#3788); a world in the frame has only the effect. +fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag: bool) -> bool { + shared_flag || effects.get("invisibility").is_some() +} /// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's -/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`) -/// hides it unless its team sees friendly invisibles, where vanilla draws it +/// own body, seen by itself: an invisible player (`own_invisible`) hides it +/// unless its team sees friendly invisibles, where vanilla draws it /// translucent. -fn body_visible( - effects: &horizon_client::effects::PlayerEffects, - team: Option<&horizon_client::scoreboard::Team>, -) -> bool { - let invisible = effects.get("invisibility").is_some(); +fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool { !horizon_client::scoreboard::invisible_to(team, team, false, invisible) } /// The player's model type (`PlayerModel` with the default skin). @@ -213,6 +216,60 @@ impl Game<'_> { /// front of) it, pulled in where visual shapes are in the way. Returns the /// position, yaw and pitch to render from. pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> { + // Minecraft.cameraEntity: a server's spectator camera on a mob or + // another player. + let (entity, descending) = match self.camera_mob() { + Some(m) => (super::remote_camera::mob_camera_entity(m), false), + None => ( + self.player_camera_entity()?, + self.player.as_ref()?.shift_key_down, + ), + }; + let mut view = self.view; + let kind = match self.camera_type { + CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson, + CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack, + CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront, + }; + let context = CollisionContext::for_entity( + CollisionEntity { + identity: EntityIdentity(0), + y: entity.position[1], + descending, + main_hand: Some(CollisionItem::EMPTY), + }, + false, + ); + let mut rays = VisualRays { + scene: SceneCollisions { + world: &self.world, + binding: self.collision, + subject: None, + }, + facts: self.res.facts(), + context, + }; + view.align_with_entity(&entity, partial, kind, |from, to| { + let hit = clip_world( + &mut rays, + Vec3::new(from[0], from[1], from[2]), + Vec3::new(to[0], to[1], to[2]), + RayBlockMode::Visual, + RayFluidMode::None, + 4096, + ) + .ok()?; + (!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z]) + }); + self.view = view; + Some(( + glam::DVec3::from_array(view.position), + view.y_rot, + view.x_rot, + )) + } + /// The player as `Camera.alignWithEntity` sees it. + fn player_camera_entity(&mut self) -> Option { let (scale, distance) = { let p = self.player.as_mut()?; ( @@ -282,48 +339,7 @@ impl Game<'_> { }), mount, }; - let mut view = self.view; - let kind = match self.camera_type { - CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson, - CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack, - CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront, - }; - let context = CollisionContext::for_entity( - CollisionEntity { - identity: EntityIdentity(0), - y: p.position.y, - descending: p.shift_key_down, - main_hand: Some(CollisionItem::EMPTY), - }, - false, - ); - let mut rays = VisualRays { - scene: SceneCollisions { - world: &self.world, - binding: self.collision, - subject: None, - }, - facts: self.res.facts(), - context, - }; - view.align_with_entity(&entity, partial, kind, |from, to| { - let hit = clip_world( - &mut rays, - Vec3::new(from[0], from[1], from[2]), - Vec3::new(to[0], to[1], to[2]), - RayBlockMode::Visual, - RayFluidMode::None, - 4096, - ) - .ok()?; - (!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z]) - }); - self.view = view; - Some(( - glam::DVec3::from_array(view.position), - view.y_rot, - view.x_rot, - )) + Some(entity) } /// Keep the player's model in step with the player (`LivingEntity.tick` /// body turning, `WalkAnimationState`, the swing and the pose). @@ -523,9 +539,16 @@ impl Game<'_> { None => self.player_team(), } } + /// `Entity.isInvisible` for the player itself (`own_invisible`). + pub(super) fn own_invisible(&self) -> bool { + own_invisible( + &self.potions.effects, + self.remote.as_ref().is_some_and(|r| r.own_invisible), + ) + } /// Whether the player's own body is drawn (`body_visible`). pub(super) fn own_body_visible(&self) -> bool { - body_visible(&self.potions.effects, self.own_team()) + body_visible(self.own_invisible(), self.own_team()) } /// The player's model for the entity pass in third person. pub(super) fn player_model_geometry( @@ -567,7 +590,7 @@ impl Game<'_> { ); let chest = self.armor[1].as_ref().map(|s| &*s.item); // CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks). - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out); } self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out); @@ -658,7 +681,7 @@ impl Game<'_> { &mut verts, ); let chest = self.armor[1].as_ref().map(|s| &*s.item); - if self.potions.effects.get("invisibility").is_none() { + if !self.own_invisible() { self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); } self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts); @@ -1115,15 +1138,32 @@ mod preview_tests { .unwrap() .id; let mut effects = horizon_client::effects::PlayerEffects::default(); - assert!(body_visible(&effects, None)); + assert!(body_visible(own_invisible(&effects, false), None)); effects.add(EffectInstance::new(id, 3600, 0)); - assert!(!body_visible(&effects, None), "no team: the body is hidden"); + let invisible = own_invisible(&effects, false); + assert!( + !body_visible(invisible, None), + "no team: the body is hidden" + ); // Entity.isInvisibleTo: a team that sees friendly invisibles sees it. let mut board = Scoreboard::default(); let team = board.add_team("blue"); - assert!(body_visible(&effects, Some(team))); + assert!(body_visible(invisible, Some(team))); team.see_friendly_invisibles = false; - assert!(!body_visible(&effects, Some(team))); + assert!(!body_visible(invisible, Some(team))); + } + + /// #3788: a pose plugin's `Player.setInvisible(true)` sets only the + /// shared flag (no effect); `Entity.isInvisible` reads that flag, so the + /// real body under the pose is not drawn. + #[test] + fn the_servers_invisible_flag_hides_the_own_body_without_the_effect() { + let effects = horizon_client::effects::PlayerEffects::default(); + assert!(own_invisible(&effects, true)); + assert!(!body_visible(own_invisible(&effects, true), None)); + let mut board = horizon_client::scoreboard::Scoreboard::default(); + let team = board.add_team("blue"); + assert!(body_visible(own_invisible(&effects, true), Some(team))); } /// LivingEntityRenderer.submit: an invisible body is not drawn, its diff --git a/crates/horizon-client/src/game/raids.rs b/crates/horizon-client/src/game/raids.rs index d5f47cc1ff..528b1e0dba 100644 --- a/crates/horizon-client/src/game/raids.rs +++ b/crates/horizon-client/src/game/raids.rs @@ -226,10 +226,10 @@ impl Game<'_> { /// highest non-air one. pub(super) fn surface_height(&self, x: i32, z: i32) -> i32 { let top = if self.dim == Dim::Nether { 127 } else { 319 }; - (horizon_client::chunk::MIN_Y..=top) + (self.world.bottom_y()..=top) .rev() .find(|&y| !self.res.facts().is_air(self.world.block(x, y, z))) - .map_or(horizon_client::chunk::MIN_Y, |y| y + 1) + .map_or(self.world.bottom_y(), |y| y + 1) } /// Every claimed village point of interest (job sites, beds, bells). fn village_pois(&self) -> Vec<[i32; 3]> { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 9a38d268bd..b9fa1fc636 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -122,6 +122,9 @@ pub(super) struct Remote { pub(super) logged_in: bool, status: String, pub(super) entity_id: i32, + /// `Minecraft.cameraEntity` when the server put the view on another + /// entity (ClientboundSetCameraPacket); None while it is the player. + pub(super) camera: Option, /// The item the player's use began with (`LivingEntity.useItem`). pub(super) use_item: Option>, pub(super) dimension: DimensionType, @@ -166,6 +169,9 @@ pub(super) struct Remote { /// The happy ghast the player just got off, until the server's teleport /// puts the player on top of it (`place_ghast_under`). pub(super) ghast_dismount: Option, + /// Whether the player is the first passenger of its vehicle, the one + /// `getControllingPassenger` names. + pub(super) first_passenger: bool, /// Item entities waiting for their item data: where they are and their /// motion. pending_items: HashMap, @@ -183,6 +189,9 @@ pub(super) struct Remote { chat_previous: Option, /// The server's command tree (ClientboundCommandsPacket). pub commands: Option, + /// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat + /// completions this server sent (ClientboundCustomChatCompletionsPacket). + custom_completions: std::collections::BTreeSet, players: HashMap, /// `ClientPacketListener.playerInfoMap` as player-list entries, and /// which of them are listed. @@ -206,6 +215,9 @@ pub(super) struct Remote { /// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep /// theirs in `Mob::glowing`. glowing: std::collections::HashSet, + /// The player's own shared flag 5 (`Entity.isInvisible`) as the server + /// last set it. + pub(super) own_invisible: bool, /// The local player's profile UUID (always in the level). profile: u128, /// The local player's profile name: its score holder and team member. @@ -219,6 +231,9 @@ pub(super) struct Remote { /// inventory): like the click queue, they wait for the tick's /// `handleKeybinds` phase rather than going out between window events. pub(super) pending_keys: Vec, + /// Whether the main and off hand still wait for the server's answer to + /// a SWAP_ITEM_WITH_OFFHAND (`remote_hand_slot_set`). + swap_answer: [bool; 2], stop_mining: bool, placing: Option<([i32; 3], u64)>, /// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one. @@ -250,6 +265,9 @@ pub(super) struct Remote { /// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server /// Links..." in the pause menu lists (only `http`/`https` URIs kept). pub(super) server_links: Vec, + /// `ClientPacketListener.getDebugQueryHandler`: the F3+I query waiting + /// for the server's data. + pub(super) debug_query: super::debug_keys::DebugQuery, } /// The time a frame spends on chunk packets before it leaves the rest of @@ -483,6 +501,7 @@ impl Game<'_> { logged_in: false, status: "Connecting to the server...".into(), entity_id: 0, + camera: None, use_item: None, dimension: DimensionType::vanilla("minecraft:overworld"), chunk_format: Arc::new(ChunkFormat::new( @@ -510,6 +529,7 @@ impl Game<'_> { stepped: HashMap::new(), head_lerps: HashMap::new(), ghast_dismount: None, + first_passenger: false, pending_items: HashMap::new(), health: (20.0, 20, 5.0), flash_on_set_health: false, @@ -519,6 +539,7 @@ impl Game<'_> { chat_queue: VecDeque::new(), chat_previous: None, commands: None, + custom_completions: Default::default(), players: HashMap::new(), tab_players: HashMap::new(), skin_textures: HashMap::new(), @@ -529,6 +550,7 @@ impl Game<'_> { level_player_names: HashMap::new(), level_player_textures: HashMap::new(), glowing: Default::default(), + own_invisible: false, profile: 0, profile_name: String::new(), destroying: None, @@ -536,6 +558,7 @@ impl Game<'_> { destroy_delay: 0, input: Default::default(), pending_keys: Vec::new(), + swap_answer: [false; 2], stop_mining: false, placing: None, flat: false, @@ -552,6 +575,7 @@ impl Game<'_> { backlog: VecDeque::new(), backlog_since: None, server_links: Vec::new(), + debug_query: Default::default(), transfer: None, }); // The level's TickRateManager follows the server's ticking packets. @@ -909,6 +933,14 @@ impl Game<'_> { ) } + /// `ClientSuggestionProvider.customCompletionSuggestions` of the server + /// connection (none without one). + pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet { + self.remote + .as_ref() + .map(|r| r.custom_completions.clone()) + .unwrap_or_default() + } /// The names in the server's player list, sorted. pub(super) fn remote_online_names(&self) -> Option> { let r = self.remote.as_ref()?; @@ -1050,14 +1082,18 @@ impl Game<'_> { } Event::Reconfigured(registries) => { // handleConfigurationStart → clearClientLevel, and the next - // ClientPacketListener starts with no players or scoreboard. + // ClientPacketListener starts with no players or scoreboard, + // and its new ClientSuggestionProvider with no custom + // chat completions. self.hud_disconnected(); self.score.board = Default::default(); if let Some(r) = self.remote.as_mut() { r.players.clear(); r.tab_players.clear(); r.listed.clear(); + r.custom_completions.clear(); } + self.ui.registries.custom_completions.clear(); self.set_registries(registries); } // handleTransfer: "Cannot transfer to server from @@ -1206,6 +1242,7 @@ impl Game<'_> { return; }; let dimension = r.registries.dimension(spawn.dimension_type); + let shape = super::Dim::from_id(&dimension.name).shape(); let same = r.dimension.name == dimension.name && keep_world; r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension)); r.dimension = dimension; @@ -1243,6 +1280,7 @@ impl Game<'_> { let count = old.len(); horizon_client::chunk::free_columns(old, count); self.world = World::new(self.res.facts().air, spawn.seed); + self.world.shape = shape; self.world.game_time = time; self.world.day_time = day; self.world.weather = weather; @@ -1390,15 +1428,6 @@ impl Game<'_> { self.command_block_data_arrived(key); } } - // `handleBlockEntityData`: a command block's data arriving while its - // edit screen is open refreshes the screen. - if self - .command_block_screen - .as_ref() - .is_some_and(|s| s.pos == key) - { - self.update_command_block_screen(); - } } /// `handleAnimate`: a player wakes up, or an entity gets the critical /// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter. @@ -1621,6 +1650,7 @@ impl Game<'_> { // copy in the mob list draws nothing: given the move, it kept the // dragon where it spawned (#2083). let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id); + let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id); let mob = self .mobs .iter_mut() @@ -1644,6 +1674,13 @@ impl Game<'_> { if let Some(t) = r.entities.get_mut(&id) { t.position = position; } + if driven { + // handleMoveEntity, handleEntityPositionSync: the mount this + // client moves (isLocalInstanceAuthoritative) keeps its own + // position, and no steps pile up for after the ride. + r.stepped.remove(&id); + return; + } if let Some(m) = mob { // A living entity: LivingEntity.createInterpolationHandler's // SteppedInterpolationHandler, over its type's update interval. @@ -1936,7 +1973,14 @@ impl Game<'_> { if internal == 200 { return; // the result slot is recomputed from the grid } - *self.slot_mut(internal) = item; + let slot = self.slot_mut(internal); + let changed = !super::hand::matches_for_swap(slot.as_ref(), item.as_ref()); + *slot = item; + if internal < 36 { + self.remote_hand_slot_set(internal, changed); + } else if internal == 504 { + self.remote_hand_slot_set(super::shield::OFFHAND_SLOT, changed); + } if internal >= super::stations::INPUT && self.station.is_some() { self.remote_station_slot_changed(internal); } @@ -1962,9 +2006,27 @@ impl Game<'_> { ) }; let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale")); + let (speed, jump) = ( + value("minecraft:movement_speed"), + value("minecraft:jump_strength"), + ); let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else { return; }; + // A horse its rider's client moves goes at its own speed and jumps + // its own height (`getSpeed`, `getCustomJump`). + if horizon_client::mobs::equine::moved_by_rider(m.species()) { + if let Some(speed) = speed { + horizon_client::mobs::finalize::set_base( + m, + horizon_client::mobs::finalize::Attr::MovementSpeed, + speed, + ); + } + if let Some(jump) = jump { + m.extra.vector[0] = jump; + } + } if let Some(max_health) = max_health { horizon_client::mobs::finalize::set_base( m, @@ -1995,8 +2057,11 @@ impl Game<'_> { self.potions.effects.clear(); if let Some(r) = self.remote.as_mut() { r.carried = Some(0); + r.own_invisible = false; r.online_mode = p.online_mode; r.entity_id = p.player_id; + // handleLogin: the new LocalPlayer is the camera. + r.camera = None; r.logged_in = true; r.view_distance = p.chunk_radius; r.show_death_screen = p.show_death_screen; @@ -2022,6 +2087,11 @@ impl Game<'_> { // passengers. A dimension change keeps the vehicle it travels in. if p.keep & 2 == 0 { self.riding = None; + // KEEP_ENTITY_DATA copies the old player's synched data + // (its shared flags) to the new one; without it they reset. + if let Some(r) = self.remote.as_mut() { + r.own_invisible = false; + } } let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension); self.enter_remote_dimension(&p.spawn, true); @@ -2033,6 +2103,8 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.death_message = None; r.health = (20.0, 20, 5.0); + // handleRespawn: the new LocalPlayer is the camera. + r.camera = None; } self.capture(true); } @@ -2052,6 +2124,7 @@ impl Game<'_> { self.snap_player_model_pitch(); } C::PlayerLookAt(p) => self.remote_look_at(&p), + C::SetCamera(p) => self.remote_set_camera(p.camera_id), C::LevelChunkWithLight(p) => self.remote_chunk(&p, None), C::LightUpdate(p) => self.remote_light_update(&p), C::ForgetLevelChunk(p) => { @@ -2270,6 +2343,11 @@ impl Game<'_> { p.on_ground, false, ); + // handleTeleportEntity: the mount this client moves is put + // there at once. + if self.driven_mount().is_some_and(|m| m.id as i32 == p.id) { + self.remote_move_vehicle(position, p.yaw); + } } C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers), // ClientPacketListener.handleEntityLinkPacket: who holds a mob's @@ -2416,10 +2494,16 @@ impl Game<'_> { // server stops the sprint after a hit's extra // knockback (the flag only, as assignValues sets // it: the speed modifier and wasSprinting stay). + // Its invisible bit (Entity.isInvisible): the + // Invisibility effect's, or a plugin's that hides + // the real body under a pose (#3788). (0, Value::Byte(flags)) => { if let Some(pl) = self.player.as_mut() { pl.sprinting = flags & 0x08 != 0; } + if let Some(r) = self.remote.as_mut() { + r.own_invisible = flags & 0x20 != 0; + } } // Entity.DATA_AIR_SUPPLY_ID and // Player.DATA_PLAYER_ABSORPTION_ID: the bubbles @@ -2670,6 +2754,12 @@ impl Game<'_> { { std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand); } + // LivingEntity.handleEntityEvent 47 to 52, 65 and 68: an + // equipped item broke (onEquippedItemBroken). + if let Some(slot) = horizon_client::mobs::item_break::equipment_break_slot(p.event) + { + self.remote_equipment_break(p.id, slot); + } // EntityEvent.DEATH: start the death animation. if p.event == 3 && let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) @@ -2988,6 +3078,15 @@ impl Game<'_> { let stack = self.to_stack(&p.item); self.tutorial_get_item(stack.as_ref()); let before = self.creative_stacks(); + let held = match p.slot { + 0..=35 => self.inventory[p.slot as usize].as_ref(), + 40 => self.offhand.as_ref(), + _ => None, + }; + let changed = !super::hand::matches_for_swap(held, stack.as_ref()); + if let Ok(slot) = usize::try_from(p.slot) { + self.remote_hand_slot_set(slot, changed); + } match p.slot { 0..=35 => self.inventory[p.slot as usize] = stack, 36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack, @@ -3048,11 +3147,23 @@ impl Game<'_> { self.capture(true); } } + C::TagQuery(p) => self.answer_tag_query(p.transaction, p.tag), C::Unknown(id, data) if id == horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS => { self.handle_command_suggestions(&data); } + C::CustomChatCompletions(p) => { + // ClientPacketListener.handleCustomChatCompletions. + if let Some(r) = self.remote.as_mut() { + horizon_client::command::modify_custom_completions( + &mut r.custom_completions, + horizon_client::command::CustomCompletionAction::from_id(p.action), + &p.entries, + ); + self.ui.registries.custom_completions = r.custom_completions.clone(); + } + } C::Commands(p) => { let tree = horizon_client::command::network::from_packet( &p, @@ -4164,6 +4275,14 @@ impl Game<'_> { | u8::from(keys.jump) << 4 | u8::from(keys.shift) << 5 | u8::from(keys.sprint) << 6; + // LocalPlayer.applyInput and aiStep: watching through another + // entity, the player does not move itself; its keys still reach the + // server, whose sneak check lets go of the camera. + let keys = if self.controls_camera() { + keys + } else { + Keys::default() + }; // A passenger moves with its vehicle (Entity.rideTick), not by itself. let riding = self.screen != Screen::Dead && self.remote_ride(keys); // The server sends no passenger's position: they sit where their @@ -4551,6 +4670,9 @@ impl Game<'_> { } else { Vec::new() }; + // The mount this client moves ticks in `remote_ride` instead, as its + // authoritative side (LivingEntity.tick with travelRidden). + let driven = self.driven_mount().map(|m| m.id); let Some(r) = self.remote.as_mut() else { return; }; @@ -4562,7 +4684,7 @@ impl Game<'_> { let mut mob_fx = Vec::new(); for m in &mut self.mobs { let player = players.contains_key(&(m.id as i32)); - if !running && !player { + if (!running && !player) || driven == Some(m.id) { continue; } m.previous = m.position; @@ -4859,6 +4981,7 @@ impl Game<'_> { ); let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected); let riding = self.riding.is_some(); + let controls_camera = self.controls_camera(); let vehicle = self.remote_vehicle_packets(); let Some(r) = self.remote.as_mut() else { return; @@ -4891,9 +5014,12 @@ impl Game<'_> { flags: u8::from(on_ground) | u8::from(collision) << 1, })); out.extend(vehicle); - } else if let Some(packet) = - r.position_sender - .update(pos, (yaw, pitch), on_ground, collision) + } else if controls_camera + // LocalPlayer.sendPosition: no move while the view is another + // entity's (the server keeps the player on it). + && let Some(packet) = + r.position_sender + .update(pos, (yaw, pitch), on_ground, collision) { out.push(packet); } @@ -5043,6 +5169,9 @@ impl Game<'_> { sequence: seq, })); self.tutorial_destroy(state, 0.0); + // startDestroyBlock: BlockState.attack on the client, so a redstone + // ore's dust (RedStoneOreBlock.attack) comes before the break. + self.redstone_ore_particles(hit.pos); if progress >= 1.0 { // startDestroyBlock: instant survival breaks have no destroy delay. if !debug_stick { @@ -5296,6 +5425,24 @@ impl Game<'_> { (result, changes) }; let reacts = result.consumes_action(); + // useItemOn on this client, unless a sneak with something in hand + // skips it: RedStoneOreBlock's dust, and the wick smoke of a lit + // candle an empty hand snuffs (CandleBlock.useItemOn). + if hand == Hand::Main && !spectator { + let sneaking = self.keys.contains(&KeyCode::ShiftLeft); + let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some(); + if !(sneaking && hands_full) { + self.redstone_ore_particles(hit.pos); + let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]); + let lit_candle = self.block_name(hit.pos).ends_with("candle") + && self.pack.assets.registry.state(state).is_some_and(|s| { + s.properties.get("lit").map(String::as_str) == Some("true") + }); + if held.is_none() && self.may_build() && lit_candle { + self.candle_smoke(hit.pos, state); + } + } + } // ArmorStandItem.useOn runs in startPrediction before the packet // goes out (the server may spawn the stand at once). let stand_fits = held @@ -5334,6 +5481,10 @@ impl Game<'_> { } if reacts { self.predict_use(&changes, seq); + // LeverBlock.useWithoutItem on the client: the predicted pull puffs. + if &*self.block_name(hit.pos) == "minecraft:lever" { + self.lever_puff(hit.pos); + } self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2])); // MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT // (a chest, barrel, furnace, …). CONSUME does not swing. @@ -6108,6 +6259,11 @@ impl Game<'_> { return false; } if self.spectator() { + // Minecraft.startAttack: a spectator's click on an entity asks + // to watch through it (MultiPlayerGameMode.spectate); a click on + // nothing still tells the server (spectatorNoAction). + let target = self.spectator_click_target(); + self.send(S::SpectatorAction(play::SpectatorAction { target })); return true; } // A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server @@ -6258,6 +6414,7 @@ impl Game<'_> { self.recipe_ui.reset_screen(); if self.creative { self.screen = Screen::Creative; + self.refresh_creative_tabs(); self.creative_screen.reopen(); self.refresh_saved_hotbars(); } else { @@ -6324,6 +6481,35 @@ impl Game<'_> { direction: 0, sequence: 0, })); + // Equal stacks swap with no slot update to wait for. + let (main, off) = (self.stack_in(self.selected), self.offhand.as_ref()); + let moves = !super::hand::matches_for_swap(main, off); + if let Some(r) = self.remote.as_mut() { + r.swap_answer = [moves; 2]; + } + } + /// The server set inventory `slot` (`shield::OFFHAND_SLOT` for the off + /// hand), `changed` from the stack this client held there. The stack + /// that answers a swap (`setItemInHand`) is another object, which the + /// hand shows after the swap animation (`hand_stack_replaced`); other + /// updates of a hand's slot count as the held stack changing in place, + /// as this client doesn't predict every use the way vanilla's does + /// (a thrown snowball's stack shrinks only when the server says so). + fn remote_hand_slot_set(&mut self, slot: usize, changed: bool) { + let hand = if slot == super::shield::OFFHAND_SLOT { + 1 + } else if slot == self.selected { + 0 + } else { + return; + }; + let answer = self + .remote + .as_mut() + .is_some_and(|r| std::mem::take(&mut r.swap_answer[hand])); + if answer && changed { + self.hand_stack_replaced(slot); + } } /// A container click: the server answers with the authoritative slots. pub(super) fn remote_click(&mut self, right: bool) { diff --git a/crates/horizon-client/src/game/remote_camera.rs b/crates/horizon-client/src/game/remote_camera.rs new file mode 100644 index 0000000000..01311652f0 --- /dev/null +++ b/crates/horizon-client/src/game/remote_camera.rs @@ -0,0 +1,108 @@ +//! A server's spectator camera (`ClientPacketListener.handleSetCamera`, +//! `Minecraft.cameraEntity`): `/spectate` or a spectator's click (sent as +//! `MultiPlayerGameMode.spectate`) attaches the view to another entity. The +//! view follows that entity while the player stands still and sends no moves +//! (`LocalPlayer.isControlledCamera`); the server keeps its player on the +//! camera and lets go when the player sneaks. +use super::Game; +use horizon_client::{ + camera::{CameraEntity, LivingCamera}, + mobs::Mob, +}; + +/// `Minecraft.setCameraEntity` from a ClientboundSetCameraPacket: the player's +/// own id puts the view back on the player; an id the level does not hold +/// changes nothing (`ClientboundSetCameraPacket.getEntity` is null). +pub(super) fn camera_after_packet( + current: Option, + player_id: i32, + camera_id: i32, + in_level: bool, +) -> Option { + if camera_id == player_id { + None + } else if in_level { + Some(camera_id) + } else { + current + } +} + +/// `Camera.alignWithEntity`'s view of a mob or another player: its +/// interpolated position, `getViewYRot`/`getViewXRot` and eye height, with +/// its scale and camera distance for a detached view. A living entity's +/// `getViewYRot` is its head's yaw (`yHeadRot`), not its body's `yRot`. +pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity { + let attribute = + |name: &str, default: f64| m.kind.attributes.get(name).copied().unwrap_or(default); + CameraEntity { + position: [m.position.x, m.position.y, m.position.z], + previous: [m.previous.x, m.previous.y, m.previous.z], + x_rot: m.head_pitch, + y_rot: m.head_yaw, + x_rot_o: m.previous_head_pitch, + y_rot_o: m.previous_head_yaw, + eye_height: m.eye_height() as f32, + living: Some(LivingCamera { + scale: attribute("minecraft:scale", 1.0) as f32, + camera_distance: attribute("minecraft:camera_distance", 4.0), + sleeping: None, + dead: None, + }), + mount: None, + } +} + +impl Game<'_> { + /// `ClientPacketListener.handleSetCamera`. + pub(super) fn remote_set_camera(&mut self, camera_id: i32) { + let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32); + if let Some(r) = self.remote.as_mut() { + r.camera = camera_after_packet(r.camera, r.entity_id, camera_id, in_level); + } + } + /// The entity a server attached the view to, when it is not the player. + pub(super) fn remote_camera_id(&self) -> Option { + self.remote.as_ref()?.camera.map(|id| id as u32) + } + /// `LocalPlayer.isControlledCamera`: the view is the player's own. + pub(super) fn controls_camera(&self) -> bool { + self.remote_camera_id().is_none() + } + /// The mob the view follows, while the level still holds it. + pub(super) fn camera_mob(&self) -> Option<&Mob> { + let id = self.remote_camera_id()?; + self.mobs.iter().find(|m| m.id == id) + } + /// The entity under a spectator's crosshair (`Minecraft.hitResult` as an + /// `EntityHitResult`): the nearest mob or object before any block. + pub(super) fn spectator_click_target(&self) -> Option { + let mob = self.mob_hit().map(|(i, d)| (self.mobs[i].id as i32, d)); + let object = self.remote_object_hit(false).map(|h| (h.id, h.distance)); + [mob, object] + .into_iter() + .flatten() + .filter(|(_, d)| self.before_block(*d)) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(id, _)| id) + } + /// `LevelRenderer`: the camera entity is not drawn from its own eyes. + pub(super) fn hides_as_camera(&self, id: u32) -> bool { + self.camera_type.first_person() && self.remote_camera_id() == Some(id) + } +} + +#[cfg(test)] +mod tests { + use super::camera_after_packet; + + /// The camera goes to a known entity, back to the player on its own id, + /// and stays where it is for an entity the level does not hold. + #[test] + fn set_camera_follows_vanilla() { + assert_eq!(camera_after_packet(None, 5, 9, true), Some(9)); + assert_eq!(camera_after_packet(Some(9), 5, 5, false), None); + assert_eq!(camera_after_packet(Some(9), 5, 12, false), Some(9)); + assert_eq!(camera_after_packet(None, 5, 12, false), None); + } +} diff --git a/crates/horizon-client/src/game/remote_mob_data.rs b/crates/horizon-client/src/game/remote_mob_data.rs index 891ec7857e..2628877f7f 100644 --- a/crates/horizon-client/src/game/remote_mob_data.rs +++ b/crates/horizon-client/src/game/remote_mob_data.rs @@ -50,6 +50,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.invisible = invisible; } } + // Entity.DATA_SILENT: Entity.isSilent, which keeps the sounds + // a client plays itself quiet (LivingEntity.breakItem's). + (4, Value::Bool(silent)) => m.silent = *silent, // Entity.DATA_POSE: the pose the species animates by. (6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8), // LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the @@ -148,7 +151,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti m.extra.color = (*c & 15) as u8; } // AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions). - (Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => { + (Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => { m.extra.size = (*size).clamp(1, 127); } // Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth @@ -462,6 +465,10 @@ pub(super) fn client_defaults(m: &mut Mob) { // Bat's constructor sets resting only on the server; the data default is 0. bat::set_resting(m, false); } + // A horse's jump strength until the server syncs its own: the type's. + if equine::moved_by_rider(m.species()) { + m.extra.vector[0] = m.kind.jump_strength; + } } #[cfg(test)] @@ -494,6 +501,23 @@ mod tests { Some(m) } + /// #2920: a server's horse is moved by its rider's client, which jumps + /// it by the type's jump strength (`createBaseHorseAttributes`, + /// `createBaseChestedHorseAttributes`) until the server syncs its own. + #[test] + fn a_servers_horse_jumps_its_types_strength() { + for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] { + let Some(m) = mob(&format!("minecraft:{kind}")) else { + return; + }; + assert!( + (m.extra.vector[0] - strength).abs() < 1e-6, + "{kind}: {}", + m.extra.vector[0] + ); + } + } + #[test] fn guardian_charge_follows_target_updates_and_caps_at_its_duration() { use horizon_client::mobs::guardian::{beam, client_tick}; @@ -568,6 +592,21 @@ mod tests { assert!(!calf.is_baby()); } + #[test] + fn cube_mobs_receive_size_from_metadata() { + for kind in ["slime", "magma_cube", "sulfur_cube"] { + let Some(mut m) = mob(&format!("minecraft:{kind}")) else { + return; + }; + assert_eq!(m.extra.size, 1); + apply(&mut m, &[(18, s::INT, Value::Int(2))]); + assert_eq!(m.extra.size, 2); + if kind == "sulfur_cube" { + assert!(!m.is_baby()); + } + } + } + #[test] fn bat_flags_select_continuous_client_clocks_without_changing_motion() { use horizon_client::mobs::{MobTypes, anim, keyframes::Animation}; diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 12c2358ae1..f381bb734a 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -121,7 +121,7 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) { if p.stuck { return; } - let water = w.in_water(p.position); + let water = w.in_water(p.bounds()); if water { p.velocity = p.velocity.scale(0.6); } @@ -714,7 +714,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { diff --git a/crates/horizon-client/src/game/remote_vehicles.rs b/crates/horizon-client/src/game/remote_vehicles.rs index 79e7b078f8..2d04c6d4ce 100644 --- a/crates/horizon-client/src/game/remote_vehicles.rs +++ b/crates/horizon-client/src/game/remote_vehicles.rs @@ -7,9 +7,12 @@ //! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is //! controlled by its rider's client: it ticks here and its moves go to the //! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`, -//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on -//! the server with the rider's input, and the rider sits in it. +//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse, +//! donkey, mule or camel: its rider's client moves it and reports the moves, +//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart +//! moves on the server with the rider's input, and the rider sits in it. use super::{Game, Keys, ray_box}; +use horizon_client::mobs::{Mob, equine}; use horizon_core::geometry::{Aabb, Vec3}; use horizon_protocol::packets::play::{self, Serverbound as S}; @@ -123,6 +126,11 @@ impl Game<'_> { display.vehicle = None; } } + if passengers.contains(&me) + && let Some(r) = self.remote.as_mut() + { + r.first_passenger = passengers.first() == Some(&me); + } if passengers.contains(&me) { if self.riding != Some(vehicle_id) { self.riding = Some(vehicle_id); @@ -134,6 +142,9 @@ impl Game<'_> { } else if self.riding == Some(vehicle_id) { // The server takes the player out, then teleports it. self.riding = None; + if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) { + m.rider = None; + } // HappyGhast.removePassenger: a still timeout of 10 (the server's // STAYS_STILL confirms it), and the rider lands on top of the ghast // (`place_ghast_under`). @@ -155,10 +166,13 @@ impl Game<'_> { return false; }; if let Some(i) = self.mobs.iter().position(|m| m.id == id) { - // An animal moves on the server with the rider's input - // (PlayerInput); here the jump bar charges for the HUD and the - // rider sits where `positionRider` puts it. - self.charge_mount_jump(i, keys); + // LocalPlayer.aiStep: the jump bar charges while jump is held. + let jump = self.charge_mount_jump(i, keys); + if self.drives(&self.mobs[i]) { + self.drive_mount(i, keys, jump); + } + // Another animal moves on the server with the rider's input + // (PlayerInput); the rider sits where `positionRider` puts it. self.position_rider(); return true; } @@ -200,6 +214,9 @@ impl Game<'_> { /// `LocalPlayer.tick` for a passenger: the boat it controls goes to the /// server every tick with its paddles (the rider's own position does not). pub(super) fn remote_vehicle_packets(&self) -> Vec { + if let Some(m) = self.driven_mount() { + return vec![mount_move(m)]; + } let Some(b) = self .riding .and_then(|id| self.boats.iter().find(|b| b.id == id)) @@ -220,20 +237,168 @@ impl Game<'_> { ] } - /// `ClientPacketListener.handleMoveVehicle`: the server put the boat the - /// player drives back. + /// `ClientPacketListener.handleMoveVehicle`: the server put the boat or + /// the mount the player drives back. pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) { + let at = Vec3::new(position[0], position[1], position[2]); + if let Some(id) = self.driven_mount().map(|m| m.id) + && let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) + { + m.previous = at; + m.position = at; + m.yaw = yaw; + m.previous_yaw = yaw; + m.velocity = Vec3::ZERO; + return; + } let Some(b) = self .riding .and_then(|id| self.boats.iter_mut().find(|b| b.id == id)) else { return; }; - let at = Vec3::new(position[0], position[1], position[2]); b.previous = at; b.position = at; b.yaw = yaw; b.previous_yaw = yaw; b.velocity = Vec3::ZERO; } + + /// Whether this client moves `m`, the mount the player rides: as its + /// first passenger (`AbstractHorse.getControllingPassenger`), on its + /// saddle (`isLocalInstanceAuthoritative`). + fn drives(&self, m: &Mob) -> bool { + self.riding == Some(m.id) + && client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger)) + } + + /// The mount this client moves, if the player rides one. + pub(super) fn driven_mount(&self) -> Option<&Mob> { + let id = self.riding?; + self.mobs.iter().find(|m| m.id == id && self.drives(m)) + } + + /// `LocalPlayer.aiStep` and `LivingEntity.travelRidden` on the + /// controlling client: a released jump goes to the server + /// (`sendRidingJump`), and the mount takes the rider's input and moves + /// here (`tickRidden`, then `travel`). + fn drive_mount(&mut self, i: usize, keys: Keys, jump: f32) { + if jump > 0.0 + && let Some(id) = self.remote.as_ref().map(|r| r.entity_id) + { + self.send(riding_jump(id, self.mount.jump_scale)); + } + let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right)); + let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward)); + let m = &mut self.mobs[i]; + // playerJumpPendingScale waits for the ground. + let pending = m.rider.map_or(0.0, |r| r.jump); + m.rider = Some(equine::Rider { + strafe, + forward, + yaw: self.yaw, + pitch: self.pitch, + jump: if jump > 0.0 { jump } else { pending }, + steer: false, + sprint: keys.sprint, + jumping: keys.jump, + client: false, + }); + let mut mob = self.mobs.remove(i); + let mut random = self.world.random.clone(); + let mut events = Vec::new(); + { + let world = self.mob_world_base(&[]); + mob.tick(&world, &mut random, &mut events); + } + // Only the server's packets take the mount away. + mob.removed = false; + self.world.random = random; + self.mobs.insert(i, mob); + } +} + +/// Whether a player's client moves `m`, the mount it rides +/// (`isLocalInstanceAuthoritative`): a saddled horse, donkey, mule or camel +/// it is the first passenger of (`AbstractHorse.getControllingPassenger`). +fn client_moves(m: &Mob, first_passenger: bool) -> bool { + first_passenger && equine::moved_by_rider(m.species()) && m.extra.saddled && m.alive() +} + +/// `LocalPlayer.sendRidingJump`: START_RIDING_JUMP with +/// `floor(getJumpRidingScale() * 100)`, as `onPlayerJump` gets it. +fn riding_jump(player: i32, scale: f32) -> S { + S::PlayerCommand(play::PlayerCommand { + id: player, + action: 3, + data: (scale * 100.0).floor() as i32, + }) +} + +/// `ServerboundMoveVehiclePacket.fromEntity`: where the mount this client +/// moves is, and how it is turned. +fn mount_move(m: &Mob) -> S { + S::MoveVehicle(play::MoveVehicleRequest { + position: [m.position.x, m.position.y, m.position.z], + yaw: m.yaw, + pitch: m.head_pitch, + on_ground: m.on_ground, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use horizon_client::mobs::MobTypes; + + /// A mob of the prepared resources' type `kind` (None without them). + fn mob(kind: &str) -> Option { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + let types = MobTypes::load(&root).ok()?; + let t = std::sync::Arc::new(types.types.get(kind)?.clone()); + Some(Mob::new(7, t, Vec3::new(1.5, 64.0, -2.5), 0.0)) + } + + /// #2920: on a vanilla server the rider's client moves a saddled horse, + /// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second + /// passenger's ride is the server's. + #[test] + fn the_rider_moves_a_saddled_horse_on_its_client() { + for kind in ["horse", "donkey", "mule", "camel", "pig", "llama"] { + let Some(mut m) = mob(&format!("minecraft:{kind}")) else { + return; + }; + assert!(!client_moves(&m, true), "{kind} without a saddle"); + m.set_saddle(Some(horizon_client::items::ItemStack::new( + "minecraft:saddle", + 1, + ))); + let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel"); + assert_eq!(client_moves(&m, true), horse, "{kind}"); + assert!(!client_moves(&m, false), "{kind} behind another rider"); + } + } + + /// #2920: the client reports where it moved the mount, and a released + /// jump with the bar's percentage (`sendRidingJump`). + #[test] + fn the_rider_sends_its_mounts_moves_and_jumps() { + let Some(mut m) = mob("minecraft:horse") else { + return; + }; + (m.yaw, m.head_pitch, m.on_ground) = (90.0, 10.0, true); + match mount_move(&m) { + S::MoveVehicle(p) => { + assert_eq!(p.position, [1.5, 64.0, -2.5]); + assert_eq!((p.yaw, p.pitch, p.on_ground), (90.0, 10.0, true)); + } + other => panic!("{other:?}"), + } + match riding_jump(42, 0.75) { + S::PlayerCommand(p) => assert_eq!((p.id, p.action, p.data), (42, 3, 75)), + other => panic!("{other:?}"), + } + } } diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 2ffdd1e60f..ae835bef16 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -7,7 +7,6 @@ use horizon_client::{ animate_tick::{AnimateContext, animate_tick, campfire_particles, marker_particle_target}, biome::Biomes, blocks::{BlockFacts, Fluid}, - chunk::{HEIGHT, MIN_Y}, containers::BlockEntity, particles::{ParticleLevel, ParticleOptions, ParticleType}, world::World, @@ -37,7 +36,7 @@ pub(super) fn motion_blocking_height( /// that is above the world's bottom and within 10 blocks of the camera. fn splash_height(world: &World, properties: &Properties, x: i32, y: i32, z: i32) -> Option { let top = motion_blocking_height(world, properties, x, z); - (top > MIN_Y && (y - 10..=y + 10).contains(&top)).then_some(top) + (top > world.bottom_y() && (y - 10..=y + 10).contains(&top)).then_some(top) } /// The first block at or below `from` that blocks motion (leaves only when /// `leaves`, as MOTION_BLOCKING_NO_LEAVES skips them) or holds a fluid, plus one. @@ -49,8 +48,8 @@ fn motion_blocking_leaves( from: i32, leaves: bool, ) -> i32 { - let mut y = from.min(MIN_Y + HEIGHT - 1); - while y > MIN_Y { + let mut y = from.min(world.top_y() - 1); + while y > world.bottom_y() { let f = facts.get(world.block(x, y, z)); // #minecraft:leaves block motion, but MOTION_BLOCKING_NO_LEAVES skips them // (their water still counts). @@ -64,7 +63,7 @@ fn motion_blocking_leaves( } y -= 1; } - MIN_Y + world.bottom_y() } /// `Biome.getPrecipitationAt` is rain (not snow) at this position. fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool { @@ -363,6 +362,9 @@ impl Game<'_> { // The day timeline: firefly bush sounds from 12600 to 23400. let firefly_bush_sounds = self.dim == Dim::Overworld && (12600..23401).contains(&self.world.day_time.rem_euclid(24000)); + // EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night. + let creaking_active = self.dim == Dim::Overworld + && (12600..23401).contains(&self.world.day_time.rem_euclid(24000)); let tables = &self.pack.context.tables; let model_offset = |state, x, z| tables.offset(state, x, z); let gateway_particles = |state, p| { @@ -382,6 +384,7 @@ impl Game<'_> { raining_at: &raining_at, height_no_leaves: &height_no_leaves, firefly_bush_sounds, + creaking_active, sky_darken: self.sky_darken(), marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)), model_offset: &model_offset, diff --git a/crates/horizon-client/src/game/saves_control.rs b/crates/horizon-client/src/game/saves_control.rs index f00edb7edc..869bfd709c 100644 --- a/crates/horizon-client/src/game/saves_control.rs +++ b/crates/horizon-client/src/game/saves_control.rs @@ -54,6 +54,7 @@ impl Game<'_> { "last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64), "commands": w.commands, "version": w.version, + "corrupted": w.corrupted, }) }) .collect(); diff --git a/crates/horizon-client/src/game/sculk.rs b/crates/horizon-client/src/game/sculk.rs index 98be4f5fba..03e5306311 100644 --- a/crates/horizon-client/src/game/sculk.rs +++ b/crates/horizon-client/src/game/sculk.rs @@ -411,13 +411,12 @@ impl Game<'_> { } } fn sculk_scan_chunk(&mut self, key: (i32, i32)) { - use horizon_client::chunk::MIN_Y; let Some(chunk) = self.world.chunks.get(&key).cloned() else { return; }; let mut found = Vec::new(); for (s, section) in chunk.sections.iter().enumerate() { - let y0 = MIN_Y + s as i32 * 16; + let y0 = self.world.bottom_y() + s as i32 * 16; // Without a sculk state in its palette, no block of the section // is one: most sections are passed over without reading a block. if !section.blocks.maybe_has(|v| self.kind_of_state(v) != 0) { @@ -1555,7 +1554,7 @@ impl Game<'_> { .map(|i| [i.position.x, i.position.y, i.position.z]) .collect(); for at in items { - self.step_on_redstone_ore(at, false); + self.step_on_redstone_ore(at, false, false); } } @@ -1573,7 +1572,7 @@ impl Game<'_> { if let Some((at, on_ground, health, flying)) = player { let crouch = self.keys.contains(&KeyCode::ShiftLeft); if on_ground && !self.spectator() { - self.step_on_redstone_ore(at, crouch); + self.step_on_redstone_ore(at, crouch, true); } self.mount_events(Who::Player, self.riding, true); let in_water = self.in_water_at(at); @@ -1594,9 +1593,57 @@ impl Game<'_> { } } + /// `RedStoneOreBlock.spawnParticles`: a red dust puff for each face of the + /// ore that is not against a solid block. Non-axis coordinates draw from + /// the client level random, x then y then z. It is `level.addParticle`, so + /// only the local player's own click or step draws it, not a guest's turn. + pub(super) fn redstone_ore_particles(&mut self, pos: [i32; 3]) { + if self.lan_acting.is_some() + || !matches!( + &*self.block_name(pos), + "minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore" + ) + { + return; + } + // Direction.values(): down, up, north, south, west, east. + const FACES: [[i32; 3]; 6] = [ + [0, -1, 0], + [0, 1, 0], + [0, 0, -1], + [0, 0, 1], + [-1, 0, 0], + [1, 0, 0], + ]; + for step in FACES { + let next = [pos[0] + step[0], pos[1] + step[1], pos[2] + step[2]]; + let state = self.world.block(next[0], next[1], next[2]); + if self.res.facts().get(state).solid_render { + continue; + } + let mut at = [0.0f64; 3]; + for axis in 0..3 { + at[axis] = if step[axis] != 0 { + 0.5 + 0.5625 * f64::from(step[axis]) + } else { + f64::from(self.particles.level_random().next_float()) + }; + } + self.particles.add( + &horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0), + [ + f64::from(pos[0]) + at[0], + f64::from(pos[1]) + at[1], + f64::from(pos[2]) + at[2], + ], + [0.0; 3], + ); + } + } + /// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the /// entity is stepping carefully (`attack_block` only lights an unlit ore). - fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) { + fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool, burst: bool) { if carefully { return; } @@ -1610,6 +1657,10 @@ impl Game<'_> { &*self.block_name(pos), "minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore" ) { + // The dust is the local player's client effect, not a mob's or an item's. + if burst { + self.redstone_ore_particles(pos); + } let _ = self.attack_block(pos); } } @@ -1628,6 +1679,7 @@ impl Game<'_> { ) { return Ok(false); } + self.redstone_ore_particles(pos); self.attack_block(pos)?; let block_item = held.is_some_and(|s| { self.res @@ -1667,7 +1719,7 @@ impl Game<'_> { .collect(); for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot { if on_ground { - self.step_on_redstone_ore(at, false); + self.step_on_redstone_ore(at, false, false); } // A mob first seen already riding raised its event when it spawned. let seen = self.sculk.steps.contains_key(&Who::Mob(id)); diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index 6fcc7c3dbc..03bbd7565f 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -3,7 +3,7 @@ //! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the //! server list's setting, downloads into `downloads/`, and a resource reload //! with the server's packs on top of the player's own. -use super::{Game, Progress}; +use super::{Game, packs::ReloadKind}; use horizon_client::{ Result, confirm_screen::Confirm, @@ -89,9 +89,6 @@ pub(super) struct ServerPackState { pub(super) active: Vec, /// The download batches' notifiers, oldest first. notifiers: Vec, - /// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the - /// frame draws its progress until `tick_server_packs` applies it. - pub(super) reload: Option, } /// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its /// own `SystemToast`. @@ -204,21 +201,6 @@ pub(super) fn saves() -> PathBuf { /// `WorldOpenFlows.WORLD_PACK_ID`: the id of a world's own resource pack. const WORLD_PACK_ID: u128 = 0x640a6a92_b6cb_48a0_b391_831586500359; -/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`. -const RELOAD_LINES: [&str; 4] = [ - "Preparing resources", - "Loading assets", - "Loading pack tables", - "Applying resources", -]; -/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a -/// worker while `progress` feeds the loading screen, then the apply lands -/// on the render thread. -pub(super) struct PackReload { - pub(super) progress: Progress, - handle: std::thread::JoinHandle>, -} - impl Game<'_> { /// `WorldOpenFlows.loadBundledResourcePack`: a world's own /// `resourcepacks/resources.zip` is pushed as a local pack before the world @@ -358,6 +340,13 @@ impl Game<'_> { return; } self.drive_pack_reload(); + // One reload at a time: the player's own waits under its overlay + // before the server's packs can ask for theirs (`ServerPacks` keeps + // its own reload from overlapping). + let selection_reloading = self + .pack_reload + .as_ref() + .is_some_and(|r| r.kind() == ReloadKind::Selection); let finished = self .server_packs .queue @@ -367,7 +356,7 @@ impl Game<'_> { for batch in finished { self.server_packs.packs.downloaded(batch); } - while self.server_packs.packs.wants_tick() { + while !selection_reloading && self.server_packs.packs.wants_tick() { let tick = self.server_packs.packs.tick(); if !tick.download.is_empty() { self.start_pack_downloads(tick.download); @@ -431,75 +420,14 @@ impl Game<'_> { self.finish_pack_reload(false); return; }; - self.server_packs.active = server.clone(); - // The overlay swallows input until the apply (`LoadingOverlay`): - // release what is held so nothing keeps acting while it covers the - // frame (`KeyMapping.releaseAll` when a screen opens). - self.keys.clear(); - self.physical_keys.clear(); - self.left = false; - self.right = false; - let vanilla = self.vanilla_root(); - if self.ui.vanilla_root.as_os_str().is_empty() { - self.ui.vanilla_root = vanilla.clone(); + self.server_packs.active = server; + if let Err(e) = self.spawn_pack_reload(ReloadKind::Server) { + log::error!("Horizon: could not start the pack reload worker: {e}"); + self.finish_pack_reload(false); } - let (selected, mods, dev_mods) = ( - self.settings.resource_packs.clone(), - self.settings.mods.enabled.clone(), - self.options.dev_mods.clone(), - ); - let (assets, res, context) = ( - self.pack.assets.clone(), - self.res.clone(), - self.pack.context.clone(), - ); - let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs); - let progress = Progress::with(&RELOAD_LINES); - let worker = progress.clone(); - let handle = std::thread::Builder::new() - .name("server-pack-reload".into()) - .spawn(move || { - super::packs::stage_pack( - &vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, - unicode, japanese, &worker, - ) - .map_err(|e| e.to_string()) - }); - match handle { - Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }), - Err(e) => { - log::error!("Horizon: could not start the pack reload worker: {e}"); - self.finish_pack_reload(false); - } - } - } - /// `PackReloadConfig.Callbacks`: applies the staged reload once its - /// worker is done; on failure the packs active before reload again. - fn drive_pack_reload(&mut self) { - let Some(reload) = &self.server_packs.reload else { - return; - }; - if !reload.handle.is_finished() { - return; - } - let reload = self.server_packs.reload.take().unwrap(); - let staged = reload - .handle - .join() - .unwrap_or_else(|_| Err("the pack reload worker panicked".into())); - let ok = match staged - .and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string())) - { - Ok(()) => true, - Err(e) => { - log::warn!("Horizon: server resource pack reload failed: {e}"); - false - } - }; - self.finish_pack_reload(ok); } /// `ServerPacks::reloaded` and the recovery reload it can ask for. - fn finish_pack_reload(&mut self, ok: bool) { + pub(super) fn finish_pack_reload(&mut self, ok: bool) { if let Some(recovery) = self.server_packs.packs.reloaded(ok) { self.start_pack_reload(&recovery); } diff --git a/crates/horizon-client/src/game/server_travel.rs b/crates/horizon-client/src/game/server_travel.rs index 639ed0f843..51e9c2df8d 100644 --- a/crates/horizon-client/src/game/server_travel.rs +++ b/crates/horizon-client/src/game/server_travel.rs @@ -725,6 +725,7 @@ impl Game<'_> { tick_steps: self.clock.step_state(), collision: Some(self.collision), border: &self.border, + streaming: Some(&self.streaming), } } } diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 3fbd3ec137..955efec256 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -434,8 +434,9 @@ impl Loading { } // Mods' settings pages open only in a running game. Event::NativeSettings(_) | Event::NativeButton(..) => {} - // The startup menu runs before the game opens a control socket. - Event::ButtonClick | Event::CopyAgentDetails => {} + // The startup menu runs before the game opens a control socket, + // and before its sound engine (no clicks, no volume previews). + Event::ButtonClick | Event::CopyAgentDetails | Event::Volume(..) => {} } } } diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index a7370669e0..192e867cf5 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -411,11 +411,10 @@ impl FluidInteractionWorld for TntFluidWorld<'_> { Ok(self.world.chunks.contains_key(&(x, z)).then_some((x, z))) } fn chunk_sections(&mut self, _: Self::Chunk) -> std::io::Result> { - static SECTIONS: [bool; horizon_client::chunk::SECTIONS] = - [true; horizon_client::chunk::SECTIONS]; + let layout = self.world.layout(); Ok(FluidSections { - minimum_section_y: horizon_client::chunk::MIN_Y / 16, - has_fluid: &SECTIONS, + minimum_section_y: layout.min_y() / 16, + has_fluid: horizon_client::chunk::every_section(layout), }) } fn bounding_box_min_y(&self) -> f64 { diff --git a/crates/horizon-client/src/game/trial_visuals.rs b/crates/horizon-client/src/game/trial_visuals.rs index cac28b5cc3..e3a69bd9dd 100644 --- a/crates/horizon-client/src/game/trial_visuals.rs +++ b/crates/horizon-client/src/game/trial_visuals.rs @@ -279,19 +279,22 @@ impl Game<'_> { }; let me = self.level.player_uuid.unwrap_or_default(); let range = vault.connected_particles_range; - let (Some(p), true) = (self.player.as_ref(), vault.connected_players.contains(&me)) else { - return; - }; - let block = [p.position.x, p.position.y, p.position.z].map(|v| v.floor() as i32); - let d = [0, 1, 2].map(|i| f64::from(block[i] - pos[i])); - if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > range * range { + // VaultSharedData.getConnectedPlayers, each through getPlayerByUUID: the + // host and the guests, with their position and height. + let mut players = Vec::new(); + for uuid in &vault.connected_players { + if *uuid == me { + if let Some(p) = self.player.as_ref() { + let at = [p.position.x, p.position.y, p.position.z]; + players.push((at, p.dimensions.height)); + } + } else if let Some(g) = self.lan_present().into_iter().find(|g| g.uuid == *uuid) { + players.push((g.position, g.height)); + } + } + if players.is_empty() { return; } - let target = [ - p.position.x, - p.position.y + f64::from(p.dimensions.height / 2.0), - p.position.z, - ]; let facing = self .pack .assets @@ -310,12 +313,21 @@ impl Game<'_> { f64::from(pos[1]) + 1.75, f64::from(pos[2]) + 0.5 + step[1] * 0.5, ]; - let direction: [f64; 3] = std::array::from_fn(|i| target[i] - keyhole[i]); - let random = self.particles.level_random(); - let count = random.next_int_bound(4).unwrap_or(0) + 2; - for _ in 0..count { - let v = offset_random(direction, self.particles.level_random(), 1.0); - self.particle(T::VaultConnection, keyhole, v); + for (at, height) in players { + // isWithinConnectionRange: player.blockPosition().distSqr(vault) in range. + let block = at.map(|v| v.floor() as i32); + let d = [0, 1, 2].map(|i| f64::from(block[i] - pos[i])); + if d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > range * range { + continue; + } + let target = [at[0], at[1] + f64::from(height / 2.0), at[2]]; + let direction: [f64; 3] = std::array::from_fn(|i| target[i] - keyhole[i]); + let random = self.particles.level_random(); + let count = random.next_int_bound(4).unwrap_or(0) + 2; + for _ in 0..count { + let v = offset_random(direction, self.particles.level_random(), 1.0); + self.particle(T::VaultConnection, keyhole, v); + } } } } diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 312beb0fe4..2e328020a3 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -5,6 +5,7 @@ //! lightning on a hit (`channeling.json`). use super::{Game, view_vector}; use horizon_client::{ + arrows::{back_off, clip_block_stop}, collisions::SceneCollisions, enchant::EffectContext, items::ItemStack, @@ -29,6 +30,8 @@ pub(super) struct ThrownTrident { pub(super) pitch: f32, pub(super) in_ground: bool, in_ground_time: u32, + /// `AbstractArrow.shakeTime`: seven ticks after sticking, no pickup. + shake: u32, dealt_damage: bool, pub(super) loyalty: u32, pub(super) no_physics: bool, @@ -64,6 +67,7 @@ impl ThrownTrident { pitch, in_ground: false, in_ground_time: 0, + shake: 0, dealt_damage: false, loyalty: 0, no_physics: false, @@ -96,6 +100,7 @@ impl ThrownTrident { /// server decides what it hits. pub(super) fn remote_tick(&mut self, w: &impl ProjectileWorld, owner_eye: Option) { self.previous = self.position; + self.shake = self.shake.saturating_sub(1); if self.in_ground_time > 4 { self.dealt_damage = true; } @@ -124,9 +129,11 @@ impl ThrownTrident { if !self.no_physics && let Some((at, _, _)) = w.clip(self.position, to) { - self.position = at; + // AbstractArrow.onHitBlock's back-off, as the server's tick does it. + self.position = back_off(at, m); self.velocity = Vec3::ZERO; self.in_ground = true; + self.shake = 7; return; } self.position = to; @@ -138,12 +145,6 @@ impl ThrownTrident { } } const HALF: f64 = 0.25; -fn bounds(p: Vec3) -> Aabb { - Aabb::new( - Vec3::new(p.x - HALF, p.y, p.z - HALF), - Vec3::new(p.x + HALF, p.y + 2.0 * HALF, p.z + HALF), - ) -} /// The entry distance of a segment into a box, if it crosses it. fn segment_box(from: Vec3, d: Vec3, b: Aabb) -> Option { let (lo, hi) = (b.min_position(), b.max_position()); @@ -409,6 +410,7 @@ impl Game<'_> { pitch: projectile.pitch, in_ground: false, in_ground_time: 0, + shake: 0, dealt_damage: false, no_physics: false, returning: 0, @@ -521,6 +523,8 @@ impl Game<'_> { while i < self.tridents.len() { let mut t = self.tridents.remove(i); t.previous = t.position; + // AbstractArrow.tick: `if (shakeTime > 0) --shakeTime;`. + t.shake = t.shake.saturating_sub(1); if t.in_ground_time > 4 { t.dealt_damage = true; } @@ -634,34 +638,32 @@ impl Game<'_> { -t.velocity.z * 0.02, ); } else { - let mut scene = SceneCollisions { - world: &self.world, - binding: self.collision, - subject: None, - }; - if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { - let (from, flight) = (t.position, t.velocity); - let blocked = (moved.x - t.velocity.x).abs() > 1e-7 - || (moved.y - t.velocity.y).abs() > 1e-7 - || (moved.z - t.velocity.z).abs() > 1e-7; - t.position = Vec3::new( - t.position.x + moved.x, - t.position.y + moved.y, - t.position.z + moved.z, - ); - if blocked { - if t.stack.enchantment_level("minecraft:channeling") > 0 { - self.channel_rod_hit(from, flight, t.guest); - } - t.in_ground = true; - t.velocity = Vec3::ZERO; - self.audio.play( - "minecraft:item.trident.hit_ground", - Some([t.position.x, t.position.y, t.position.z]), - 1.0, - 1.0, - ); + // AbstractArrow.tick: a zero-width ray clip of the movement + // (`ClipContext.Block.COLLIDER`, no fluids), then + // `AbstractArrow.onHitBlock` on a hit. + let from = t.position; + let flight = t.velocity; + if let Some((stop, _)) = + clip_block_stop(&self.world, self.res.facts(), self.collision, from, flight) + { + if t.stack.enchantment_level("minecraft:channeling") > 0 { + self.channel_rod_hit(from, flight, t.guest); } + // 0.05 back along signum(movement), stopped, stuck, shaking. + t.position = stop; + t.velocity = Vec3::ZERO; + t.in_ground = true; + t.shake = 7; + self.audio.play( + "minecraft:item.trident.hit_ground", + Some([stop.x, stop.y, stop.z]), + 1.0, + 1.0, + ); + } else { + // A MISS moves the whole segment. + t.position = + Vec3::new(from.x + flight.x, from.y + flight.y, from.z + flight.z); } } if !t.in_ground { @@ -679,8 +681,9 @@ impl Game<'_> { } // playerTouch: stuck or returning tridents go back to a player. // ThrownTrident.playerTouch: only its owner takes it, or anyone - // once the owner is not in this level (`getOwner() == null`). - if t.in_ground || t.no_physics { + // once the owner is not in this level (`getOwner() == null`), and + // not while `AbstractArrow.shakeTime` still runs. + if (t.in_ground || t.no_physics) && t.shake == 0 { let near = |c: &&Catcher| { c.touches && (owner_eye.is_none() || c.guest == t.guest) diff --git a/crates/horizon-client/src/game/wandering_trader.rs b/crates/horizon-client/src/game/wandering_trader.rs index 87ca5753b2..11ff5cd3be 100644 --- a/crates/horizon-client/src/game/wandering_trader.rs +++ b/crates/horizon-client/src/game/wandering_trader.rs @@ -240,7 +240,7 @@ impl Game<'_> { if !self.world.loaded(x, z) { continue; } - for y in (from[1] - range).max(horizon_client::chunk::MIN_Y)..=from[1] + range { + for y in (from[1] - range).max(self.world.bottom_y())..=from[1] + range { if &*self.res.facts().get(self.world.block(x, y, z)).name != "minecraft:bell" { continue; } diff --git a/crates/horizon-client/src/game/wind.rs b/crates/horizon-client/src/game/wind.rs index 4137d098ec..a13adda01e 100644 --- a/crates/horizon-client/src/game/wind.rs +++ b/crates/horizon-client/src/game/wind.rs @@ -158,7 +158,7 @@ impl Game<'_> { .get("minecraft:blocks_wind_charge_explosions") .cloned() .unwrap_or_default(); - let (min_y, max_y) = (horizon_client::chunk::MIN_Y, 320); + let (min_y, max_y) = (self.world.bottom_y(), self.world.top_y()); let mut set: BTreeSet<[i32; 3]> = BTreeSet::new(); for xx in 0..16 { for yy in 0..16 { @@ -517,6 +517,16 @@ impl Game<'_> { if !triggers(&name) { return Ok(()); } + // BellBlock.onExplosionHit: attemptToRing with no direction, so the bell + // rings toward its facing, not the face the burst came from, + // powered or not. + if &*name == "minecraft:bell" { + self.with_level(|b, level, ticks| { + b.bell_attempt_to_ring(level, ticks, BlockPos::new(pos[0], pos[1], pos[2])) + }); + self.process_level_events()?; + return Ok(()); + } // Doors react in their lower half only; powered ones and pressed // buttons stay as they are. if name.ends_with("_door") && prop(self, "half").as_deref() != Some("lower") { diff --git a/crates/horizon-client/src/game/world_loading.rs b/crates/horizon-client/src/game/world_loading.rs index ad36cb2592..9eeecdd6f7 100644 --- a/crates/horizon-client/src/game/world_loading.rs +++ b/crates/horizon-client/src/game/world_loading.rs @@ -119,6 +119,7 @@ impl WorldRequest { make_streaming(res, pack, options, settings, &save_root, dim, level.seed)?; streaming.radius = settings.render_distance; let mut world = World::new(res.facts().air, level.seed); + world.shape = dim.shape(); world.fast_lava = dim == Dim::Nether; world.game_time = level.time; world.day_time = options.time.unwrap_or(level.day_time); diff --git a/crates/horizon-client/src/item_render.rs b/crates/horizon-client/src/item_render.rs index 13107e98a0..190e180f15 100644 --- a/crates/horizon-client/src/item_render.rs +++ b/crates/horizon-client/src/item_render.rs @@ -96,6 +96,36 @@ impl Glint { ] } } +/// The glint inputs of one frame, read once: `GameRenderer.extract` snapshots +/// `glintSpeed` and `glintStrength` into `OptionsRenderState` +/// (`extractOptions`), and `TextureTransform.setupGlintTexturing` reads +/// `Util.getMillis()` once per glint render-type batch, not per item. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct GlintFrame { + /// `Util.getMillis()` this frame. + pub millis: f32, + /// `OptionsRenderState.glintSpeed`. + pub speed: f32, + /// `OptionsRenderState.glintStrength`. + pub alpha: f32, + /// The atlas has `misc/enchanted_glint_item`. + pub sprite: bool, +} +impl GlintFrame { + /// The glint at a texturing scale, if the atlas has its sprite. + pub fn glint(&self, scale: f32) -> Option { + self.sprite.then_some(Glint { + speed: self.speed, + alpha: self.alpha, + millis: self.millis, + scale, + }) + } + /// [`Glint::scroll_ticks`] for this frame. + pub fn scroll_ticks(&self) -> f32 { + Glint::scroll_ticks(self.millis, self.speed) + } +} /// Which glint render type a quad uses. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Foil { @@ -863,6 +893,39 @@ pub fn cluster_poses(state: &ItemRenderState, pose: Mat4, amount: u32, seed: i64 mod tests { use super::*; + /// One frame's glint is read once and shared: every item drawn from the + /// same `GlintFrame` scrolls by the same phase, and without the atlas + /// sprite no item gets a glint (`TextureTransform.setupGlintTexturing` + /// once per batch, `GameRenderer.extractOptions` once per frame). + #[test] + fn a_frame_glint_is_shared_by_every_item() { + let frame = GlintFrame { + millis: 123_456.0, + speed: 0.5, + alpha: 0.75, + sprite: true, + }; + let world = frame.glint(0.5).expect("the atlas has the glint sprite"); + assert_eq!( + (world.speed, world.alpha, world.millis), + (0.5, 0.75, 123_456.0) + ); + assert_eq!(world.scale, 0.5); + let uvs: Vec<_> = (0..1000) + .map(|_| frame.glint(0.5).unwrap().uv([0.25, 0.75])) + .collect(); + assert!(uvs.iter().all(|uv| *uv == uvs[0])); + assert_eq!(frame.scroll_ticks(), Glint::scroll_ticks(123_456.0, 0.5)); + // 123456 ms * 0.5 * 8 = 493824 ticks, modulo 330000. + assert_eq!(frame.scroll_ticks(), 163_824.0); + let missing = GlintFrame { + sprite: false, + ..frame + }; + assert!(missing.glint(0.5).is_none()); + assert_eq!(missing.scroll_ticks(), frame.scroll_ticks()); + } + /// A bad item model surfaces only once items.json has loaded, so a broken /// items.json still reports itself first. #[test] diff --git a/crates/horizon-client/src/lan/chunks.rs b/crates/horizon-client/src/lan/chunks.rs index ebfaba2c2c..7a4428864a 100644 --- a/crates/horizon-client/src/lan/chunks.rs +++ b/crates/horizon-client/src/lan/chunks.rs @@ -570,13 +570,28 @@ impl ChunkEncoder { bits } - /// The non-air sections of the column and its neighbours, as bits. - fn around(&self, chunk: &Chunk, world: &crate::world::World) -> SectionMask { + /// The non-air sections of the column and its neighbours, as bits. A + /// neighbour still a proto chunk counts too, as vanilla's light storage + /// counts a neighbour at INITIALIZE_LIGHT (#4294). + fn around( + &self, + chunk: &Chunk, + world: &crate::world::World, + protos: &dyn Fn(i32, i32) -> Option, + ) -> SectionMask { let mut around = SectionMask::EMPTY; for dz in -1..=1 { for dx in -1..=1 { - if let Some(c) = world.chunks.get(&(chunk.x + dx, chunk.z + dz)) { + let (x, z) = (chunk.x + dx, chunk.z + dz); + if let Some(c) = world.chunks.get(&(x, z)) { around |= self.occupied(c); + } else if let Some(sections) = protos(x, z) { + // Column sections: this level's range from 0. + for i in sections.iter() { + if self.sections.contains(&i) { + around.insert(i - self.sections.start); + } + } } } } @@ -590,7 +605,21 @@ impl ChunkEncoder { /// sections and light layers go straight to it, with no packet built and /// copied again first. pub fn body_in(&self, chunk: &Chunk, world: &crate::world::World) -> Vec { - self.body(chunk, &world.block_entities, self.around(chunk, world)) + self.body_with(chunk, world, &|_, _| None) + } + /// [`Self::body_in`], with `protos` giving the non-air sections of a + /// neighbour that is still a proto chunk. + pub fn body_with( + &self, + chunk: &Chunk, + world: &crate::world::World, + protos: &dyn Fn(i32, i32) -> Option, + ) -> Vec { + self.body( + chunk, + &world.block_entities, + self.around(chunk, world, protos), + ) } /// Make what these columns need and is not cached yet (their heightmaps @@ -994,9 +1023,6 @@ pub fn entity_tag(entity: &BlockEntity) -> Option { .next_spawn_data .as_ref() .map(|d| Tag::compound([("spawn_data", d.encode())])), - // CommandBlockEntity.getUpdateTag: saveWithoutMetadata, the command - // and its output settings (the edit screen reads them). - BlockEntity::CommandBlock(c) => Some(Tag::compound(c.save())), // SkullBlockEntity: the profile is the whole reason a head differs. BlockEntity::Skull(s) => { let mut entries = Vec::new(); @@ -1328,6 +1354,36 @@ mod tests { assert_eq!(second, light_data(&chunk.sections, SectionMask::ALL)); } + /// A neighbour still a proto chunk counts for the light layers as it + /// will once loaded (#4294: vanilla counts a neighbour at + /// INITIALIZE_LIGHT). + #[test] + fn a_proto_neighbour_counts_for_the_light_layers() { + let encoder = encoder(); + let mut world = crate::world::World::new(horizon_world::registry::BlockStateId(0), 0); + for x in -1..=1 { + for z in -1..=1 { + let mut chunk = Chunk::empty(x, z); + chunk.set(0, MIN_Y, 0, 1); + world.chunks.insert((x, z), Arc::new(chunk)); + } + } + // The neighbour east has a block ten sections up. + let mut tall = Chunk::empty(1, 0); + tall.set(0, MIN_Y, 0, 1); + tall.set(0, MIN_Y + 16 * 10, 0, 1); + world.chunks.insert((1, 0), Arc::new(tall)); + let centre = world.chunks[&(0, 0)].clone(); + let loaded = encoder.body_in(¢re, &world); + world.chunks.remove(&(1, 0)); + assert_ne!(encoder.body_in(¢re, &world), loaded); + let mut sections = SectionMask::EMPTY; + sections.insert(0); + sections.insert(10); + let proto = |x, z| ((x, z) == (1, 0)).then_some(sections); + assert_eq!(encoder.body_with(¢re, &world, &proto), loaded); + } + #[test] fn light_masks_cover_the_world_plus_borders() { let chunk = Chunk::empty(0, 0); diff --git a/crates/horizon-client/src/lan/conn.rs b/crates/horizon-client/src/lan/conn.rs index ba4c6b8da7..903221088b 100644 --- a/crates/horizon-client/src/lan/conn.rs +++ b/crates/horizon-client/src/lan/conn.rs @@ -1177,7 +1177,7 @@ fn read_loop( // The client closed the connection: Connection.channelInactive // without details, multiplayer.disconnect.generic. Err(e) - if e.0 == "Connection closed by the server" + if e.is_end_of_stream() || e.0.contains("reset by peer") || e.0.contains("aborted") => { diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index dbd89162a2..10824bb315 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -67,6 +67,9 @@ pub struct EntityState { pub needs_sync: bool, /// `Leashable.getLeashHolder`: the entity holding its lead. pub leash: Option, + /// `ServerPlayer.isSpectator`: a player in spectator mode, whom only the + /// clients of other spectators track (`ServerPlayer.broadcastToPlayer`). + pub spectator: bool, } impl EntityState { @@ -90,6 +93,7 @@ impl EntityState { self.precise = false; self.needs_sync = false; self.leash = None; + self.spectator = false; } } @@ -235,6 +239,9 @@ struct ServerEntity { /// TrackedEntity.lastSectionPos: its pairs are checked again when it /// moves to another section. section: [i32; 3], + /// Whether it was a spectator last tick: its pairs are checked again when + /// that changes, as `ServerPlayer.broadcastToPlayer` answers otherwise. + spectator: bool, } /// SectionPos.of(entity). @@ -275,6 +282,7 @@ impl ServerEntity { index: 0, added: true, section: section_of(e.position), + spectator: e.spectator, } } @@ -602,6 +610,8 @@ impl Tracker { /// view distance and chunks. pub struct Viewer<'a> { pub entity_id: i32, + /// Its player is in spectator mode: it tracks the other spectators too. + pub spectator: bool, /// It accepted a move since the last tick (ChunkMap.move): every pair /// of it is checked again. pub moved: bool, @@ -624,16 +634,20 @@ struct Probe { id: i32, range: i32, chunk: u32, + spectator: bool, } /// TrackedEntity.updatePlayer: within the type's range (capped by the view -/// distance) horizontally, in a chunk the client has. `held` remembers for +/// distance) horizontally, shown to the client (`Entity.broadcastToPlayer`: +/// `ServerPlayer`'s hides a spectator from players who are not spectators; +/// a guest's camera is always its own player, so a spectating client sees +/// every player) and in a chunk the client has. `held` remembers for /// each of the tick's chunks whether the client has it (0: not looked up /// yet, 1: no, 2: yes), so each chunk is looked up once per client. fn sees(v: &Viewer, p: &Probe, chunks: &[(i32, i32)], held: &mut [u8]) -> bool { let range = f64::from(p.range.min(v.view_distance * 16)); let (dx, dz) = (v.position[0] - p.x, v.position[2] - p.z); - dx * dx + dz * dz <= range * range && { + dx * dx + dz * dz <= range * range && (v.spectator || !p.spectator) && { let held = &mut held[p.chunk as usize]; if *held == 0 { *held = 1 + u8::from(v.chunks.contains(&chunks[p.chunk as usize])); @@ -718,11 +732,14 @@ impl Entities { }); s.seen = now; s.index = index; - // ChunkMap.addEntity, and ChunkMap.tick's section change: + // ChunkMap.addEntity, ChunkMap.tick's section change, and a + // player going in or out of spectator mode (which ChunkMap.move + // catches at the player's next move, a second later at most): // updatePlayers. let section = section_of(e.position); - if added || section != s.section { + if added || section != s.section || e.spectator != s.spectator { s.section = section; + s.spectator = e.spectator; self.moved.push(index); self.is_moved[index] = true; } @@ -743,6 +760,7 @@ impl Entities { .range_percent .map_or(s.range * 16, |p| p * (s.range * 16) / 100), chunk, + spectator: e.spectator, }); } // TrackedEntity.getEffectiveRange: a vehicle is tracked as far as its @@ -921,6 +939,7 @@ mod tests { precise: false, needs_sync: false, leash: None, + spectator: false, } } @@ -1264,6 +1283,7 @@ mod tests { out: [Vec>; 2], positions: [[f64; 3]; 2], moved: [bool; 2], + spectators: [bool; 2], } impl Clients { @@ -1274,6 +1294,7 @@ mod tests { out: Default::default(), positions: [[0.0, 64.0, 0.0]; 2], moved: [false; 2], + spectators: [false; 2], } } fn tick(&mut self, level: &mut Entities, entities: &[&EntityState]) { @@ -1285,6 +1306,7 @@ mod tests { let mut viewers = [ Viewer { entity_id: 1, + spectator: self.spectators[0], moved: self.moved[0], position: self.positions[0], view_distance: 10, @@ -1294,6 +1316,7 @@ mod tests { }, Viewer { entity_id: 2, + spectator: self.spectators[1], moved: self.moved[1], position: self.positions[1], view_distance: 10, @@ -1431,6 +1454,53 @@ mod tests { } } + /// ServerPlayer.broadcastToPlayer: a spectator is shown only to other + /// spectators, who see everyone. Going in or out of spectator mode checks + /// its pairs again, though nobody moved. + #[test] + fn spectators_are_tracked_only_by_spectators() { + let mut level = Entities::default(); + let mut c = Clients::new(); + let player = |id: i32| { + let mut p = state(0.0); + (p.id, p.uuid, p.kind) = (id, id as u128, "minecraft:player".into()); + p + }; + let (mut one, mut two) = (player(1), player(2)); + // A third player, already spectating, that neither client plays. + let mut three = player(3); + three.spectator = true; + c.tick(&mut level, &[&one, &two, &three]); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1)); + assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3)); + // The second player starts spectating: the first client loses it, + // and it sees the first player and the other spectator. + (two.spectator, c.spectators[1]) = (true, true); + c.tick(&mut level, &[&one, &two, &three]); + assert!(!c.trackers[0].is_tracking(2)); + let removed = body(play::Clientbound::RemoveEntities(play::RemoveEntities { + ids: vec![2], + })); + assert_eq!(c.out[0].first(), Some(&removed)); + assert!(c.trackers[1].is_tracking(1) && c.trackers[1].is_tracking(3)); + // Its armour changes go to nobody who doesn't track it. + two.equipment.push((5, Some(Stack::new(5, 1)))); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.out[0].is_empty()); + // The first player starts spectating too: both spectators show up. + (one.spectator, c.spectators[0]) = (true, true); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[0].is_tracking(3)); + // Both back in survival: they still see each other, and lose the + // third player. + (one.spectator, c.spectators[0]) = (false, false); + (two.spectator, c.spectators[1]) = (false, false); + c.tick(&mut level, &[&one, &two, &three]); + assert!(c.trackers[0].is_tracking(2) && c.trackers[1].is_tracking(1)); + assert!(!c.trackers[0].is_tracking(3) && !c.trackers[1].is_tracking(3)); + } + #[test] fn a_forgotten_entity_is_paired_again_at_the_next_tick() { // A respawned player: the hub removes it from the other clients and diff --git a/crates/horizon-client/src/lan/guest.rs b/crates/horizon-client/src/lan/guest.rs index 649e57e8a6..36d2702bac 100644 --- a/crates/horizon-client/src/lan/guest.rs +++ b/crates/horizon-client/src/lan/guest.rs @@ -788,6 +788,41 @@ fn network_stack( horizon_protocol::item::Component::Stacks(charged), )); } + // `ItemContainerContents`: a shulker box's items, for its tooltip + // (ClientContainerTooltip). The wire form is the slot count, then one + // optional template per slot, so the guest's stack lists the same items + // the host's holds. + if !d.container.is_empty() { + let slots: Vec<(i32, Stack)> = d + .container + .iter() + .filter_map(|(slot, s)| { + Some(( + i32::from(*slot), + network_stack(item_id, synced_id, &Some(s.clone()))?, + )) + }) + .collect(); + // The saved list names only the filled slots; the codec counts them + // all, from the first to the last that holds one. + let size = slots.iter().map(|(slot, _)| slot + 1).max().unwrap_or(0); + let mut w = horizon_protocol::buf::Writer::new(); + w.varint(size); + for slot in 0..size { + let stack = slots.iter().find(|(at, _)| *at == slot).map(|(_, s)| s); + w.bool(stack.is_some()); + if let Some(stack) = stack { + >::write( + stack, + &mut w, + ); + } + } + stack.added.push(( + horizon_protocol::item::component::CONTAINER, + horizon_protocol::item::Component::Raw(w.data), + )); + } // What a bundle holds, for its tooltip (ClientBundleTooltip). if !d.bundle_contents.is_empty() { let contents = d @@ -1002,4 +1037,41 @@ mod tests { g.sneaking = true; assert!(!g.may_reach_block([0, 71, 0])); } + + /// A shulker box's `minecraft:container` crosses to a guest, so its + /// tooltip can list the items it holds (#2979). + #[test] + fn shulker_contents_survive_inventory_sync() { + let name = Tag::compound([("text", Tag::String("Gold bit".into()))]); + let mut inner = ItemStack::new("minecraft:diamond", 2); + inner.data_mut().custom_name = Some(name); + let mut boxed = ItemStack::new("minecraft:shulker_box", 1); + boxed.data_mut().container = vec![(0, inner)]; + let id = |name: &str| if name.ends_with(":diamond") { 22 } else { 7 }; + + let sent = stack_to_network(id, |_, _| None, &Some(boxed)); + let mut writer = Writer::new(); + horizon_protocol::item::Slot::write(&sent, &mut writer); + let mut reader = Reader::new(&writer.data); + let received = horizon_protocol::item::Slot::read(&mut reader) + .unwrap() + .unwrap(); + assert_eq!(reader.remaining(), 0); + + let Some(Component::Raw(bytes)) = received.component(component::CONTAINER) else { + panic!("the stack carried no container component"); + }; + // ItemContainerContents.STREAM_CODEC: the slot count, then one + // optional template per slot. + let mut r = Reader::new(bytes); + assert_eq!(r.count(256).unwrap(), 1); + assert!(r.bool().unwrap()); + let template = horizon_protocol::item::Template::read(&mut r).unwrap(); + assert_eq!(r.remaining(), 0); + assert_eq!((template.item, template.count), (22, 2)); + assert!(matches!( + template.component(component::CUSTOM_NAME), + Some(Component::Text(_)) + )); + } } diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 3d9808e461..983abe75ce 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -137,6 +137,10 @@ pub struct TickCtx<'a> { pub collision: Option<&'a BlockCollisionRegistry<'a>>, /// The level's world border (PlayerList.sendLevelInfo sends it). pub border: &'a crate::worldborder::WorldBorder, + /// The level's columns still being generated: a column is sent once + /// each neighbour is loaded or a decorated proto chunk (#4294). None: + /// every column there is is loaded. + pub streaming: Option<&'a crate::streaming::Streaming>, } /// What the gameplay code has to do for a guest. @@ -1426,7 +1430,7 @@ impl Hub { for i in 0..self.guests.len() { if self.here(&self.guests[i]) { self.update_view(i); - if let Some(batch) = self.next_batch(i, world) { + if let Some(batch) = self.next_batch(i, world, ctx.streaming) { batches.push((i, batch)); } } @@ -1459,7 +1463,11 @@ impl Hub { self.encoder.warm(&columns); let mut bodies: Vec> = columns .iter() - .map(|c| self.encoder.body_in(c, world)) + .map(|c| { + self.encoder.body_with(c, world, &|x, z| { + ctx.streaming.and_then(|s| s.proto_sections(x, z)) + }) + }) .collect(); for (i, taken, batch) in batches { let g = &mut self.guests[i]; @@ -1518,7 +1526,12 @@ impl Hub { /// PlayerChunkSender.sendNextChunks' choice: ready chunks, closest first, /// as many as the batch quota the client's acknowledgements pace allows, /// with their places in the queue. - fn next_batch<'w>(&mut self, i: usize, world: &'w World) -> Option> { + fn next_batch<'w>( + &mut self, + i: usize, + world: &'w World, + streaming: Option<&crate::streaming::Streaming>, + ) -> Option> { let g = &mut self.guests[i]; if self.ticks <= g.placed_at + 1 || g.unacknowledged >= g.max_unacknowledged { return None; @@ -1533,6 +1546,20 @@ impl Hub { .iter() .enumerate() .filter_map(|(idx, key)| Some((idx, &**world.chunks.get(key).filter(|c| c.lit)?))) + // ChunkStatus.LIGHT needs its neighbours at INITIALIZE_LIGHT: a + // column whose light layers would leave one out waits for it. + .filter(|(_, c)| { + let Some(streaming) = streaming else { + return true; + }; + (-1..=1).all(|dz| { + (-1..=1).all(|dx| { + let (x, z) = (c.x + dx, c.z + dz); + world.chunks.contains_key(&(x, z)) + || streaming.proto_sections(x, z).is_some() + }) + }) + }) .take(budget) .collect(); (!batch.is_empty()).then_some(batch) @@ -1788,6 +1815,7 @@ impl Hub { precise: false, needs_sync: false, leash: None, + spectator: false, } } @@ -1824,7 +1852,7 @@ impl Hub { let e = &h.equipment; self.equipment_of(&e[0], &e[1], [&e[5], &e[4], &e[3], &e[2]]) }; - all.push(self.player_state( + let mut state = self.player_state( h.entity_id, h.uuid, h.position, @@ -1839,7 +1867,9 @@ impl Hub { equipment, h.attributes.clone(), &[], - )); + ); + state.spectator = h.game_mode == 3; + all.push(state); } let placed: Vec = (0..self.guests.len()) .filter(|&i| self.here(&self.guests[i])) @@ -1858,7 +1888,7 @@ impl Hub { let equipment = self.equipment_of(&main, &off, [&armor[0], &armor[1], &armor[2], &armor[3]]); let g = &self.guests[i]; - self.player_state( + let mut state = self.player_state( g.entity_id, g.uuid, g.position, @@ -1876,7 +1906,9 @@ impl Hub { .map(|m| super::attributes::syncable(&m.attributes)) .unwrap_or_default(), &g.effects.list, - ) + ); + state.spectator = g.spectator(); + state }) .collect(); all.extend(guest_states); @@ -1897,6 +1929,7 @@ impl Hub { .zip(outs.iter_mut()) .map(|(g, out)| Viewer { entity_id: g.entity_id, + spectator: g.spectator(), moved: std::mem::take(&mut g.moved), position: g.position, view_distance: g.view_distance, diff --git a/crates/horizon-client/src/lib.rs b/crates/horizon-client/src/lib.rs index 5709610187..428d29e827 100644 --- a/crates/horizon-client/src/lib.rs +++ b/crates/horizon-client/src/lib.rs @@ -14,8 +14,9 @@ pub use horizon_client_render::{ mesher, minimap, mipmap, mod_list, model_library, models, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections, potent_sulfur, providers, screen_draw, screen_effects, sculk, section_mask, settings, shader_pack_list, signs, - skins, sound_engine, sound_events, spans, split_layout, sprites, streaming, tab_list, - telemetry, testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip, + skins, sound_engine, sound_events, sound_preview, spans, split_layout, sprites, streaming, + tab_list, telemetry, testgen, title_edition, trims, ui, vault, weather, widgets, world, + worldborder, zip, }; pub mod attack_strength; pub mod attribute_updates; diff --git a/crates/horizon-client/src/main.rs b/crates/horizon-client/src/main.rs index 8d03455af1..7d9e0a5ef4 100644 --- a/crates/horizon-client/src/main.rs +++ b/crates/horizon-client/src/main.rs @@ -4,20 +4,36 @@ mod game; use horizon_client::{crash, logging}; +#[global_allocator] +static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator; + /// The mod API level this game serves, kept in the program for the launcher /// to read without running it (`horizon_mods::api_level::MARK`). `main` /// reads it, so the linker keeps it. static MOD_API_LEVEL: [u8; 27] = horizon_mods::api_level::MARK; fn main() { + horizon_threads::configure_allocator(); std::hint::black_box(&MOD_API_LEVEL); horizon_client::telemetry::mark_start(); let args: Vec = std::env::args().skip(1).collect(); let game_args = args.iter().take_while(|a| *a != "--dedicated"); - let help = game_args.clone().any(|a| a == "--help" || a == "-h"); - // The dedicated server logs in its own folder, as vanilla's does; the game in its home. + let help = game_args + .clone() + .any(|a| a == "--help" || a == "-h" || a == "--launch-api"); + // --home before the log opens in it. + if let Some(dir) = game_args.clone().skip_while(|a| *a != "--home").nth(1) { + logging::set_game_home(std::path::absolute(dir).unwrap_or_else(|_| dir.into())); + } + // The dedicated server logs in its own folder (--game-dir's), as vanilla's does; the game in its home. let (home, side, thread) = if args.iter().any(|a| a == "--dedicated") { - (std::path::PathBuf::from("."), "server", "Server thread") + let folder = game_args + .clone() + .skip_while(|a| *a != "--game-dir" && *a != "--gameDir") + .nth(1) + .and_then(|dir| std::path::absolute(dir).ok()) + .unwrap_or_else(|| ".".into()); + (folder, "server", "Server thread") } else { (logging::game_home(), "client", "Render thread") }; @@ -28,8 +44,12 @@ fn main() { crash::install(home.clone(), side); } if let Err(error) = game::run() { + // A bad command line is the caller's mistake, not a crash: no report. + let usage = error.is::(); log::error!("Horizon: {error}"); - if !help { + logging::flush(); + // The report's path is the last line on stderr (crash::SAVED_LINE). + if !help && !usage { crash::report_error(&home, side, &*error); } // Started from Explorer, the console window closes with the game: @@ -39,7 +59,11 @@ fn main() { eprintln!("Press Enter to close."); let _ = std::io::stdin().read_line(&mut String::new()); } - std::process::exit(1); + std::process::exit(if usage { + game::exit_code::USAGE + } else { + game::exit_code::ERROR + }); } logging::flush(); } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index 286d254d1d..0350f8e64c 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -21,6 +21,7 @@ pub mod breeze; pub mod camel; pub mod caravan; pub mod control; +pub mod conversion; pub mod copper_golem; pub mod creaking; pub mod creaking_heart; @@ -44,6 +45,7 @@ pub mod happy_ghast; pub mod hostile; pub mod illager; pub mod illusioner; +pub mod item_break; pub mod keyframes; pub mod leash; pub mod living; @@ -1303,6 +1305,15 @@ pub enum MobEvent { amount: f32, source: Damage, }, + /// `AbstractCubeMob.dealDamage` on a mob (an iron golem it touches): the + /// hit, then, only when `hurtServer` let it land, the cube's + /// `entity.slime.attack` with its pitch drawn from the cube's random. + CubeHurtMob { + cube: u32, + target: u32, + amount: f32, + source: Damage, + }, /// `LivingEntity.stabAttack`'s `target.stopRiding()`: a spear knocks the /// target off its mount. StabDismount { @@ -1350,6 +1361,14 @@ pub enum MobEvent { at: [f64; 3], setup: SpawnSetup, }, + /// `Mob.convertTo` with `ConversionType.SINGLE` ([`conversion::single`]): + /// a new mob of `kind` takes the place of `from` (already removed) and + /// keeps its gear, name and flags. + Convert { + from: Box, + kind: &'static str, + params: conversion::Params, + }, DropItem { item: Arc, count: u32, diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 70c6b782e1..26fc4a8eb3 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -831,7 +831,7 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } } // Entity.checkBelowWorld: LivingEntity.onBelowWorld hurts 4 a tick. - if m.position.y < f64::from(crate::chunk::MIN_Y - 64) { + if m.position.y < f64::from(w.world.bottom_y() - 64) { m.damage(4.0, Damage::of(DamageKind::OutOfWorld)); } m.first_tick = false; @@ -1124,7 +1124,13 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: & f64::from(m.ai.yya), f64::from(m.ai.zza), ); - m.travel(w, input, events); + if super::equine::moved_by_client(m) { + // LivingEntity.travelRidden: the rider's client moves it + // (handleMoveVehicle), so the server only stops it. + m.velocity = Vec3::ZERO; + } else { + m.travel(w, input, events); + } push_entities(m, w, events); // Spider.tick: climbing while pressed against a wall. if m.kind.flags.climber { @@ -2627,6 +2633,51 @@ mod same_vehicle_push_tests { } } +#[cfg(test)] +mod ridden_tests { + use super::*; + use crate::mobs::world::with_test_world; + + /// #2920: a saddled horse ridden forward, ticked on the server; with + /// `client` its rider's client moves it (`travelRidden` without + /// `canSimulateMovement`): it only turns to the rider's look and stops. + fn ridden(client: bool) -> Option { + let mut out = None; + with_test_world(&[], |w, types| { + let kind = std::sync::Arc::new(types.types["minecraft:horse"].clone()); + let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0); + m.set_saddle(Some(crate::items::ItemStack::new("minecraft:saddle", 1))); + m.persistent = true; + m.rider = Some(super::super::equine::Rider { + forward: 1.0, + yaw: 90.0, + client, + ..Default::default() + }); + let (mut random, mut events) = (LegacyRandom::new(0), Vec::new()); + for _ in 0..5 { + tick(&mut m, w, &mut random, &mut events); + } + out = Some(m); + }); + out + } + + #[test] + fn the_server_leaves_a_horse_to_its_riders_client() { + let Some(moved) = ridden(false) else { + return; + }; + // The host's own ride: the horse walks west and falls through the air. + assert!(moved.position.x < 0.4, "{:?}", moved.position); + assert!(moved.position.y < 64.0, "{:?}", moved.position); + let held = ridden(true).unwrap(); + assert_eq!(held.position, Vec3::new(0.5, 64.0, 0.5)); + assert_eq!(held.velocity, Vec3::ZERO); + assert_eq!(held.yaw, 90.0, "tickRidden still turns it"); + } +} + #[cfg(test)] mod turtle_ambient_tests { use super::*; diff --git a/crates/horizon-client/src/mobs/anim.rs b/crates/horizon-client/src/mobs/anim.rs index be7d592cc0..5fec28e47d 100644 --- a/crates/horizon-client/src/mobs/anim.rs +++ b/crates/horizon-client/src/mobs/anim.rs @@ -246,6 +246,8 @@ pub enum ArmPose { ThrowTrident, /// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging. Spear, + /// A brush being used (`HumanoidModel.ArmPose.BRUSH`). + Brush, } /// HumanoidModel.setupAnim with empty hands (endermen). pub fn humanoid_pub(p: &mut Poses, s: &State, _m: &Mob) { @@ -378,6 +380,12 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) { p.xrot(arm, x * 0.5 - PI / 10.0); p.yrot(arm, 0.0); } + // Vanilla's BRUSH case: the ITEM pose with twice the drop. + ArmPose::Brush => { + let x = p.get(arm)[3]; + p.xrot(arm, x * 0.5 - PI / 5.0); + p.yrot(arm, 0.0); + } ArmPose::Block => { let x = p.get(arm)[3] * 0.5 - 0.942_477_9 + hx.clamp(-1.396_263_4, 0.436_332_32); p.xrot(arm, x); @@ -761,6 +769,7 @@ pub fn setup( ArmPose::Empty }; let using_main = m.extra.using_item > 0 && !m.extra.using_off_hand; + let using_off = m.extra.using_item > 0 && m.extra.using_off_hand; // AvatarRenderer.getArmPose: an arm holding a spear (at rest too). let spear_in = |hand: &Option>| { hand.as_deref() @@ -780,6 +789,7 @@ pub fn setup( Some("minecraft:crossbow") if m.extra.charged && s.swing == 0.0 => { ArmPose::CrossbowHold } + Some("minecraft:brush") if using_main => ArmPose::Brush, Some(_) if main_spear.is_some() => ArmPose::Spear, Some(_) => ArmPose::Item, None => ArmPose::Empty, @@ -790,8 +800,10 @@ pub fn setup( let off = if player && m.extra.off_hand.is_some() && !pose.affects_offhand() { if off_spear.is_some() { ArmPose::Spear - } else if m.extra.using_item > 0 && m.extra.using_off_hand && m.extra.blocking { + } else if using_off && m.extra.blocking { ArmPose::Block + } else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") { + ArmPose::Brush } else { ArmPose::Item } @@ -1158,4 +1170,60 @@ mod flight_pose_tests { setup(&m, &state, &mut p, &HashMap::new()); assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); } + + #[test] + fn a_brush_being_used_sweeps_the_arm_lower_than_a_plain_item() { + // AvatarRenderer.getArmPose: a brush being used is the BRUSH pose, and + // HumanoidModel.poseRightArm drops that arm twice as far as ITEM + // (#4273: brushing had no third-person arm pose at all). + let state = State { + pos: 0.0, + speed: 0.0, + age: 0.0, + y_rot: 0.0, + x_rot: 0.0, + swing: 0.0, + baby: false, + partial: 0.0, + }; + let make = || Poses { + map: ["head", "right_arm", "left_arm"] + .into_iter() + .map(|n| (n.to_string(), [0.0; 9])) + .collect(), + dummy: [0.0; 9], + skipped: Default::default(), + hidden: Default::default(), + }; + let mut m = player(); + m.extra.main_hand = Some("minecraft:brush".into()); + m.extra.using_item = 20; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + // HumanoidModel.setupAnim's BRUSH pose. + assert!((p.get("right_arm")[3] + PI / 5.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + // An item with no use animation keeps the ITEM pose: BRUSH cannot leak. + let mut m = player(); + m.extra.main_hand = Some("minecraft:stick".into()); + m.extra.using_item = 20; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + // AvatarRenderer.getArmPose builds the pose per hand: brushing with the + // brush in the off hand is BRUSH on the left arm, and the main hand + // keeps its ITEM pose (the two must not swap). + let mut m = player(); + m.extra.main_hand = Some("minecraft:stick".into()); + m.extra.off_hand = Some("minecraft:brush".into()); + m.extra.using_item = 20; + m.extra.using_off_hand = true; + let mut p = make(); + setup(&m, &state, &mut p, &HashMap::new()); + assert!((p.get("left_arm")[3] + PI / 5.0).abs() < 1e-6); + assert_eq!(p.get("left_arm")[4], 0.0); + assert!((p.get("right_arm")[3] + PI / 10.0).abs() < 1e-6); + assert_eq!(p.get("right_arm")[4], 0.0); + } } diff --git a/crates/horizon-client/src/mobs/animal_brain.rs b/crates/horizon-client/src/mobs/animal_brain.rs index 77c7f331a9..bf86f8e441 100644 --- a/crates/horizon-client/src/mobs/animal_brain.rs +++ b/crates/horizon-client/src/mobs/animal_brain.rs @@ -1083,7 +1083,7 @@ impl B { *last_target = walk.target.block(cx.w); t.begin(cx.lr, cx.now); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -1476,7 +1476,7 @@ impl B { if d > 4 && compute_path(m, cx, &walk, path, speed) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } diff --git a/crates/horizon-client/src/mobs/armadillo.rs b/crates/horizon-client/src/mobs/armadillo.rs index 8ef7419554..2f7abeb5fc 100644 --- a/crates/horizon-client/src/mobs/armadillo.rs +++ b/crates/horizon-client/src/mobs/armadillo.rs @@ -8,7 +8,8 @@ use super::{ Damage, DamageKind, Mob, MobEvent, MobWorld, Species, Who, goals::{ - Cx, Float, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, Stroll, goal_clone, + Breed, Cx, Float, FollowParent, Goal, LOOK, LookAtPlayer, MOVE, Panic, RandomLookAround, + Stroll, Tempt, goal_clone, }, }; use horizon_core::{geometry::Vec3, random::RandomSource}; @@ -60,6 +61,12 @@ pub fn register(m: &mut Mob) { g.add(0, Float); g.add(1, ArmadilloPanic(panic)); g.add(2, Rolled); + // ArmadilloAi.initIdleActivity: AnimalMakeLove(1.0), then RunOne of + // FollowTemptation(1.25, armadillo_food) and BabyFollowAdult(5..16, 1.25). + g.add(3, Idle(Breed::new(1.0))); + let tempt = Tempt::new(1.25, m.kind.food.clone(), false); + g.add(4, Idle(ArmadilloTempt(tempt))); + g.add(4, Idle(FollowParent::new(1.25))); g.add(5, Stroll::avoiding_water(1.0)); g.add(6, LookAtPlayer::new(6.0)); g.add(7, RandomLookAround::default()); @@ -392,6 +399,70 @@ impl Goal for ArmadilloPanic { self.0.stop(m, cx); } } +/// A behaviour of the IDLE activity: `ArmadilloAi.updateActivity` picks +/// PANIC over it while danger is in mind and it is not panicking, and a +/// ball does not walk (`ArmadilloAi$1`, its `MoveToTargetSink`). +#[derive(Clone, Debug)] +struct Idle(G); +fn idle(m: &Mob) -> bool { + !scared(m) && !m.extra.armadillo.panic_activity +} +impl Goal for Idle { + goal_clone!(); + fn flags(&self) -> u8 { + self.0.flags() + } + fn name(&self) -> &'static str { + self.0.name() + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + idle(m) && self.0.can_use(m, cx) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + idle(m) && self.0.can_continue(m, cx) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx); + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop(m, cx); + } + fn every_tick(&self) -> bool { + self.0.every_tick() + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.tick(m, cx); + } +} +/// `FollowTemptation` with `ArmadilloAi.initIdleActivity`'s close enough +/// distance: 1 block for a baby, 2 for an adult. +#[derive(Clone, Debug)] +struct ArmadilloTempt(Tempt); +impl Goal for ArmadilloTempt { + goal_clone!(); + fn flags(&self) -> u8 { + self.0.flags() + } + fn name(&self) -> &'static str { + self.0.name() + } + fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_use(m, cx) + } + fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { + self.0.can_continue(m, cx) + } + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.start(m, cx); + } + fn stop(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop(m, cx); + } + fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { + self.0.stop_distance = if m.is_baby() { 1.0 } else { 2.0 }; + self.0.tick(m, cx); + } +} /// The behaviours that do not run while the armadillo is a ball: its /// `MoveToTargetSink` and the idle activity's walking and looking. #[derive(Clone, Debug)] @@ -445,6 +516,125 @@ mod tests { assert!(hiding_in_shell(&m), "the model shows the shell"); } + /// Runs the goals of `m` for `ticks` ticks; the events. + fn run_goals(m: &mut Mob, w: &MobWorld, ticks: u32) -> Vec { + let mut events = Vec::new(); + let mut goals = std::mem::take(&mut m.ai.goals); + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + for _ in 0..ticks { + goals.tick(m, &mut cx); + } + m.ai.goals = goals; + events + } + /// Real armadillos from the vanilla data, with their goals, at these + /// spots; `None` without the data. + fn real(at: [[f64; 3]; N]) -> Option<[Mob; N]> { + let mut out = None; + crate::mobs::world::with_test_world(&[], |_, types| { + let kind = Arc::new(types.types["minecraft:armadillo"].clone()); + let mut id = 0; + out = Some(at.map(|[x, y, z]| { + id += 1; + let mut m = Mob::new(id, kind.clone(), Vec3::new(x, y, z), 0.0); + m.on_ground = true; + m + })); + }); + out + } + + #[test] + fn two_armadillos_in_love_make_a_baby() { + // ArmadilloAi.initIdleActivity: AnimalMakeLove(ARMADILLO, 1.0), so two + // armadillos fed spider eyes breed (#3512). + let Some([mut a, mut b, mut c]) = + real([[0.5, 64.0, 0.5], [2.5, 64.0, 0.5], [0.5, 64.0, 1.5]]) + else { + return; + }; + for m in [&mut a, &mut b, &mut c] { + m.extra.in_love = 600; + } + let partner = [b.seen(None)]; + crate::mobs::world::with_test_world(&partner, |w, _| { + let events = run_goals(&mut a, w, 80); + assert!( + events.iter().any(|e| matches!( + e, + MobEvent::Breed { + parent: 1, + partner: 2, + .. + } + )), + "the pair breeds" + ); + assert_eq!(a.extra.in_love, 0); + assert_eq!(a.extra.age, 6000, "the parent rests"); + + // ArmadilloAi.updateActivity: with danger in mind and no panic, + // PANIC replaces IDLE and its behaviours do not run, nor do a + // ball's. + c.extra.armadillo.panic_activity = true; + let events = run_goals(&mut c, w, 80); + assert!(!events.iter().any(|e| matches!(e, MobEvent::Breed { .. }))); + assert!(!c.ai.goals.is_running("Breed")); + c.extra.armadillo.panic_activity = false; + set_state(&mut c, SCARED); + run_goals(&mut c, w, 1); + assert!(!c.ai.goals.is_running("Breed"), "a ball does not breed"); + set_state(&mut c, IDLE); + run_goals(&mut c, w, 1); + assert!(c.ai.goals.is_running("Breed"), "it looks for its mate"); + }); + } + + #[test] + fn a_baby_armadillo_follows_an_adult() { + // BabyFollowAdult(5..16, 1.25) in the idle activity. + let Some([mut baby, adult]) = real([[0.5, 64.0, 0.5], [6.5, 64.0, 0.5]]) else { + return; + }; + baby.extra.age = -24000; + crate::mobs::world::with_test_world(&[adult.seen(None)], |w, _| { + run_goals(&mut baby, w, 1); + assert!(baby.ai.goals.is_running("FollowParent")); + set_state(&mut baby, SCARED); + run_goals(&mut baby, w, 1); + assert!(!baby.ai.goals.is_running("FollowParent"), "not as a ball"); + }); + } + + #[test] + fn a_tempted_baby_comes_closer_than_an_adult() { + // ArmadilloAi.initIdleActivity: FollowTemptation is close enough at + // 1 block for a baby and 2 for an adult. + let Some([mut baby, mut adult]) = real([[0.5, 64.0, 0.5], [4.5, 64.0, 0.5]]) else { + return; + }; + baby.extra.age = -24000; + crate::mobs::world::with_test_world(&[], |w, _| { + let mut events = Vec::new(); + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + let mut tempt = ArmadilloTempt(Tempt::new(1.25, baby.kind.food.clone(), false)); + tempt.tick(&mut baby, &mut cx); + assert_eq!(tempt.0.stop_distance, 1.0); + tempt.tick(&mut adult, &mut cx); + assert_eq!(tempt.0.stop_distance, 2.0); + }); + } + #[test] fn campfire_damage_is_fire_and_has_no_knockback_but_does_not_cause_panic() { // `campfire` is in #is_fire and #no_knockback, but in neither diff --git a/crates/horizon-client/src/mobs/bat.rs b/crates/horizon-client/src/mobs/bat.rs index ca5efa4a25..bd20ab9008 100644 --- a/crates/horizon-client/src/mobs/bat.rs +++ b/crates/horizon-client/src/mobs/bat.rs @@ -66,7 +66,7 @@ pub fn server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { return; } if let Some(&t) = m.extra.positions.first() - && (!w.facts.is_air(w.world.block(t[0], t[1], t[2])) || t[1] <= crate::chunk::MIN_Y) + && (!w.facts.is_air(w.world.block(t[0], t[1], t[2])) || t[1] <= w.world.bottom_y()) { m.extra.positions.clear(); } diff --git a/crates/horizon-client/src/mobs/bee.rs b/crates/horizon-client/src/mobs/bee.rs index 9765dca280..0156a2c190 100644 --- a/crates/horizon-client/src/mobs/bee.rs +++ b/crates/horizon-client/src/mobs/bee.rs @@ -946,7 +946,7 @@ impl GoToHive { let close_enough = if closer_than(m, p, 3) { 1 } else { 2 }; m.ai.nav.max_visited_multiplier = 10.0; let path = m.create_path_to(w, p, close_enough); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(w, path, 1.0); m.ai.nav.path.as_ref().is_some_and(|path| path.reached) } } @@ -1188,7 +1188,7 @@ impl Goal for Wander { at[2].floor() as i32, ]; let path = m.create_path_to(cx.w, p, 1); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } } diff --git a/crates/horizon-client/src/mobs/conversion.rs b/crates/horizon-client/src/mobs/conversion.rs new file mode 100644 index 0000000000..3e6d26f588 --- /dev/null +++ b/crates/horizon-client/src/mobs/conversion.rs @@ -0,0 +1,156 @@ +//! `Mob.convertTo` with `ConversionType.SINGLE`: one mob turns into another +//! of a different type in its place, keeping what the old one had. +use super::Mob; + +/// `ConversionParams` of a `SINGLE` conversion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Params { + /// `keepEquipment`: the worn and held items move to the new mob, with + /// their drop chances. + pub keep_equipment: bool, + /// `preserveCanPickUpLoot`. + pub preserve_can_pick_up_loot: bool, +} +impl Params { + /// `ConversionParams.single(mob, true, true)`, what `ConversionTracker` + /// uses (zombie to drowned, husk to zombie, skeleton to stray). + pub const KEEP_ALL: Self = Self { + keep_equipment: true, + preserve_can_pick_up_loot: true, + }; +} + +/// `ConversionType.SINGLE.convert` and `ConversionType.convertCommon`: `to` +/// is a fresh mob (`EntityType.create(level, CONVERSION)`, no spawn +/// finalization) that takes `from`'s place, motion, ride, lead, equipment, +/// effects, age, name and flags. Its health stays its own type's. +pub fn single(from: &Mob, to: &mut Mob, params: Params) { + // copyPosition, setDeltaMovement and the old mob's vehicle. + to.position = from.position; + to.previous = from.position; + to.yaw = from.yaw; + to.previous_yaw = from.yaw; + to.head_yaw = from.head_yaw; + to.previous_head_yaw = from.head_yaw; + to.head_pitch = from.head_pitch; + to.previous_head_pitch = from.head_pitch; + to.velocity = from.velocity; + to.extra.mount = from.extra.mount; + to.extra.passenger = from.extra.passenger; + if params.keep_equipment { + // Every slot, copyAndClear, and setDropChance from the old mob. + to.extra.set_main_hand(from.extra.main_hand.clone()); + if let Some(stack) = from.extra.main_hand_stack() { + to.extra.set_main_hand_stack(Some(stack.clone())); + } + to.extra.off_hand = from.extra.off_hand.clone(); + to.extra.head = from.extra.head.clone(); + to.extra.chest = from.extra.chest.clone(); + to.extra.legs = from.extra.legs.clone(); + to.extra.feet = from.extra.feet.clone(); + to.extra.armor = from.extra.armor.clone(); + to.extra.gear = from.extra.gear.clone(); + to.extra.drop_chances = from.extra.drop_chances.clone(); + } + to.fall_distance = from.fall_distance; + to.extra.fall_flying = from.extra.fall_flying; + to.ai.last_hurt_by_player = from.ai.last_hurt_by_player; + to.hurt_time = from.hurt_time; + to.body_yaw = from.body_yaw; + to.previous_body_yaw = from.body_yaw; + to.on_ground = from.on_ground; + to.extra.leash = from.extra.leash; + to.extra.leash_pending = from.extra.leash_pending; + to.extra.leash_player = from.extra.leash_player; + // convertCommon. + to.extra.effects = from.extra.effects.clone(); + to.extra.hidden_effects = from.extra.hidden_effects.clone(); + if from.is_baby() { + // setBaby(true); an ageable pair keeps the exact age. + to.extra.age = from.extra.age; + to.extra.forced_age = from.extra.forced_age; + to.extra.forced_age_timer = from.extra.forced_age_timer; + } + if params.preserve_can_pick_up_loot { + to.extra.pick_up_loot = from.extra.pick_up_loot; + } + to.extra.left_handed = from.extra.left_handed; + to.no_ai = from.no_ai; + to.persistent |= from.persistent; + to.name_visible = from.name_visible; + to.invulnerable_flag = from.invulnerable_flag; + to.invulnerable = from.invulnerable; + to.no_gravity = from.no_gravity; + to.silent = from.silent; + // COMPONENTS_TO_COPY: the custom name. + to.custom_name = from.custom_name.clone(); + to.custom_name_text = from.custom_name_text.clone(); + // A zombie that turns into another zombie keeps canBreakDoors. + if super::doors::is_zombie(from.species()) && super::doors::is_zombie(to.species()) { + super::doors::set_can_break_doors(to, from.extra.can_break_doors); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{items::ItemStack, mobs::world::with_test_world}; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + #[test] + fn a_frozen_skeleton_becomes_a_stray_that_keeps_its_bow_and_name() { + with_test_world(&[], |_, types| { + let mob = |id, kind: &str| { + Mob::new( + id, + Arc::new(types.types[kind].clone()), + Vec3::new(3.5, 70.0, -2.5), + 0.0, + ) + }; + let mut s = mob(1, "minecraft:skeleton"); + s.yaw = 90.0; + s.custom_name = Some("Frosty".into()); + s.no_ai = true; + s.extra + .set_main_hand_stack(Some(ItemStack::new("minecraft:bow", 1))); + s.extra.chest = Some("minecraft:chainmail_chestplate".into()); + s.extra.leash = Some(crate::mobs::leash::Holder::Knot([3, 71, -3])); + s.extra.mount = Some(7); + s.extra.passenger = true; + let mut st = mob(2, "minecraft:stray"); + single(&s, &mut st, Params::KEEP_ALL); + assert_eq!(st.custom_name.as_deref(), Some("Frosty")); + assert!(st.no_ai); + assert_eq!(st.yaw, 90.0); + assert_eq!( + st.extra.main_hand_stack().map(|s| &*s.item), + Some("minecraft:bow") + ); + assert_eq!( + st.extra.chest.as_deref(), + Some("minecraft:chainmail_chestplate") + ); + assert_eq!( + st.extra.leash, + Some(crate::mobs::leash::Holder::Knot([3, 71, -3])) + ); + assert_eq!(st.extra.mount, Some(7)); + // Health is the new type's own, not copied. + assert_eq!(st.health, mob(3, "minecraft:stray").health); + // Without keepEquipment the gear stays behind. + let mut bare = mob(4, "minecraft:stray"); + single( + &s, + &mut bare, + Params { + keep_equipment: false, + preserve_can_pick_up_loot: false, + }, + ); + assert!(bare.extra.main_hand.is_none() && bare.extra.chest.is_none()); + assert_eq!(bare.custom_name.as_deref(), Some("Frosty")); + }); + } +} diff --git a/crates/horizon-client/src/mobs/cube.rs b/crates/horizon-client/src/mobs/cube.rs index 3cc16463ca..45c615729f 100644 --- a/crates/horizon-client/src/mobs/cube.rs +++ b/crates/horizon-client/src/mobs/cube.rs @@ -2,7 +2,7 @@ //! movement (`CubeMobMoveControl`), random directions, contact damage, //! squish animation and splitting. use super::{ - Mob, MobEvent, MobWorld, Species, + Damage, Mob, MobEvent, MobWorld, Species, Who, anim::{Poses, State}, control::{MoveKind, Operation, rotlerp}, goals::{Cx, Goal, JUMP, LOOK, MOVE, NearestTarget, Prey, goal_clone, reduced}, @@ -228,14 +228,8 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { .inflate(Vec3::new(reach, 0.0, reach)) .intersects(p.bounds) { - let damage = m.attrs.attack_damage - + if m.species() == Species::MagmaCube { - 2.0 - } else { - 0.0 - }; events.push(MobEvent::AttackPlayer( - damage, + attack_damage(m), [m.position.x, m.position.y, m.position.z], )); if m.species() == Species::Slime { @@ -249,6 +243,55 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } } } + hurt_touching_golems(m, w, events); +} +/// `AbstractCubeMob.getAttackDamage`: the attack damage attribute, plus 2 for +/// a magma cube (`MagmaCube.getAttackDamage`). +fn attack_damage(m: &Mob) -> f32 { + m.attrs.attack_damage + + if m.species() == Species::MagmaCube { + 2.0 + } else { + 0.0 + } +} +/// `AbstractCubeMob.push(entity)`: an iron golem whose `pushEntities` takes +/// this cube (its box overlaps the cube's, `EntitySelector.pushableBy`) gets +/// `dealDamage`: alive, `doTeamsAllowDamage`, `isWithinMeleeAttackRange` +/// (always true for overlapping boxes) and `hasLineOfSight`, then a +/// `mobAttack` hit for `getAttackDamage`. +fn hurt_touching_golems(m: &mut Mob, w: &MobWorld, events: &mut Vec) { + // canDealDamage also needs Mob.isEffectiveAi: a NoAI cube never hurts. + if !can_deal_damage(m) || m.no_ai || !m.alive() { + return; + } + let bounds = m.bounds(); + let own = w.teams.of(Who::Mob(m.id)); + let pushable = m.pushable(); + let golems: Vec = w + .others_near(bounds) + .filter(|o| { + let theirs = w.teams.of(Who::Mob(o.id)); + o.id != m.id + && o.species == Species::IronGolem + && o.alive + && o.bounds.intersects(bounds) + && crate::scoreboard::pushable_by(theirs, own, pushable) + && crate::scoreboard::teams_allow_damage(own, theirs) + }) + .map(|o| o.id) + .collect(); + let from = [m.position.x, m.position.y, m.position.z]; + for golem in golems { + if m.sees(w, Who::Mob(golem)) { + events.push(MobEvent::CubeHurtMob { + cube: m.id, + target: golem, + amount: attack_damage(m), + source: Damage::mob(m.id, from), + }); + } + } } /// MagmaCubeModel.setupAnim (slimes have no animation). pub fn anim(m: &Mob, s: &State, p: &mut Poses) { @@ -369,3 +412,82 @@ impl Goal for CubeKeepJumping { set_wanted(m, 1.0); } } + +#[cfg(test)] +mod golem_touch_tests { + use super::*; + use crate::mobs::world::with_test_world; + use std::sync::Arc; + + /// The golems a cube of this kind and size, `x` blocks east of an iron + /// golem, hurts in one `ai_step`, and the damage. + fn hits(name: &str, size: i32, x: f64) -> Option> { + hits_with(name, size, x, false) + } + fn hits_with(name: &str, size: i32, x: f64, no_ai: bool) -> Option> { + let mut out = None; + with_test_world(&[], |_, types| { + let mob = |id: u32, name: &str, at: Vec3| { + Mob::new(id, Arc::new(types.types[name].clone()), at, 0.0) + }; + let golem = mob(2, "minecraft:iron_golem", Vec3::new(0.5, 64.0, 0.5)); + let mut cube = mob(1, name, Vec3::new(0.5 + x, 64.0, 0.5)); + set_size(&mut cube, size); + cube.no_ai = no_ai; + out = Some((cube, golem.seen(None))); + }); + let (mut cube, golem) = out?; + let mut events = Vec::new(); + with_test_world(&[golem], |w, _| ai_step(&mut cube, w, &mut events)); + Some( + events + .iter() + .filter_map(|e| match e { + MobEvent::CubeHurtMob { + cube: 1, + target, + amount, + source, + } => { + assert_eq!(source.attacker, Some(Who::Mob(1))); + Some((*target, *amount)) + } + _ => None, + }) + .collect(), + ) + } + + /// `AbstractCubeMob.push`: a slime or magma cube the iron golem bumps + /// into hurts it like a touching player (#3513), for `getAttackDamage`; + /// a tiny slime (`canDealDamage`) and a cube clear of the golem do not. + #[test] + fn a_cube_touching_an_iron_golem_hurts_it() { + let Some(slime) = hits("minecraft:slime", 2, 1.0) else { + return; + }; + assert_eq!(slime, vec![(2, 2.0)], "a size 2 slime hits the golem for 2"); + let magma = hits("minecraft:magma_cube", 4, 1.5).unwrap(); + assert_eq!(magma, vec![(2, 6.0)], "a size 4 magma cube hits for 4 + 2"); + let tiny = hits("minecraft:magma_cube", 1, 0.9).unwrap(); + assert_eq!( + tiny, + vec![(2, 3.0)], + "a tiny magma cube still hits for 1 + 2" + ); + assert!( + hits("minecraft:slime", 1, 0.9).unwrap().is_empty(), + "a tiny slime never hurts" + ); + assert!( + hits("minecraft:slime", 2, 3.0).unwrap().is_empty(), + "the golem is out of touch" + ); + assert!( + hits_with("minecraft:slime", 2, 1.0, true) + .unwrap() + .is_empty(), + "a NoAI cube is not isEffectiveAi, so it cannot deal damage" + ); + } +} diff --git a/crates/horizon-client/src/mobs/drops.rs b/crates/horizon-client/src/mobs/drops.rs index ca0a90fde3..2a23be894b 100644 --- a/crates/horizon-client/src/mobs/drops.rs +++ b/crates/horizon-client/src/mobs/drops.rs @@ -2,11 +2,15 @@ //! killer's `killedEntity` hook (a charged creeper's head drop), then //! `dropAllDeathLoot`: the entity loot table (drawn from the world's random //! sequence), `Mob.dropCustomDeathLoot` (equipment by drop chance, with the -//! entity random), the species' extra drops and the experience orbs. +//! entity random), the species' extra drops (an enderman's carried block) and +//! the experience orbs. use super::{Mob, Species, equine, gear, raider, render::DYE_NAMES, species}; use crate::random_sequences::RandomSequences; use horizon_core::random::RandomSource; -use horizon_world::loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack}; +use horizon_world::{ + loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack}, + registry::BlockStateId, +}; /// `DropChances.DEFAULT_EQUIPMENT_DROP_CHANCE`. pub const DEFAULT_DROP_CHANCE: f32 = 0.085; @@ -17,6 +21,9 @@ pub use horizon_world::loot::EQUIPMENT_SLOTS as SLOTS; #[derive(Clone, Debug, PartialEq, Eq)] pub enum Drop { Item(LootStack), + /// `Enderman.dropCustomDeathLoot`: the carried block's own drops, rolled + /// with `enderman::carried_block_tool` (`enderman::carried_block_drops`). + CarriedBlock(BlockStateId), /// An experience orb of this value. Orb(i32), } @@ -480,6 +487,12 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec crate::chunk::MIN_Y && !w.facts.get(w.world.block(bx, by, bz)).full_collision { + while by > w.world.bottom_y() && !w.facts.get(w.world.block(bx, by, bz)).full_collision { by -= 1; } let ground = w.facts.get(w.world.block(bx, by, bz)); @@ -446,6 +451,44 @@ impl Goal for LeaveBlock { } } +/// The tool `Enderman.dropCustomDeathLoot` rolls the carried block's drops +/// with: a diamond axe enchanted by `VanillaEnchantmentProviders.ENDERMAN_LOOT_DROP` +/// (`enchantment_provider/enderman_loot_drop.json`, a single Silk Touch I), +/// so the block itself comes back. +pub fn carried_block_tool() -> ItemStack { + let mut axe = ItemStack::new("minecraft:diamond_axe", 1); + axe.enchant("minecraft:silk_touch", 1); + axe +} + +/// `Enderman.dropCustomDeathLoot`: `BlockState.getDrops` of the carried +/// state with [`carried_block_tool`] and the enderman as `this` entity, +/// drawing from the block table's random sequence (`sequences`: the world's +/// sequences and seed), else `random` (the level random). +pub fn carried_block_drops( + items: &Items, + registry: &BlockRegistry, + state: BlockStateId, + random: &mut impl RandomSource, + sequences: Option<(&mut RandomSequences, i64)>, +) -> Vec { + let tool = carried_block_tool(); + items + .block_drops_exploded( + registry, + state, + Some(&tool), + &[], + random, + sequences, + None, + true, + ) + .into_iter() + .filter(|s| s.count > 0 && &*s.item != "minecraft:air") + .collect() +} + #[cfg(test)] mod tests { use super::*; @@ -476,4 +519,67 @@ mod tests { assert!(!dodges(&seated, &Damage::of(kind)), "{kind:?}"); } } + /// Enderman.dropCustomDeathLoot (#3513): a killed enderman drops the + /// block it carries, mined with a silk touch diamond axe, so a grass + /// block comes back as a grass block (not dirt) and a nylium as itself. + #[test] + fn a_killed_enderman_drops_its_carried_block() { + use crate::mobs::drops; + use horizon_world::loot::{LootData, PackOverlay}; + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() || !root.join("data").exists() { + return; + } + let assets = crate::assets::Assets::load(&root).unwrap(); + let registry = assets.registry; + let facts = crate::blocks::BlockFacts::load(&root, registry).unwrap(); + let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).unwrap(); + let loot = LootData::load(&root, &PackOverlay::NONE).unwrap(); + let death = |mob_drops| drops::Death { + loot: &loot, + damage: None, + player: None, + luck: 0.0, + vehicle: None, + mob_drops, + world_seed: 0, + }; + let mut sequences = RandomSequences::default(); + let mut random = horizon_core::random::LegacyRandom::new(0); + for block in [ + "minecraft:grass_block", + "minecraft:crimson_nylium", + "minecraft:tnt", + ] { + let mut m = enderman(); + m.extra.carried = registry.default_state(block); + let (out, _) = drops::die(&mut m, &death(true), &mut sequences); + let dropped: Vec = out + .into_iter() + .flat_map(|d| match d { + drops::Drop::CarriedBlock(state) => carried_block_drops( + &items, + registry, + state, + &mut random, + Some((&mut sequences, 0)), + ), + _ => Vec::new(), + }) + .map(|s| format!("{} {}", s.item, s.count)) + .collect(); + assert_eq!(dropped, [format!("{block} 1")], "{block}"); + } + // doMobLoot off: no dropCustomDeathLoot, so no block either. + let mut m = enderman(); + m.extra.carried = registry.default_state("minecraft:grass_block"); + let (out, _) = drops::die(&mut m, &death(false), &mut sequences); + assert!( + !out.iter() + .any(|d| matches!(d, drops::Drop::CarriedBlock(_))), + "{out:?}" + ); + } } diff --git a/crates/horizon-client/src/mobs/equine.rs b/crates/horizon-client/src/mobs/equine.rs index 06730531a9..7252cedb62 100644 --- a/crates/horizon-client/src/mobs/equine.rs +++ b/crates/horizon-client/src/mobs/equine.rs @@ -47,6 +47,9 @@ pub struct Rider { pub sprint: bool, /// Holding the jump key (a happy ghast rises). pub jumping: bool, + /// A server's player whose own client moves the mount + /// (`Entity.isClientAuthoritative`): here it only turns (`tickRidden`). + pub client: bool, } pub fn is_equine(s: Species) -> bool { @@ -215,6 +218,18 @@ pub fn controlled(m: &Mob) -> bool { _ => m.extra.saddled, } } +/// `AbstractHorse`s (camels too, not llamas, which no player steers): the +/// player controlling one moves it on its own client +/// (`Entity.isLocalInstanceAuthoritative`, `LivingEntity.travelRidden`) and +/// sends the moves (`ServerboundMoveVehiclePacket`). +pub fn moved_by_rider(s: Species) -> bool { + is_equine(s) && !super::llama::is_llama(s) +} +/// A server's mount that its rider's client moves: `travelRidden` without +/// `canSimulateMovement` turns it (`tickRidden`) and stops it there. +pub fn moved_by_client(m: &Mob) -> bool { + moved_by_rider(m.species()) && m.rider.is_some_and(|r| r.client) && controlled(m) +} /// `ItemBasedSteering.boost`: 140 to 980 ticks of extra speed. pub fn boost(m: &mut Mob) -> bool { if m.extra.counter2 > 0 { diff --git a/crates/horizon-client/src/mobs/finalize.rs b/crates/horizon-client/src/mobs/finalize.rs index cdb680f582..8a9b32c3b7 100644 --- a/crates/horizon-client/src/mobs/finalize.rs +++ b/crates/horizon-client/src/mobs/finalize.rs @@ -628,6 +628,29 @@ pub fn add_modifier(m: &mut Mob, a: Attr, id: &'static str, amount: f64, op: Op) } refresh(m, a); } +/// `AttributeInstance.removeModifier`. +pub fn remove_modifier(m: &mut Mob, a: Attr, id: &str) { + if let Some(s) = m.extra.attributes.as_mut() { + s.modifiers.retain(|x| x.attr != a || x.id != id); + } + refresh(m, a); +} +/// `Mob.finalizeSpawn`, which every mob's own `finalizeSpawn` reaches +/// through `super`: the `random_spawn_bonus` follow range modifier (once) +/// and `setLeftHanded(nextFloat() < 0.05F)`. +pub fn mob_spawn(m: &mut Mob, random: &mut impl RandomSource) { + if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { + let bonus = random.triangle(0.0, 0.114_850_000_000_000_01); + add_modifier( + m, + Attr::FollowRange, + "minecraft:random_spawn_bonus", + bonus, + Op::AddMultipliedBase, + ); + } + m.extra.left_handed = random.next_float() < 0.05; +} /// `Zombie.handleAttributes`, from the zombie's own random. pub fn zombie_attributes(m: &mut Mob, special: f32, reason: Reason) { let r = &mut m.random; @@ -867,6 +890,10 @@ pub fn spawn_egg_offspring(child: &mut Mob, species: Species, variant: u8, color if species == Species::Sheep { child.extra.color = color; } + // `SulfurCube.getBreedOffspring`: `setSize(1, true)`. + if species == Species::SulfurCube { + sulfur_size(child, 1); + } } /// `setBaby`: AgeableMob through its age, zombies, piglins and zoglins. pub fn set_baby(m: &mut Mob, baby: bool) { @@ -1020,17 +1047,7 @@ impl Ctx<'_, L, R> { /// `Mob.finalizeSpawn`: the follow range spawn bonus and left-handedness. fn mob(&mut self, m: &mut Mob, g: Option) -> Option { - if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { - let bonus = self.random.triangle(0.0, 0.114_850_000_000_000_01); - add_modifier( - m, - Attr::FollowRange, - "minecraft:random_spawn_bonus", - bonus, - Op::AddMultipliedBase, - ); - } - m.extra.left_handed = self.float() < 0.05; + mob_spawn(m, self.random); g } /// `AgeableMob.finalizeSpawn`. diff --git a/crates/horizon-client/src/mobs/frog.rs b/crates/horizon-client/src/mobs/frog.rs index 1935c2bd79..1133429ea7 100644 --- a/crates/horizon-client/src/mobs/frog.rs +++ b/crates/horizon-client/src/mobs/frog.rs @@ -145,6 +145,16 @@ pub fn tick(m: &mut Mob) { } /// `Frog.canEat`: a slime or magma cube of size 1. +/// `Frog.updateWalkAnimation`: the frog's short steps walk the legs 25 times +/// the distance (the base is 4), and not at all while the jump plays. +pub fn walk_target(m: &Mob, distance: f32) -> f32 { + if m.anim.states[JUMP].started.is_some() { + 0.0 + } else { + (distance * 25.0).min(1.0) + } +} + fn can_eat(species: Species, size: i32) -> bool { matches!(species, Species::Slime | Species::MagmaCube) && size == 1 } @@ -730,6 +740,31 @@ mod tests { Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0) } + /// `Frog.updateWalkAnimation`: target `min(distance * 25, 1)`, 0 while + /// the jump animation is started, eased by 0.4 (`WalkAnimationState.update`). + #[test] + fn a_frog_walks_its_legs_at_the_vanilla_rate() { + let mut m = frog(); + m.previous = m.position; + m.position.x += 0.02; + m.update_walk_animation(); + assert!((m.limb_speed - 0.5 * 0.4).abs() < 1e-6, "{}", m.limb_speed); + assert!((m.limb_position - m.limb_speed).abs() < 1e-6); + + let mut m = frog(); + m.previous = m.position; + m.position.x += 0.1; + m.update_walk_animation(); + assert!((m.limb_speed - 0.4).abs() < 1e-6, "{}", m.limb_speed); + + let mut m = frog(); + m.anim.states[JUMP].start(0.0); + m.previous = m.position; + m.position.x += 0.02; + m.update_walk_animation(); + assert_eq!(m.limb_speed, 0.0); + } + /// `LongJumpToRandomPos.start`: the 9 x 5 x 9 box without the frog's own /// block, x fastest, then y, then z, each weighted by its squared distance. #[test] diff --git a/crates/horizon-client/src/mobs/goals.rs b/crates/horizon-client/src/mobs/goals.rs index 1b6ec08871..c3913d22d8 100644 --- a/crates/horizon-client/src/mobs/goals.rs +++ b/crates/horizon-client/src/mobs/goals.rs @@ -185,11 +185,11 @@ fn move_up_to_above_solid(w: &MobWorld, mut p: [i32; 3], above: i32) -> [i32; 3] return p; } p[1] += 1; - while p[1] <= 319 && w.solid(p[0], p[1], p[2]) { + while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) { p[1] += 1; } let first = p[1]; - while p[1] <= 319 && p[1] - first < above { + while p[1] < w.world.top_y() && p[1] - first < above { p[1] += 1; if w.solid(p[0], p[1], p[2]) { p[1] -= 1; @@ -283,8 +283,8 @@ impl Mob { near && !within_home(c, r, p) } fn default_check(&self, w: &MobWorld, h: f64, p: [i32; 3]) -> bool { - p[1] >= crate::chunk::MIN_Y - && p[1] < 320 + p[1] >= w.world.bottom_y() + && p[1] < w.world.top_y() && !self.restricted(h, p) && self.stable_destination(w, p) && self.malus(w.path_type_static(p[0], p[1], p[2])) == 0.0 @@ -363,7 +363,7 @@ impl Mob { fn land_up_out_of_solid(&self, w: &MobWorld, mut p: [i32; 3]) -> Option<[i32; 3]> { if w.solid(p[0], p[1], p[2]) { p[1] += 1; - while p[1] <= 319 && w.solid(p[0], p[1], p[2]) { + while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) { p[1] += 1; } } @@ -429,13 +429,14 @@ impl Mob { ) -> Option<[i32; 3]> { let d = self.random_direction_within(0.0, f64::from(h), v, fly_height, xd, zd, max_rad)?; let mut p = self.toward(f64::from(h), d); - if p[1] < crate::chunk::MIN_Y || p[1] >= 320 || self.restricted(f64::from(h), p) { + if p[1] < w.world.bottom_y() || p[1] >= w.world.top_y() || self.restricted(f64::from(h), p) + { return None; } // RandomPos.moveUpOutOfSolid if w.solid(p[0], p[1], p[2]) { p[1] += 1; - while p[1] <= 319 && w.solid(p[0], p[1], p[2]) { + while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) { p[1] += 1; } } @@ -477,8 +478,8 @@ impl Mob { let d = m.random_direction_within(0.0, f64::from(h), v, 0, xd, zd, max_rad)?; // LandRandomPos.generateRandomPosTowardDirection let p = m.toward(f64::from(h), d); - if p[1] < crate::chunk::MIN_Y - || p[1] >= 320 + if p[1] < w.world.bottom_y() + || p[1] >= w.world.top_y() || m.restricted(f64::from(h), p) || !m.stable_destination(w, p) { @@ -507,8 +508,8 @@ impl Mob { self.pick_best(w, |m| { let d = m.random_direction(h, v); let p = m.toward(f64::from(h), d); - if p[1] < crate::chunk::MIN_Y - || p[1] >= 320 + if p[1] < w.world.bottom_y() + || p[1] >= w.world.top_y() || m.restricted(f64::from(h), p) || !m.stable_destination(w, p) { @@ -530,8 +531,8 @@ impl Mob { for _ in 0..10 { let d = self.random_direction(h, v); let p = self.toward(f64::from(h), d); - if p[1] < crate::chunk::MIN_Y - || p[1] >= 320 + if p[1] < w.world.bottom_y() + || p[1] >= w.world.top_y() || self.restricted(f64::from(h), p) || !self.stable_destination(w, p) { @@ -613,8 +614,8 @@ impl Mob { f64::from(std::f32::consts::FRAC_PI_2), )?; let p = m.toward(max_h, d); - if p[1] < crate::chunk::MIN_Y - || p[1] >= 320 + if p[1] < w.world.bottom_y() + || p[1] >= w.world.top_y() || m.restricted(max_h, p) || !m.stable_destination(w, p) { @@ -1271,9 +1272,9 @@ impl Goal for MeleeAttack { } !(t.is_player && t.creative) } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); m.ai.aggressive = true; self.recalc = 0; self.until_attack = 0; @@ -1920,9 +1921,9 @@ impl Goal for AvoidEntity { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.walk); + m.nav_move_to_path(cx.w, path, self.walk); } fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { let from = match &self.what { diff --git a/crates/horizon-client/src/mobs/happy_ghast.rs b/crates/horizon-client/src/mobs/happy_ghast.rs index 418556750a..c007f6f67a 100644 --- a/crates/horizon-client/src/mobs/happy_ghast.rs +++ b/crates/horizon-client/src/mobs/happy_ghast.rs @@ -475,7 +475,7 @@ impl Goal for FloatAround { let mut t = result.unwrap_or_else(|| choose(m)); let top = cx.w.motion_blocking_height(t.x.floor() as i32, t.z.floor() as i32); - if top < t.y.floor() as i32 && top > crate::chunk::MIN_Y { + if top < t.y.floor() as i32 && top > cx.w.world.bottom_y() { t.y = m.position.y - (m.position.y - t.y).abs(); } m.ai.mov.set_wanted(t.x, t.y, t.z, 1.0); diff --git a/crates/horizon-client/src/mobs/item_break.rs b/crates/horizon-client/src/mobs/item_break.rs new file mode 100644 index 0000000000..5ae564cc23 --- /dev/null +++ b/crates/horizon-client/src/mobs/item_break.rs @@ -0,0 +1,147 @@ +//! `LivingEntity.breakItem` as a client sees it. A server never plays the +//! break itself: `LivingEntity.onEquippedItemBroken` broadcasts the entity +//! event `entityEventForEquipmentBreak` gives for the slot, and each client's +//! `LivingEntity.handleEntityEvent` breaks the item it still draws in that +//! slot (`getItemBySlot`), with the sound and the crumbs. +use super::{Mob, drops::head_is_body}; +use crate::items::ItemStack; +use horizon_world::player_inventory::EquipmentSlot; + +/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68: the slot whose +/// item broke (the inverse of `entityEventForEquipmentBreak`). +pub fn equipment_break_slot(event: i8) -> Option { + Some(match event { + 47 => EquipmentSlot::MainHand, + 48 => EquipmentSlot::OffHand, + 49 => EquipmentSlot::Head, + 50 => EquipmentSlot::Chest, + 51 => EquipmentSlot::Legs, + 52 => EquipmentSlot::Feet, + 65 => EquipmentSlot::Body, + 68 => EquipmentSlot::Saddle, + _ => return None, + }) +} + +impl Mob { + /// `LivingEntity.getItemBySlot` for a mob or another player a server + /// sends: the stack it wears or holds in `slot`, with the components + /// `ClientboundSetEquipmentPacket` gave it where they are kept. + pub fn item_by_slot(&self, slot: EquipmentSlot) -> Option { + let e = &self.extra; + let body = head_is_body(self.species()); + let plain = |id: &Option>| id.as_deref().map(|i| ItemStack::new(i, 1)); + let worn = |index: usize, id: &Option>| { + e.armor[index] + .clone() + .filter(|s| id.as_ref() == Some(&s.item)) + .or_else(|| plain(id)) + }; + match slot { + EquipmentSlot::MainHand => e.main_hand_stack().cloned().or_else(|| plain(&e.main_hand)), + EquipmentSlot::OffHand => plain(&e.off_hand), + EquipmentSlot::Head if !body => worn(0, &e.head), + EquipmentSlot::Chest => worn(1, &e.chest), + EquipmentSlot::Legs => worn(2, &e.legs), + EquipmentSlot::Feet => worn(3, &e.feet), + // The body armour (a wolf's, a horse's, a llama's carpet) is + // kept in `head`, with the durability it has lost. + EquipmentSlot::Body if body => plain(&e.head).map(|s| ItemStack { + damage: e.body_damage.max(0) as u32, + ..s + }), + EquipmentSlot::Saddle => self.saddle_item(), + EquipmentSlot::Head | EquipmentSlot::Body => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::Species; + use horizon_core::geometry::Vec3; + use std::sync::Arc; + + fn mob(id: &str, species: Species) -> Mob { + let mut kind = crate::mobs::sensor_scan_tests::kind(); + let k = Arc::make_mut(&mut kind); + k.id = id.into(); + k.species = species; + Mob::new(1, kind, Vec3::ZERO, 0.0) + } + + /// `LivingEntity.entityEventForEquipmentBreak`: 47 main hand, 48 off hand, + /// 49 to 52 head to feet, 65 body, 68 saddle; other events break nothing. + #[test] + fn each_equipment_break_event_names_its_slot() { + let slots = [ + (47, EquipmentSlot::MainHand), + (48, EquipmentSlot::OffHand), + (49, EquipmentSlot::Head), + (50, EquipmentSlot::Chest), + (51, EquipmentSlot::Legs), + (52, EquipmentSlot::Feet), + (65, EquipmentSlot::Body), + (68, EquipmentSlot::Saddle), + ]; + for (event, slot) in slots { + assert_eq!(equipment_break_slot(event), Some(slot), "event {event}"); + } + for event in [2, 3, 35, 46, 53, 54, 55, 60, 64, 66, 67, 69] { + assert_eq!(equipment_break_slot(event), None, "event {event}"); + } + } + + /// The stack another player (an avatar, as a mannequin) holds or wears + /// is the one that breaks. + #[test] + fn a_player_breaks_what_it_holds_and_wears() { + let mut p = mob("minecraft:player", Species::Mannequin); + let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1); + pick.damage = 1560; + p.extra.set_main_hand_stack(Some(pick.clone())); + p.extra.off_hand = Some("minecraft:shield".into()); + p.extra.head = Some("minecraft:iron_helmet".into()); + let mut boots = ItemStack::new("minecraft:leather_boots", 1); + boots.damage = 64; + p.extra.feet = Some(boots.item.clone()); + p.extra.armor[3] = Some(boots.clone()); + let item = |slot| p.item_by_slot(slot).map(|s: ItemStack| (s.item, s.damage)); + assert_eq!( + item(EquipmentSlot::MainHand), + Some((pick.item.clone(), 1560)) + ); + assert_eq!( + item(EquipmentSlot::OffHand), + Some(("minecraft:shield".into(), 0)) + ); + assert_eq!( + item(EquipmentSlot::Head), + Some(("minecraft:iron_helmet".into(), 0)) + ); + assert_eq!(item(EquipmentSlot::Feet), Some((boots.item.clone(), 64))); + assert_eq!(item(EquipmentSlot::Chest), None); + assert_eq!(item(EquipmentSlot::Body), None); + assert_eq!(item(EquipmentSlot::Saddle), None); + } + + /// A wolf's armour is its body slot (kept in `head`), and a horse's + /// saddle its saddle slot. + #[test] + fn body_armour_and_saddles_break_from_their_slots() { + let mut wolf = mob("minecraft:wolf", Species::Wolf); + wolf.extra.head = Some("minecraft:wolf_armor".into()); + wolf.extra.body_damage = 63; + let armour = wolf.item_by_slot(EquipmentSlot::Body).unwrap(); + assert_eq!((&*armour.item, armour.damage), ("minecraft:wolf_armor", 63)); + assert_eq!(wolf.item_by_slot(EquipmentSlot::Head), None); + + let mut horse = mob("minecraft:horse", Species::Horse); + horse.set_saddle(Some(ItemStack::new("minecraft:saddle", 1))); + assert_eq!( + horse.item_by_slot(EquipmentSlot::Saddle).map(|s| s.item), + Some("minecraft:saddle".into()) + ); + } +} diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 5ae818613c..5116bbcf07 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -1280,6 +1280,8 @@ impl Mob { // Creaking.updateWalkAnimation walks the model faster. let target = if self.species() == super::Species::Creaking { (distance * 25.0).min(3.0) + } else if self.species() == super::Species::Frog { + super::frog::walk_target(self, distance) } else { (distance * 4.0).min(1.0) }; diff --git a/crates/horizon-client/src/mobs/monster_brain.rs b/crates/horizon-client/src/mobs/monster_brain.rs index dcab3f5893..e5ab25ed44 100644 --- a/crates/horizon-client/src/mobs/monster_brain.rs +++ b/crates/horizon-client/src/mobs/monster_brain.rs @@ -609,7 +609,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); if *slide { // SlideToTargetSink.start let at = m.center(); @@ -961,7 +961,7 @@ impl B { if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } diff --git a/crates/horizon-client/src/mobs/nav.rs b/crates/horizon-client/src/mobs/nav.rs index 7ec0eb2868..dcc9cef4fc 100644 --- a/crates/horizon-client/src/mobs/nav.rs +++ b/crates/horizon-client/src/mobs/nav.rs @@ -211,10 +211,10 @@ impl Mob { let solid = |p: [i32; 3]| w.solid(p[0], p[1], p[2]); if air(p) { let mut q = [p[0], p[1] - 1, p[2]]; - while q[1] >= crate::chunk::MIN_Y && air(q) { + while q[1] >= w.world.bottom_y() && air(q) { q[1] -= 1; } - if q[1] >= crate::chunk::MIN_Y { + if q[1] >= w.world.bottom_y() { return [q[0], q[1] + 1, q[2]]; } q[1] = p[1] + 1; @@ -240,7 +240,7 @@ impl Mob { max_len: f32, ) -> Option { if targets.is_empty() - || self.position.y < f64::from(crate::chunk::MIN_Y) + || self.position.y < f64::from(w.world.bottom_y()) || !self.can_update_path() { return None; @@ -307,7 +307,9 @@ impl Mob { ]; self.create_path_to(w, p, reach) } - pub fn nav_move_to_path(&mut self, path: Option, speed: f64) -> bool { + /// `PathNavigation.moveTo(Path, speed)`: `trimPath` runs before the + /// node count is checked, so a path the trim empties is refused. + pub fn nav_move_to_path(&mut self, w: &MobWorld, path: Option, speed: f64) -> bool { let Some(path) = path else { self.ai.nav.path = None; return false; @@ -318,6 +320,7 @@ impl Mob { if self.ai.nav.is_done() { return false; } + self.trim_path(w); if self.ai.nav.path.as_ref().is_none_or(|p| p.nodes.is_empty()) { return false; } @@ -336,7 +339,7 @@ impl Mob { at[2].floor() as i32, ]; let path = self.create_path_to(w, p, 1); - self.nav_move_to_path(path, speed) + self.nav_move_to_path(w, path, speed) } pub fn nav_move_to_entity( &mut self, @@ -356,7 +359,7 @@ impl Mob { self.ai.nav.speed_modifier = speed; return true; } - path.is_some() && self.nav_move_to_path(path, speed) + path.is_some() && self.nav_move_to_path(w, path, speed) } pub fn nav_stop(&mut self) { self.ai.nav.path = None; @@ -372,6 +375,28 @@ impl Mob { self.ai.nav.delayed_recompute = false; } } + /// `GroundPathNavigation.trimPath` with `avoidSun`: a mob that is out of + /// the sun keeps only the nodes before the first one the sky sees. + fn trim_path(&mut self, w: &MobWorld) { + if !self.ai.nav.avoid_sun + || !matches!(self.ai.nav.kind, NavKind::Ground | NavKind::WallClimber) + { + return; + } + let at = [ + self.position.x.floor() as i32, + (self.position.y + 0.5).floor() as i32, + self.position.z.floor() as i32, + ]; + if w.can_see_sky(at[0], at[1], at[2]) { + return; + } + if let Some(path) = &mut self.ai.nav.path + && let Some(i) = path.nodes.iter().position(|n| w.can_see_sky(n.x, n.y, n.z)) + { + path.nodes.truncate(i); + } + } /// PathNavigation.tick. pub fn nav_tick(&mut self, w: &MobWorld) { self.ai.nav.tick += 1; diff --git a/crates/horizon-client/src/mobs/path.rs b/crates/horizon-client/src/mobs/path.rs index abadc95658..4a7d98a164 100644 --- a/crates/horizon-client/src/mobs/path.rs +++ b/crates/horizon-client/src/mobs/path.rs @@ -96,7 +96,7 @@ impl MobWorld<'_> { /// WalkNodeEvaluator.getPathTypeStatic. pub fn path_type_static(&self, x: i32, y: i32, z: i32) -> PathType { let t = self.path_type_from_state(x, y, z); - if t == PathType::Open && y > MIN_Y { + if t == PathType::Open && y > self.world.bottom_y() { match self.path_type_from_state(x, y - 1, z) { PathType::Open | PathType::Water | PathType::Lava | PathType::Walkable => { PathType::Open @@ -313,8 +313,6 @@ pub struct Finder<'a, 'w> { entity_depth: i32, } -const MIN_Y: i32 = crate::chunk::MIN_Y; - impl<'a, 'w> Finder<'a, 'w> { pub fn new(w: &'a MobWorld<'w>, mob: &'a PathMob, eval: Evaluator) -> Self { Self { @@ -433,7 +431,7 @@ impl<'a, 'w> Finder<'a, 'w> { } fn fly_path_type(&self, x: i32, y: i32, z: i32) -> PathType { let mut t = self.type_from_state(x, y, z); - if t == PathType::Open && y > MIN_Y { + if t == PathType::Open && y > self.w.world.bottom_y() { let below = self.type_from_state(x, y - 1, z); t = match below { PathType::Fire | PathType::Lava => PathType::Fire, @@ -648,7 +646,7 @@ impl<'a, 'w> Finder<'a, 'w> { start_y = (p.y + 0.5).floor() as i32; } else { let mut y = (p.y + 1.0).floor() as i32; - while y > MIN_Y { + while y > self.w.world.bottom_y() { start_y = y; y -= 1; let s = self.w.world.block(bx, y, bz); @@ -838,7 +836,7 @@ impl<'a, 'w> Finder<'a, 'w> { mut best: Option, ) -> Option { y -= 1; - while y > MIN_Y { + while y > self.w.world.bottom_y() { let t = self.cached(x, y, z); if t != PathType::Water { return best; @@ -851,7 +849,7 @@ impl<'a, 'w> Finder<'a, 'w> { } fn first_ground_below(&mut self, x: i32, y: i32, z: i32) -> u32 { let mut cy = y - 1; - while cy >= MIN_Y { + while cy >= self.w.world.bottom_y() { if y - cy > self.mob.max_fall_distance { return self.blocked_node(x, cy, z); } diff --git a/crates/horizon-client/src/mobs/piglin.rs b/crates/horizon-client/src/mobs/piglin.rs index aab7cf2d94..64e611992a 100644 --- a/crates/horizon-client/src/mobs/piglin.rs +++ b/crates/horizon-client/src/mobs/piglin.rs @@ -946,9 +946,9 @@ impl Goal for WalkAway { fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool { self.source(m, cx.w).is_some() && !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); } fn every_tick(&self) -> bool { true @@ -956,7 +956,7 @@ impl Goal for WalkAway { fn tick(&mut self, m: &mut Mob, cx: &mut Cx) { if self.step(m, cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, self.speed); + m.nav_move_to_path(cx.w, path, self.speed); } } } @@ -1262,9 +1262,9 @@ impl Goal for HoglinAvoidRepellent { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } /// `PiglinAi.avoidRepellent`: `SetWalkTargetAwayFrom.pos(NEAREST_REPELLENT, 1.0, 8, false)` @@ -1315,9 +1315,9 @@ impl Goal for PiglinAvoidRepellent { fn can_continue(&mut self, m: &mut Mob, _: &mut Cx) -> bool { !m.ai.nav.is_done() } - fn start(&mut self, m: &mut Mob, _: &mut Cx) { + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { let path = m.ai.pending_path.take(); - m.nav_move_to_path(path, 1.0); + m.nav_move_to_path(cx.w, path, 1.0); } } /// `PiglinAi.isPlayerHoldingLovedItem`: the idle and celebrate activities' diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 04ebdff1ea..9c5f734588 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -218,15 +218,15 @@ pub fn keeps_alive(m: &Mob, distance_squared: f64) -> bool { /// `Heightmap.Types.MOTION_BLOCKING_NO_LEAVES` position above (x, z). pub fn heightmap_pos(w: &MobWorld, x: i32, z: i32) -> [i32; 3] { - let top = crate::chunk::MIN_Y + crate::chunk::HEIGHT; - let y = (crate::chunk::MIN_Y..top) + let top = w.world.top_y(); + let y = (w.world.bottom_y()..top) .rev() .find(|&y| { let f = w.facts.get(w.world.block(x, y, z)); (f.full_collision || f.fluid != crate::blocks::Fluid::None) && !f.name.ends_with("leaves") }) - .map_or(crate::chunk::MIN_Y, |y| y + 1); + .map_or(w.world.bottom_y(), |y| y + 1); [x, y, z] } @@ -380,8 +380,8 @@ impl Goal for ObtainBanner { fn can_continue(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool { !m.ai.nav.is_done() && state(m).is_some_and(|s| s.banner_at.is_some()) } - fn start(&mut self, m: &mut Mob, _cx: &mut Cx) { - m.nav_move_to_path(self.path_to_banner.take(), 1.15); + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + m.nav_move_to_path(cx.w, self.path_to_banner.take(), 1.15); } } diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 68cdf77a6b..a3cc5abd89 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -100,6 +100,23 @@ pub fn sheep_wool_rgb(color: u8) -> u32 { let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255); f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255) } +/// `ColorLerper.getLerpedColor(ColorLerper.Type.SHEEP, ageInTicks)`: the 16 +/// wool colours in DyeColor order, one per 25 ticks, lerped in between. The +/// colour a `jeb_` sheep's wool takes. +pub fn jeb_wool_rgb(age: f32) -> u32 { + // ageInTicks is the tick count plus the partial tick: never negative. + let i = age.floor(); + let ticks = i as u64; + let j = ticks / 25; + let f = ((ticks % 25) as f32 + (age - i)) / 25.0; + let lo = sheep_wool_rgb((j % 16) as u8); + let hi = sheep_wool_rgb(((j + 1) % 16) as u8); + let mix = |shift: u32| { + let (lo, hi) = ((lo >> shift & 255) as f32, (hi >> shift & 255) as f32); + ((lo + (hi - lo) * f) as u32) << shift + }; + mix(16) | mix(8) | mix(0) +} pub const VILLAGER_TYPES: [&str; 7] = [ "plains", "desert", "jungle", "savanna", "snow", "swamp", "taiga", ]; @@ -459,8 +476,9 @@ pub fn body_color(m: &Mob, alpha: f32) -> u32 { _ => 0xFFFF_FFFF, } } -/// Extra render layers drawn over the body with the same pose. -pub fn layers(m: &Mob) -> Vec { +/// Extra render layers drawn over the body with the same pose. `age` is the +/// render state's `ageInTicks` (`Mob.age` plus the partial tick). +pub fn layers(m: &Mob, age: f32) -> Vec { use Species as S; let mut v = Vec::new(); if avatar(m) { @@ -657,8 +675,14 @@ pub fn layers(m: &Mob) -> Vec { // SlimeOuterLayer: the translucent outer cube. S::Slime => push(&mut v, "slime#outer", "entity/slime/slime", white, false), S::Sheep if !m.extra.sheared => { - let color = abgr(sheep_wool_rgb(m.extra.color)); - if !m.is_baby() && m.extra.color != 0 { + // MobRenderer.checkMagicName(entity, "jeb_"): an exact custom name. + let jeb = m.custom_name.as_deref() == Some("jeb_"); + let color = abgr(if jeb { + jeb_wool_rgb(age) + } else { + sheep_wool_rgb(m.extra.color) + }); + if !m.is_baby() && (jeb || m.extra.color != 0) { push( &mut v, "sheep#wool_undercoat", @@ -1370,7 +1394,7 @@ fn render_visible( for (part, uv, color, layer_overlay) in extra { draw(part, base, &poses, *uv, *color, light, *layer_overlay, out); } - let mut all_layers = layers(m); + let mut all_layers = layers(m, s.age); // WardenRenderer: the translucent emissive layers fade with the animations. if m.species() == Species::Warden { for (model, texture, a) in super::warden::emissive_layers(m, alpha) { @@ -1737,7 +1761,7 @@ mod tests { m.extra.age = if baby { -24000 } else { 0 }; (m.extra.variant, m.extra.variant2, m.extra.variant3) = (t as u8, p as u8, 5); - let layers = layers(&m); + let layers = layers(&m, m.age as f32); let group = if baby { "/baby/" } else { "/type/" }; assert!( layers.iter().any(|l| l.texture.contains(group)), @@ -2052,6 +2076,86 @@ mod tests { // A layer that is not dyeable is not tinted. assert_eq!(armor_tint(None, Some(&stack)), 0xFFFF_FFFF); } + + /// `ColorLerper.getLerpedColor(Type.SHEEP, ageInTicks)`: the points the + /// 26.3 jar gave — 0, half a step, one whole step, a step boundary, the + /// last step back into white, and the 400-tick (16-step) wrap. + #[test] + fn jeb_wool_lerps_through_the_sixteen_dye_colours() { + assert_eq!(jeb_wool_rgb(0.0), 0xE6E6E6); + assert_eq!(jeb_wool_rgb(12.5), 0xD0A37D); + assert_eq!(jeb_wool_rgb(25.0), 0xBA6015); + assert_eq!(jeb_wool_rgb(25.0), sheep_wool_rgb(1)); + assert_eq!(jeb_wool_rgb(50.0), 0x953A8D); + assert_eq!(jeb_wool_rgb(399.0), 0xDDDDDD); + assert_eq!(jeb_wool_rgb(400.0), 0xE6E6E6); + } + + fn sheep_layer(m: &Mob, model: &str, age: f32) -> Option { + layers(m, age).into_iter().find(|l| l.model == model) + } + + /// SheepRenderer.extractRenderState's `jeb_` magic name: the wool layer + /// takes the lerped colour and cycles with the age, a white `jeb_` sheep + /// keeps its undercoat (SheepWoolUndercoatLayer draws for it too), and + /// the dye colour, the baby model and the shears still win. + #[test] + fn a_jeb_sheep_cycles_its_wool_colour_and_keeps_the_undercoat() { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client/26.3"); + if !root.join("entity-models.json").exists() { + return; + } + let types = super::super::MobTypes::load(&root).unwrap(); + let kind = std::sync::Arc::new(types.types["minecraft:sheep"].clone()); + let mut mob = Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0); + mob.custom_name = Some("jeb_".into()); + let young = sheep_layer(&mob, "sheep#wool", 0.0) + .expect("an unsheared sheep draws its wool") + .color; + assert_eq!(young, abgr(0xE6E6E6)); + let older = sheep_layer(&mob, "sheep#wool", 100.0) + .expect("an unsheared sheep draws its wool") + .color; + assert_ne!(young, older, "the colour cycles with the age"); + let undercoat = sheep_layer(&mob, "sheep#wool_undercoat", 0.0) + .expect("a jeb_ sheep keeps its undercoat"); + assert_eq!(undercoat.color, abgr(0xE6E6E6)); + // A name that is not exactly "jeb_" leaves a dyed sheep's colour, and + // a non-white sheep has its undercoat too. + mob.custom_name = Some("jeb".into()); + mob.extra.color = 14; + let dyed = abgr(sheep_wool_rgb(14)); + assert_eq!( + sheep_layer(&mob, "sheep#wool", 0.0) + .expect("an unsheared sheep draws its wool") + .color, + dyed + ); + assert_eq!( + sheep_layer(&mob, "sheep#wool_undercoat", 0.0) + .expect("a dyed sheep keeps its undercoat") + .color, + dyed + ); + // A baby `jeb_` sheep gets the lerped colour on the baby wool and + // never an undercoat. + mob.custom_name = Some("jeb_".into()); + mob.extra.color = 0; + mob.extra.age = -24000; + assert!(mob.is_baby()); + assert_eq!( + sheep_layer(&mob, "sheep_baby#wool", 50.0) + .expect("a baby sheep draws its wool") + .color, + abgr(0x953A8D) + ); + assert!(sheep_layer(&mob, "sheep#wool_undercoat", 50.0).is_none()); + // Sheared: no wool layer at all. + mob.extra.age = 0; + mob.extra.sheared = true; + assert!(sheep_layer(&mob, "sheep#wool", 0.0).is_none()); + } } #[cfg(test)] diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index d146d50ec2..b37dc28fc4 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -170,7 +170,7 @@ fn teleport(m: &mut Mob, w: &MobWorld, events: &mut Vec) -> bool { f64::from(t[2]) + 1.0, ), ); - if t[1] <= crate::chunk::MIN_Y + if t[1] <= w.world.bottom_y() || !w.facts.is_air(w.world.block(t[0], t[1], t[2])) || !no_collision(m, w, unit.deflate(Vec3::new(1.0e-6, 1.0e-6, 1.0e-6))) { @@ -410,7 +410,7 @@ impl ProjectileWorld for Probe<'_, '_> { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { diff --git a/crates/horizon-client/src/mobs/spawning.rs b/crates/horizon-client/src/mobs/spawning.rs index b454815cb3..891c10bf88 100644 --- a/crates/horizon-client/src/mobs/spawning.rs +++ b/crates/horizon-client/src/mobs/spawning.rs @@ -176,6 +176,13 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { if sounds > 0 { m.extra.sound_variant = Some(m.random.next_int_bound(sounds as i32).unwrap_or(0) as u8); } + // Mob.finalizeSpawn, reached through `super` by every mob but the cave + // spider (CaveSpider.finalizeSpawn returns without calling it). + if m.species() != S::CaveSpider { + let mut random = m.random.clone(); + super::finalize::mob_spawn(m, &mut random); + m.random = random; + } match m.species() { // Warden.finalizeSpawn: a minute before it digs back down. S::Warden => super::warden::finalize(m, false, &mut Vec::new()), @@ -255,10 +262,12 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { m.extra.set_main_hand(Some("minecraft:bow".into())); m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special(); } - // WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only. + // WitherSkeleton.populateDefaultEquipmentSlots: a stone sword only; + // WitherSkeleton.finalizeSpawn then sets the attack damage base to 4. S::WitherSkeleton => { m.extra.set_main_hand(Some("minecraft:stone_sword".into())); m.extra.pick_up_loot = m.random.next_float() < 0.55 * difficulty.special(); + super::finalize::set_base(m, super::finalize::Attr::AttackDamage, 4.0); } S::Pillager => { m.extra.set_main_hand(Some("minecraft:crossbow".into())); @@ -343,6 +352,14 @@ pub fn finalize(m: &mut Mob, biome: &BiomeFacts, difficulty: &LocalDifficulty) { ); } } +/// A baby from `Animal.spawnChildFromBreeding`: `getBreedOffspring` creates +/// it without `finalizeSpawn`, so it never ran `Mob.finalizeSpawn`. Undoes +/// that part of `finalize`: no `random_spawn_bonus`, right-handed. +pub fn bred_offspring(m: &mut Mob) { + use super::finalize::{Attr, remove_modifier}; + remove_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus"); + m.extra.left_handed = false; +} /// VillagerType.byBiome pub fn villager_type(b: &BiomeFacts) -> u8 { if b.desert { @@ -361,3 +378,76 @@ pub fn villager_type(b: &BiomeFacts) -> u8 { 0 } } +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::finalize::{Attr, has_modifier, value}; + + fn types() -> Option { + let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../.local/horizon-client") + .join(horizon_core::VANILLA_VERSION); + if !root.join("items.json").exists() { + return None; + } + Some(crate::mobs::MobTypes::load(&root).unwrap()) + } + fn spawn(types: &crate::mobs::MobTypes, kind: &str, id: u32) -> Mob { + let kind = std::sync::Arc::new(types.types.get(kind).unwrap().clone()); + let mut m = Mob::new( + id, + kind, + horizon_core::geometry::Vec3::new(0.5, 64.0, 0.5), + 0.0, + ); + let difficulty = LocalDifficulty::new(3, 0, 0, 1.0); + finalize(&mut m, &BiomeFacts::default(), &difficulty); + m + } + /// #2783: `/summon`, eggs, spawners and natural spawns run + /// `WitherSkeleton.finalizeSpawn`: attack damage base 4.0, not the + /// default 2.0. + #[test] + fn runtime_wither_skeletons_hit_for_four() { + let Some(types) = types() else { return }; + let w = spawn(&types, "minecraft:wither_skeleton", 1); + assert_eq!(value(&w, Attr::AttackDamage), 4.0); + assert_eq!(w.attrs.attack_damage, 4.0); + } + /// #2783: runtime spawns run `Mob.finalizeSpawn`: the follow range + /// bonus and 5 percent left-handed mobs. + #[test] + fn runtime_spawns_run_the_mob_finalize_spawn() { + let Some(types) = types() else { return }; + let mut left = 0; + for id in 1..=400 { + let m = spawn(&types, "minecraft:skeleton", id); + assert!(has_modifier( + &m, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + left += usize::from(m.extra.left_handed); + } + // 5 percent of 400 is 20. + assert!((5..=40).contains(&left), "{left} of 400 left-handed"); + // A bred baby never ran it. + let mut cow = spawn(&types, "minecraft:cow", 1); + cow.extra.left_handed = true; + bred_offspring(&mut cow); + assert!(!has_modifier( + &cow, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + assert!(!cow.extra.left_handed); + assert_eq!(cow.attrs.follow_range, value(&cow, Attr::FollowRange)); + // CaveSpider.finalizeSpawn does not call super. + let c = spawn(&types, "minecraft:cave_spider", 1); + assert!(!has_modifier( + &c, + Attr::FollowRange, + "minecraft:random_spawn_bonus" + )); + } +} diff --git a/crates/horizon-client/src/mobs/tame.rs b/crates/horizon-client/src/mobs/tame.rs index f6e3fa14ec..8fc98c213d 100644 --- a/crates/horizon-client/src/mobs/tame.rs +++ b/crates/horizon-client/src/mobs/tame.rs @@ -1175,12 +1175,12 @@ fn give_morning_gift(m: &mut Mob, cx: &mut Cx) { let z = at[2] + m.random.next_int_bound(11).unwrap_or(5) - 5; let tagged = |p: [i32; 3], tag| has_tag(w, p, tag); let mut ground = y - 1; - while ground > crate::chunk::MIN_Y + while ground > w.world.bottom_y() && !tagged([x, ground, z], "minecraft:entities_can_teleport_to") { ground -= 1; } - if w.world.loaded(x, z) && ground > crate::chunk::MIN_Y { + if w.world.loaded(x, z) && ground > w.world.bottom_y() { let feet = f64::from(ground + 1); let half = f64::from(m.width()) / 2.0; let b = Aabb::new( diff --git a/crates/horizon-client/src/mobs/undead.rs b/crates/horizon-client/src/mobs/undead.rs index 0c598dc94c..19d56ea7bd 100644 --- a/crates/horizon-client/src/mobs/undead.rs +++ b/crates/horizon-client/src/mobs/undead.rs @@ -554,24 +554,20 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) { } else if m.extra.counter2 > 0 { m.extra.counter2 -= 1; if m.extra.counter2 == 0 { - // levelEvent 1048: the conversion sound at the stray. - let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; - cx.events.push(MobEvent::Sound( - "entity.skeleton.converted_to_stray", - m.center(), - 2.0, - pitch, - )); - cx.events.push(MobEvent::SpawnKind { - kind: "minecraft:stray".into(), - at: [m.position.x, m.position.y, m.position.z], - setup: super::SpawnSetup { - yaw: m.yaw, - persistent: m.persistent, - ..Default::default() - }, - }); - m.removed = true; + // ConversionTracker.doConversion: convertTo(STRAY, + // ConversionParams.single(this, true, true)). + convert(m, "minecraft:stray", cx); + // levelEvent 1048: the conversion sound at the stray, unless it + // is silent. + if !m.silent { + let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0; + cx.events.push(MobEvent::Sound( + "entity.skeleton.converted_to_stray", + m.center(), + 2.0, + pitch, + )); + } } } else { m.extra.counter += 1; @@ -580,6 +576,17 @@ fn skeleton_freezing(m: &mut Mob, cx: &mut Cx) { } } } +/// `Mob.convertTo(kind, ConversionParams.single(this, true, true))`: the +/// mob is discarded and a new one of `kind` takes its place with its +/// equipment, name, effects and flags (`MobEvent::Convert`). +fn convert(m: &mut Mob, kind: &'static str, cx: &mut Cx) { + cx.events.push(MobEvent::Convert { + from: Box::new(m.clone()), + kind, + params: super::conversion::Params::KEEP_ALL, + }); + m.removed = true; +} pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { if m.species() == Species::Skeleton && m.alive() && !m.no_ai { skeleton_freezing(m, cx); @@ -601,18 +608,13 @@ pub fn custom_server_ai_step(m: &mut Mob, cx: &mut Cx) { } else { ("minecraft:drowned", "entity.zombie.converted_to_drowned") }; - cx.events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0)); - cx.events.push(MobEvent::SpawnKind { - kind: kind.into(), - at: [m.position.x, m.position.y, m.position.z], - setup: super::SpawnSetup { - baby: m.is_baby(), - yaw: m.yaw, - persistent: m.persistent, - ..Default::default() - }, - }); - m.removed = true; + // ConversionTracker.doConversion: convertTo(type, + // ConversionParams.single(this, true, true)); levelEvent + // 1040 or 1041 unless the new mob is silent. + convert(m, kind, cx); + if !m.silent { + cx.events.push(MobEvent::Sound(sound, m.center(), 1.0, 1.0)); + } } } else if m.fluids.eye_in_water { m.extra.counter += 1; @@ -1074,8 +1076,8 @@ impl Goal for MoveThroughVillage { ) } /// `MoveThroughVillageGoal.start`. - fn start(&mut self, m: &mut Mob, _: &mut Cx) { - m.nav_move_to_path(self.path.clone(), self.speed); + fn start(&mut self, m: &mut Mob, cx: &mut Cx) { + m.nav_move_to_path(cx.w, self.path.clone(), self.speed); } /// `MoveThroughVillageGoal.stop`: a point reached (or the walk ended) is /// visited. @@ -1234,3 +1236,118 @@ impl Goal for AttackTurtleEgg { self.since_reached += 1; } } + +#[cfg(test)] +mod conversion_tests { + use super::*; + use crate::{items::ItemStack, mobs::world::with_test_world}; + use std::sync::Arc; + + /// The mob the game makes of a conversion event (`Game::convert_to` or, + /// for a plain spawn, `spawn_mob` plus `apply_setup` without the spawn + /// finalization's random gear). + fn resolve(event: &MobEvent, types: &crate::mobs::MobTypes) -> Option { + let new = |kind: &str, at: Vec3, yaw: f32| { + Mob::new(2, Arc::new(types.types[kind].clone()), at, yaw) + }; + match event { + MobEvent::Convert { from, kind, params } => { + let mut m = new(kind, from.position, from.yaw); + crate::mobs::conversion::single(from, &mut m, *params); + Some(m) + } + MobEvent::SpawnKind { kind, at, setup } => { + let mut m = new(kind, Vec3::new(at[0], at[1], at[2]), setup.yaw); + m.persistent = setup.persistent; + if setup.baby { + m.extra.age = -24000; + } + Some(m) + } + _ => None, + } + } + + /// `Zombie.drowningTracker` / `Husk`: the last tick of the conversion + /// under water turns the mob into `into`; what comes out (None only + /// without the extracted vanilla data). + fn drown(kind: &str, dress: impl FnOnce(&mut Mob)) -> Option { + let mut out = None; + with_test_world(&[], |w, types| { + let mut m = Mob::new( + 1, + Arc::new(types.types[kind].clone()), + Vec3::new(0.5, 64.0, 0.5), + 30.0, + ); + dress(&mut m); + m.fluids.eye_in_water = true; + m.extra.counter2 = 1; + let mut lr = horizon_core::random::LegacyRandom::new(0); + let mut events = Vec::new(); + let mut cx = Cx { + w, + lr: &mut lr, + events: &mut events, + }; + custom_server_ai_step(&mut m, &mut cx); + assert!(m.removed, "the {kind} is discarded"); + let new = events.iter().find_map(|e| resolve(e, types)); + out = Some(new.expect("the conversion makes a new mob")); + }); + out + } + + #[test] + fn a_drowning_zombie_keeps_its_gear_and_name_tag() { + let Some(d) = drown("minecraft:zombie", |m| { + m.custom_name = Some("Bob".into()); + m.name_visible = true; + m.persistent = true; + m.extra.head = Some("minecraft:iron_helmet".into()); + m.extra.armor[0] = Some(ItemStack::new("minecraft:iron_helmet", 1)); + m.extra + .set_main_hand_stack(Some(ItemStack::new("minecraft:iron_sword", 1))); + m.extra.drop_chances = Some(Box::new([2.0; 8])); + m.extra.pick_up_loot = true; + m.add_effect("minecraft:speed", 600, 1); + }) else { + return; + }; + assert_eq!(d.species(), Species::Drowned); + assert_eq!(d.custom_name.as_deref(), Some("Bob")); + assert!(d.name_visible && d.persistent); + assert_eq!(d.extra.head.as_deref(), Some("minecraft:iron_helmet")); + assert_eq!( + d.extra.armor[0].as_ref().map(|s| &*s.item), + Some("minecraft:iron_helmet") + ); + assert_eq!( + d.extra.main_hand_stack().map(|s| &*s.item), + Some("minecraft:iron_sword") + ); + assert_eq!(d.extra.drop_chances.as_deref(), Some(&[2.0; 8])); + assert!(d.extra.pick_up_loot); + assert!(d.has_effect("minecraft:speed")); + assert_eq!(d.position, Vec3::new(0.5, 64.0, 0.5)); + } + + #[test] + fn a_husk_turns_into_a_zombie_with_its_flags() { + let Some(z) = drown("minecraft:husk", |m| { + m.custom_name = Some("Dusty".into()); + m.no_ai = false; + m.silent = true; + m.extra.age = i32::MIN / 2; + m.extra.feet = Some("minecraft:golden_boots".into()); + m.extra.armor[3] = Some(ItemStack::new("minecraft:golden_boots", 1)); + m.extra.left_handed = true; + }) else { + return; + }; + assert_eq!(z.species(), Species::Zombie); + assert_eq!(z.custom_name.as_deref(), Some("Dusty")); + assert!(z.silent && z.is_baby() && z.extra.left_handed); + assert_eq!(z.extra.feet.as_deref(), Some("minecraft:golden_boots")); + } +} diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index 6bdecedd70..a178807a20 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -790,18 +790,7 @@ pub fn summon_golem( /// `Mob.finalizeSpawn` for a summoned iron golem, from the level's random: /// the follow range spawn bonus and left-handedness. pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) { - use super::finalize::{Attr, Op, add_modifier, has_modifier}; - if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") { - let bonus = random.triangle(0.0, 0.114_850_000_000_000_01); - add_modifier( - m, - Attr::FollowRange, - "minecraft:random_spawn_bonus", - bonus, - Op::AddMultipliedBase, - ); - } - m.extra.left_handed = random.next_float() < 0.05; + super::finalize::mob_spawn(m, random); } /// `Villager.canBreed`: enough food, awake and an adult. pub fn can_breed(m: &Mob) -> bool { diff --git a/crates/horizon-client/src/mobs/villager_brain.rs b/crates/horizon-client/src/mobs/villager_brain.rs index 65db338f97..ad43b1350a 100644 --- a/crates/horizon-client/src/mobs/villager_brain.rs +++ b/crates/horizon-client/src/mobs/villager_brain.rs @@ -925,7 +925,7 @@ fn chunk_pois( return; }; let kind = |state: u32| poi_of_state(w, horizon_world::registry::BlockStateId(state)); - let min = crate::chunk::MIN_Y; + let min = w.world.bottom_y(); for s in (y0 - min) >> 4..=(y1 - min) >> 4 { let section = &chunk.sections[s as usize]; for &(i, poi) in w.world.pois.get((qx, s, qz), section, kind).iter() { @@ -954,8 +954,8 @@ fn pois_in_range( let r2 = i64::from(radius) * i64::from(radius); let (cx, cz) = (c[0] >> 4, c[2] >> 4); let chunks = radius.div_euclid(16) + 1; - let y0 = (c[1] - radius).max(crate::chunk::MIN_Y); - let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1); + let y0 = (c[1] - radius).max(w.world.bottom_y()); + let y1 = (c[1] + radius).min(w.world.top_y() - 1); for qz in cz - chunks..=cz + chunks { for qx in cx - chunks..=cx + chunks { chunk_pois(w, (qx, qz), y0, y1, |poi, p| { @@ -990,8 +990,8 @@ pub(super) fn occupied_pois_in_range( let r2 = i64::from(radius) * i64::from(radius); let (cx, cz) = (center[0] >> 4, center[2] >> 4); let chunks = radius.div_euclid(16) + 1; - let y0 = (center[1] - radius).max(crate::chunk::MIN_Y); - let y1 = (center[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1); + let y0 = (center[1] - radius).max(w.world.bottom_y()); + let y1 = (center[1] + radius).min(w.world.top_y() - 1); for qz in cz - chunks..=cz + chunks { for qx in cx - chunks..=cx + chunks { chunk_pois(w, (qx, qz), y0, y1, |_, p| { @@ -1568,7 +1568,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -2886,7 +2886,7 @@ impl B { if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } } @@ -3595,8 +3595,8 @@ fn homes_in_range(m: &Mob, w: &MobWorld, radius: i32) -> Vec<[i32; 3]> { let c = block_of(m.position); let r2 = i64::from(radius) * i64::from(radius); let mut out = Vec::new(); - let y0 = (c[1] - radius).max(crate::chunk::MIN_Y); - let y1 = (c[1] + radius).min(crate::chunk::MIN_Y + crate::chunk::HEIGHT - 1); + let y0 = (c[1] - radius).max(w.world.bottom_y()); + let y1 = (c[1] + radius).min(w.world.top_y() - 1); for qz in (c[2] - radius) >> 4..=(c[2] + radius) >> 4 { for qx in (c[0] - radius) >> 4..=(c[0] + radius) >> 4 { chunk_pois(w, (qx, qz), y0, y1, |poi, p| { diff --git a/crates/horizon-client/src/mobs/warden_brain.rs b/crates/horizon-client/src/mobs/warden_brain.rs index 7d63364960..8237dfd9c3 100644 --- a/crates/horizon-client/src/mobs/warden_brain.rs +++ b/crates/horizon-client/src/mobs/warden_brain.rs @@ -686,7 +686,7 @@ impl B { t.begin(cx.lr, cx.now); // start mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); return true; } let mm = mem(m); @@ -1027,7 +1027,7 @@ impl B { { *last_target = Some(now_target); mem(m).path.set_opt(path.clone()); - m.nav_move_to_path(path.clone(), f64::from(*speed)); + m.nav_move_to_path(cx.w, path.clone(), f64::from(*speed)); } } // canStillUse is true: they run their duration. diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 4ce39fd843..9f8711a325 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -38,14 +38,14 @@ impl MobWorld<'_> { } /// `Heightmap.Types.MOTION_BLOCKING`: above the highest blocking block. pub fn motion_blocking_height(&self, x: i32, z: i32) -> i32 { - let top = crate::chunk::MIN_Y + crate::chunk::HEIGHT; - (crate::chunk::MIN_Y..top) + let top = self.world.top_y(); + (self.world.bottom_y()..top) .rev() .find(|&y| { let f = self.facts.get(self.world.block(x, y, z)); f.full_collision || f.fluid != crate::blocks::Fluid::None }) - .map_or(crate::chunk::MIN_Y, |y| y + 1) + .map_or(self.world.bottom_y(), |y| y + 1) } /// BlockState.isSolid (the legacy solid flag). pub fn solid(&self, x: i32, y: i32, z: i32) -> bool { diff --git a/crates/horizon-client/src/motion/environment.rs b/crates/horizon-client/src/motion/environment.rs index 826cc31bb2..56dad3323e 100644 --- a/crates/horizon-client/src/motion/environment.rs +++ b/crates/horizon-client/src/motion/environment.rs @@ -102,7 +102,7 @@ impl MotionWorld<'_, '_, '_> { /// take too) and suffocation (`isInWall`, which the caller decides from /// the blocks at the eyes). pub fn server_hazards_tick(&mut self, in_wall: bool) -> Result<()> { - if self.player.position.y < f64::from(crate::chunk::MIN_Y - 64) { + if self.player.position.y < f64::from(self.scene.bottom_y() - 64) { let id = self .resources .health diff --git a/crates/horizon-client/src/motion/fluids.rs b/crates/horizon-client/src/motion/fluids.rs index 4704f8ee22..abf6386b16 100644 --- a/crates/horizon-client/src/motion/fluids.rs +++ b/crates/horizon-client/src/motion/fluids.rs @@ -302,10 +302,10 @@ impl FluidInteractionWorld for MotionWorld<'_, '_, '_> { fn chunk_sections(&mut self, _: ()) -> Result> { // Resident sections keep no fluid count: scanning the box finds no // fluid where a section without fluid would have skipped it. - static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS]; + let layout = self.scene.layout(); Ok(FluidSections { - minimum_section_y: crate::chunk::MIN_Y >> 4, - has_fluid: &SECTIONS, + minimum_section_y: layout.min_y() >> 4, + has_fluid: crate::chunk::every_section(layout), }) } fn bounding_box_min_y(&self) -> f64 { diff --git a/crates/horizon-client/src/net.rs b/crates/horizon-client/src/net.rs index fef47385c4..58d4de8af3 100644 --- a/crates/horizon-client/src/net.rs +++ b/crates/horizon-client/src/net.rs @@ -392,6 +392,52 @@ fn tr(lang: &Language, key: &str) -> String { lang.get(key).cloned().unwrap_or_else(|| key.to_owned()) } +/// `Connection$1.initChannel`: a client socket's pipeline starts with +/// `ReadTimeoutHandler(30)`. A server sends a keep-alive every 15 s, so one +/// that sends nothing for 30 s has frozen or dropped off the network. +const READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// `Connection.connect`: the socket to a server, with `read_timeout` +/// ([`READ_TIMEOUT`]) on every read. The friends relay supplies a concrete +/// endpoint, not a Minecraft hostname, so it skips the SRV lookup. +fn connect_tcp( + host: &str, + port: u16, + relayed: bool, + read_timeout: Duration, +) -> Result { + let c = if relayed { + Connection::connect(host, port, Duration::from_secs(10)) + } else { + Connection::connect_server(host, port, Duration::from_secs(10)) + } + .map_err(|e| e.to_string())?; + c.reader + .set_read_timeout(Some(read_timeout)) + .map_err(|e| e.to_string())?; + Ok(c) +} + +/// `Connection.exceptionCaught`: a read that timed out ends with +/// `disconnect.timeout`, any other failure with `disconnect.genericReason` +/// ("Internal Exception: ..."); a stream the server closed ends with +/// `disconnect.endOfStream` (`Connection.channelInactive`). +fn connection_lost(e: horizon_protocol::Error, lang: &Language) -> String { + if e.is_timed_out() { + tr(lang, "disconnect.timeout") + } else if e.is_end_of_stream() { + tr(lang, "disconnect.endOfStream") + } else { + plain( + &translatable( + "disconnect.genericReason", + &[&format!("Internal Exception: {e}")], + ), + lang, + ) + } +} + /// The whole connection. Ok: why it ended for good (the server's reason, or /// a refusal no retry fixes); Err: the connection failed. fn run( @@ -422,17 +468,11 @@ fn run( target } }; - let c = if address.starts_with(FRIEND) { - // The friends relay supplies a concrete endpoint, not a Minecraft hostname. - Connection::connect(&host, port, Duration::from_secs(10)) - } else { - Connection::connect_server(&host, port, Duration::from_secs(10)) - } - .map_err(|e| e.to_string())?; + let c = connect_tcp(&host, port, address.starts_with(FRIEND), READ_TIMEOUT)?; (host, port, c) }; *shared_counters.lock().unwrap() = Some(c.reader.counters.clone()); - let e = |e: horizon_protocol::Error| e.to_string(); + let e = |e: horizon_protocol::Error| connection_lost(e, lang); c.send( &handshake::Serverbound::Intention(handshake::Intention { protocol: PROTOCOL_VERSION, @@ -578,7 +618,7 @@ fn receive_play( resources: &Path, ) -> Result { loop { - let (id, payload) = reader.recv().map_err(|e| e.to_string())?; + let (id, payload) = reader.recv().map_err(|e| connection_lost(e, lang))?; let packet = match play::Clientbound::decode(id, &payload) { Ok(p) => p, Err(err) => { @@ -673,13 +713,13 @@ fn configure( let mut registries = Dynamic::default(); let reply = |p: cfg::Serverbound| -> Result<(), String> { match writer.lock().unwrap().as_mut() { - Some(w) => w.send(&p.encode()).map_err(|e| e.to_string()), + Some(w) => w.send(&p.encode()).map_err(|e| connection_lost(e, lang)), None => Err("Disconnected".into()), } }; loop { - let (id, payload) = reader.recv().map_err(|e| e.to_string())?; - match cfg::Clientbound::decode(id, &payload).map_err(|e| e.to_string())? { + let (id, payload) = reader.recv().map_err(|e| connection_lost(e, lang))?; + match cfg::Clientbound::decode(id, &payload).map_err(|e| connection_lost(e, lang))? { cfg::Clientbound::KeepAlive(k) => reply(cfg::Serverbound::KeepAlive(k))?, cfg::Clientbound::CustomPayload(p) if p.channel == crate::content_identity::CHANNEL => { let hash = crate::content_identity::identity(resources) @@ -741,7 +781,7 @@ fn configure( let mut w = writer.lock().unwrap(); let w = w.as_mut().ok_or("Disconnected")?; w.send(&cfg::Serverbound::FinishConfiguration(Empty).encode()) - .map_err(|e| e.to_string())?; + .map_err(|e| connection_lost(e, lang))?; configuring.store(false, Ordering::Relaxed); return Ok(registries); } @@ -859,6 +899,62 @@ pub fn ping(address: &str, timeout: Duration) -> Result<(serde_json::Value, u128 mod tests { use super::*; + /// The en_us text of the disconnect reasons. + fn disconnect_language() -> Language { + [ + ("disconnect.timeout", "Timed out"), + ("disconnect.endOfStream", "End of stream"), + ("disconnect.genericReason", "%s"), + ] + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect() + } + + #[test] + fn a_server_that_stops_answering_times_out() { + // The server accepts and then sends nothing, like a frozen server + // or a network gone without a reset. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let held = std::thread::spawn(move || listener.accept().map(|(stream, _)| stream)); + let c = connect_tcp("127.0.0.1", port, true, Duration::from_millis(300)).unwrap(); + let _server = held.join().unwrap().unwrap(); + let (mut reader, writer) = c.split(); + let (done, result) = channel(); + std::thread::spawn(move || { + let (send, _events) = channel(); + let _ = done.send(receive_play( + &mut reader, + &Mutex::new(Some(writer)), + &AtomicBool::new(false), + &disconnect_language(), + &send, + &Cookies::default(), + Path::new("."), + )); + }); + let ended = result + .recv_timeout(Duration::from_secs(10)) + .expect("the connection never timed out"); + assert_eq!(ended, Err("Timed out".to_owned())); + } + + #[test] + fn lost_connections_read_like_vanillas_disconnect_reasons() { + let lang = disconnect_language(); + let lost = |e| connection_lost(e, &lang); + assert_eq!(lost(horizon_protocol::Error::timed_out()), "Timed out"); + assert_eq!( + lost(horizon_protocol::Error::end_of_stream()), + "End of stream" + ); + assert_eq!( + lost(horizon_protocol::Error::new("Bad VarInt")), + "Internal Exception: Bad VarInt" + ); + } + #[test] fn configuration_answers_the_content_identity_challenge() { let root = diff --git a/crates/horizon-client/src/projectiles.rs b/crates/horizon-client/src/projectiles.rs index f9c01a92b3..a927e1b521 100644 --- a/crates/horizon-client/src/projectiles.rs +++ b/crates/horizon-client/src/projectiles.rs @@ -129,6 +129,10 @@ pub struct Projectile { /// `Entity.needsSync`: a server tells its clients about the change this /// tick instead of at the next update interval. pub needs_sync: bool, + /// `Entity.wasTouchingWater`: what the last tick's + /// `updateFluidInteraction` found around the box after the move, read by + /// the next tick's inertia (false until the first tick has moved it). + pub touching_water: bool, } /// What a projectile struck this tick. #[derive(Clone, Copy, Debug, PartialEq)] @@ -148,7 +152,9 @@ pub enum Hit { /// the boxes of entities it could hit. pub trait ProjectileWorld { fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])>; - fn in_water(&self, at: Vec3) -> bool; + /// `Entity.isInWater` as `updateFluidInteraction` sets it: some water + /// block's surface reaches the bottom of the box (deflated by 0.001). + fn in_water(&self, bounds: Aabb) -> bool; fn targets(&self) -> &[(Who, Aabb)]; /// `Level.isEmptyBlock`. fn empty(&self, _p: [i32; 3]) -> bool { @@ -176,8 +182,26 @@ fn axis_of(d: u8) -> u8 { } } impl Projectile { + /// `Entity.getBoundingBox`: its type's square box standing on its position. + pub fn bounds(&self) -> Aabb { + let (p, half) = (self.position, self.kind.size() / 2.0); + let height = self.kind.size(); + Aabb::new( + Vec3::new(p.x - half, p.y, p.z - half), + Vec3::new(p.x + half, p.y + height, p.z + half), + ) + } pub fn new(kind: ProjectileKind, position: Vec3, owner: Option) -> Self { let kind_is_arrow = matches!(kind, ProjectileKind::Arrow { .. } | ProjectileKind::Trident); + // `AbstractWindCharge`'s constructors set it to 0. + let acceleration = if matches!( + kind, + ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge + ) { + 0.0 + } else { + 0.1 + }; Self { id: 0, kind, @@ -192,8 +216,9 @@ impl Projectile { stuck: false, left_owner: false, bonus: Default::default(), - acceleration: 0.1, + acceleration, needs_sync: false, + touching_water: false, arrow: kind_is_arrow.then(|| { // Entity.random: seeded unpredictably, the position stands in. Box::new(crate::arrows::ArrowState::new( @@ -649,6 +674,8 @@ impl Projectile { // WitherSkull.getInertia: 0.73 for dangerous skulls. let inertia = match self.kind { ProjectileKind::WitherSkull { dangerous: true } => 0.73, + // AbstractWindCharge.getInertia and getLiquidInertia. + ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => 1.0, _ if water => 0.8, _ => 0.95, }; @@ -668,16 +695,24 @@ impl Projectile { if matches!(self.kind, ProjectileKind::ShulkerBullet { .. }) { return self.tick_bullet(w); } - let water = w.in_water(self.position); + // ThrowableProjectile and AbstractHurtingProjectile.applyInertia read + // `isInWater` before their move and `Projectile.tick`'s base tick; + // LlamaSpit.tick runs that base tick first, at the spit's box here. + let water = if self.kind == ProjectileKind::LlamaSpit { + w.in_water(self.bounds()) + } else { + self.touching_water + }; match self.kind { ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } - | ProjectileKind::WitherSkull { .. } => { + | ProjectileKind::WitherSkull { .. } + | ProjectileKind::WindCharge + | ProjectileKind::BreezeWindCharge => { self.apply_hurting_inertia(water); } // Arrows and tridents tick with `arrows::tick`. ProjectileKind::Arrow { .. } | ProjectileKind::Trident => {} - ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge => {} // LlamaSpit.tick: gone in water; drag and gravity follow the move. ProjectileKind::LlamaSpit => { if water { @@ -765,6 +800,8 @@ impl Projectile { if !self.is_fireball() { self.update_rotation(); } + // Entity.baseTick's updateFluidInteraction, after the move. + self.touching_water = w.in_water(self.bounds()); let alive = self.position.y > -128.0 && self.age < if self.is_fireball() { 1200 } else { 6000 }; (alive, Hit::None) @@ -841,7 +878,7 @@ mod tests { fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> { None } - fn in_water(&self, _: Vec3) -> bool { + fn in_water(&self, _: Aabb) -> bool { false } fn targets(&self) -> &[(Who, Aabb)] { @@ -914,6 +951,18 @@ mod tests { assert!((p.position.x - 0.285).abs() < 1e-9); } + #[test] + fn a_wind_charge_holds_its_speed_until_a_deflection_gives_it_power() { + let mut p = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None); + p.velocity = Vec3::new(0.0, 1.5, 0.0); + p.apply_hurting_inertia(false); + assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0)); + // Inertia 1 in water too, and the power adds along the movement. + p.acceleration = 0.1; + p.apply_hurting_inertia(true); + assert!((p.velocity.y - 1.6).abs() < 1e-12, "{p:?}"); + } + #[test] fn ender_pearl_hits_a_wind_charge_box() { let mut pearl = Projectile::new( diff --git a/crates/horizon-client/src/save.rs b/crates/horizon-client/src/save.rs index 829e05ea63..12c37ceb54 100644 --- a/crates/horizon-client/src/save.rs +++ b/crates/horizon-client/src/save.rs @@ -2374,12 +2374,58 @@ impl Drop for SaveDir { } pub fn read_dat(path: &Path) -> Option { - let bytes = fs::read(path).ok()?; + try_read_dat(path).ok() +} +/// `read_dat`, with why the file did not read. +fn try_read_dat(path: &Path) -> std::result::Result { + let bytes = fs::read(path).map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => DatIssue::Missing, + _ => DatIssue::Unreadable(e.to_string()), + })?; let raw = match Compression::Gzip.decode(&bytes, Limits::saved().max_bytes) { Ok(raw) => raw, Err(_) => bytes, }; - Some(nbt::read_named(&raw, Limits::saved()).ok()?.0.tag) + nbt::read_named(&raw, Limits::saved()) + .map(|(named, _)| named.tag) + .map_err(|e| DatIssue::Unreadable(e.to_string())) +} +/// What `LevelStorageAccess.collectIssues` finds wrong with a level.dat. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DatIssue { + /// No such file (`FileNotFoundException`). + Missing, + /// It exists but does not read as a level: why. + Unreadable(String), +} +/// `LevelStorageAccess.collectIssues`: why level.dat (level.dat_old with +/// `old`) can't be read as the world's level data, `None` when it can. +pub fn level_dat_issue(dir: &Path, old: bool) -> Option { + let file = if old { "level.dat_old" } else { "level.dat" }; + match try_read_dat(&dir.join(file)) { + Ok(root) if matches!(root.get("Data"), Some(Tag::Compound(_))) => None, + Ok(_) => Some(DatIssue::Unreadable("Invalid root tag".into())), + Err(issue) => Some(issue), + } +} +/// `LevelStorageAccess.restoreLevelDataFromOld` (`Util.safeReplaceOrMoveFile`): +/// level.dat moves aside to `level.dat_corrupted_