diff --git a/Cargo.lock b/Cargo.lock index 4aebb4a47d..1eb02f73f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -329,6 +329,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1473,6 +1479,7 @@ dependencies = [ "horizon-theme", "horizon-threads", "horizon-world", + "image-webp", "log", "lz4_flex", "miniz_oxide 0.8.9", @@ -1491,6 +1498,7 @@ dependencies = [ "wgpu", "winit", "zip", + "zune-jpeg", ] [[package]] @@ -1667,6 +1675,7 @@ dependencies = [ "horizon-core", "horizon-world", "ipconfig", + "log", "serde_json", "sha1", "sha2", @@ -1936,6 +1945,16 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -3157,6 +3176,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -5337,3 +5362,18 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/crates/horizon-client-render/Cargo.toml b/crates/horizon-client-render/Cargo.toml index b20eef9a1d..3ffa1d9a0f 100644 --- a/crates/horizon-client-render/Cargo.toml +++ b/crates/horizon-client-render/Cargo.toml @@ -43,6 +43,10 @@ miniz_oxide = "0.8" naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] } half = "2" png = "0.18" +# Project icons served by Modrinth are often WebP or JPEG rather than PNG, and +# the pack browser showed nothing for those. Both are small pure-Rust decoders. +image-webp = { version = "0.2", default-features = false } +zune-jpeg = { version = "0.4", default-features = false, features = ["std"] } pollster = "0.4" serde = { version = "1", features = ["derive"] } serde_json = { version = "1.0.151", features = ["raw_value"] } diff --git a/crates/horizon-client-render/src/extras.rs b/crates/horizon-client-render/src/extras.rs index d04ba4ab94..f85f79a787 100644 --- a/crates/horizon-client-render/src/extras.rs +++ b/crates/horizon-client-render/src/extras.rs @@ -60,6 +60,10 @@ impl Corner { #[derive(Clone, Debug, PartialEq)] pub struct Extras { + /// The Select Resource Packs screen's Add button, which browses + /// Modrinth. Off by default: it is Horizon's own and the screen is + /// otherwise vanilla's (tools/test_pack_screen_parity.py). + pub pack_browser: bool, pub fullbright: bool, /// Held, dropped and burning light sources light their surroundings (`dynamic_light`). pub dynamic_lights: bool, @@ -154,6 +158,7 @@ pub struct Extras { impl Default for Extras { fn default() -> Self { Self { + pack_browser: false, fullbright: false, dynamic_lights: false, zoom: false, @@ -222,6 +227,7 @@ impl Extras { _ => d, }; match key.trim() { + "packBrowser" => e.pack_browser = flag(e.pack_browser), "fullbright" => e.fullbright = flag(e.fullbright), "dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights), "zoom" => e.zoom = flag(e.zoom), @@ -300,7 +306,8 @@ impl Extras { } pub fn serialize(&self) -> String { format!( - "fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\n", + "packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\n", + bool_text(self.pack_browser), bool_text(self.fullbright), bool_text(self.dynamic_lights), bool_text(self.zoom), @@ -496,6 +503,8 @@ pub enum Item { /// On the Splitscreen settings page. Splitscreen, WheelCyclesSettings, + /// The Select Resource Packs screen's Add button (Modrinth). + PackBrowser, /// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`). CosmeticsMine, CosmeticsOthers, @@ -526,6 +535,7 @@ pub fn rows() -> Vec> { Ok(MusicPage), Err("horizon.extras.header.menus"), Ok(WheelCyclesSettings), + Ok(PackBrowser), ]; if horizon_cosmetics::enabled() { rows.extend([ @@ -559,6 +569,7 @@ impl Item { Item::ChatAnimation => "horizon.extras.chat_animation", Item::ChatIndicator => "horizon.extras.chat_indicator", Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings", + Item::PackBrowser => "horizon.extras.pack_browser", Item::CosmeticsMine => "horizon.extras.cosmetics_mine", Item::CosmeticsOthers => "horizon.extras.cosmetics_others", } @@ -585,6 +596,7 @@ impl Item { Item::ChatAnimation => "horizon.extras.chat_animation.tooltip", Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip", Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip", + Item::PackBrowser => "horizon.extras.pack_browser.tooltip", Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip", Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip", } @@ -620,6 +632,7 @@ impl Item { Item::DiscordPresence => "Discord Rich Presence", Item::Splitscreen => "Splitscreen", Item::WheelCyclesSettings => "Scroll wheel changes settings", + Item::PackBrowser => "Resource pack browser", Item::CosmeticsMine => "My cosmetics", Item::CosmeticsOthers => "Other players' cosmetics", } @@ -634,7 +647,8 @@ impl Item { ChatIndicator => ChatAnimation, Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort | AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen - | ChatAnimation | WheelCyclesSettings | MusicPage | CosmeticsMine | CosmeticsOthers => { + | ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine + | CosmeticsOthers => { return None; } }) @@ -701,6 +715,9 @@ impl Item { Item::WheelCyclesSettings => { "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list." } + Item::PackBrowser => { + "Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth." + } } } /// The frame-time cost the main switch of an extra shows. @@ -718,7 +735,8 @@ impl Item { | Item::MouseTweaks | Item::Sort | Item::Splitscreen - | Item::WheelCyclesSettings => Some(Cost::None), + | Item::WheelCyclesSettings + | Item::PackBrowser => Some(Cost::None), Item::CosmeticsMine | Item::CosmeticsOthers => Some(Cost::Low), _ => None, } @@ -744,6 +762,7 @@ impl Item { Item::DiscordPresence => e.discord_presence, Item::Splitscreen => e.splitscreen, Item::WheelCyclesSettings => e.wheel_cycles_settings, + Item::PackBrowser => e.pack_browser, Item::CosmeticsMine => e.cosmetics_mine, Item::CosmeticsOthers => e.cosmetics_others, _ => return None, @@ -781,6 +800,7 @@ impl Item { Item::DiscordPresence => e.discord_presence = !e.discord_presence, Item::Splitscreen => e.splitscreen = !e.splitscreen, Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings, + Item::PackBrowser => e.pack_browser = !e.pack_browser, Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine, Item::CosmeticsOthers => e.cosmetics_others = !e.cosmetics_others, Item::SortKey => {} diff --git a/crates/horizon-client-render/src/gfx/pack_uniforms.rs b/crates/horizon-client-render/src/gfx/pack_uniforms.rs index 6f50755c42..f8bd6f2be7 100644 --- a/crates/horizon-client-render/src/gfx/pack_uniforms.rs +++ b/crates/horizon-client-render/src/gfx/pack_uniforms.rs @@ -947,6 +947,218 @@ mod initial_history_tests { assert!(float(&u, "wetness")[0] > 0.0); assert!(u.eye_smooth.iter().all(|v| *v > 0.0)); } + /// Actual provider bytes through translated GLSL and a GPU pixel, with a nonidentity + /// model-view/projection and a distant camera. Geometry is already camera-relative. + #[test] + #[ignore = "requires a host GPU; run with --ignored --nocapture"] + fn core_matrix_inputs_render_provider_values() { + use horizon_shaders::{ + GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers, + }; + use std::io::Write; + use wgpu::util::DeviceExt; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap(); + eprintln!("Core matrix GPU: {:?}", adapter.get_info()); + let (device, queue) = + pollster::block_on(adapter.request_device(&Default::default())).unwrap(); + let hud = HudBatch::new([800, 400]); + let mut f = frame(&hud); + f.camera = DVec3::new(1024.25, 100.5, -2048.75); + f.yaw = 180.0; + f.pitch = 0.0; + f.fov_degrees = 90.0; + f.bob = Mat4::from_translation(Vec3::new(0.5, -0.25, 0.0)); + let mut values = FrameUniforms::default(); + values.update(&f, &PackInputs::default(), [800, 400]); + for (declaration, setup, projection, view, expected) in [ + ( + "", + "", + "projectionMatrix", + "modelViewMatrix", + [64u8, 64, 128, 255], + ), + ( + "uniform mat4 projectionMatrix, modelViewMatrix;", + "", + "projectionMatrix", + "modelViewMatrix", + [64, 64, 128, 255], + ), + ( + "", + "", + "gl_ProjectionMatrix", + "gl_ModelViewMatrix", + [64, 64, 128, 255], + ), + ( + "", + "mat4 projectionMatrix = mat4(2.0); mat4 modelViewMatrix = mat4(3.0);", + "projectionMatrix", + "modelViewMatrix", + [128, 191, 255, 255], + ), + ( + "mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);", + "", + "projectionMatrix", + "modelViewMatrix", + [128, 191, 255, 255], + ), + ] { + let vertex = format!( + "#version 330 compatibility\n{declaration}\nout vec3 result;\n\ + void main() {{\n{setup}\n\ + vec4 q = {projection} * {view} * vec4(0.5, 0.75, -2.0, 1.0);\n\ + result = vec3(q.xy / q.w, q.w / 4.0);\n\ + vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\ + gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}" + ); + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + for (path, source) in [ + ("shaders/gbuffers_line.vsh", vertex.as_str()), + ( + "shaders/gbuffers_line.fsh", + "#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }", + ), + ] { + zip.start_file(path, zip::write::SimpleFileOptions::default()) + .unwrap(); + zip.write_all(source.as_bytes()).unwrap(); + } + let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner()) + .unwrap(); + let macros = Macros::standard(); + let program = translate_gbuffers( + &preprocess(&pack, "gbuffers_line.vsh", ¯os), + &preprocess(&pack, "gbuffers_line.fsh", ¯os), + Target::Buffers, + &GbuffersInputs::default(), + ) + .unwrap(); + let mut bytes = vec![0; program.interface.uniform_size as usize]; + for uniform in &program.interface.uniforms { + if let Some(i) = index(&uniform.name) { + values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize); + } + } + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: None, + contents: &bytes, + usage: wgpu::BufferUsages::UNIFORM, + }); + let module = |module| { + device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)), + }) + }; + let vs = module(program.vertex); + let fs = module(program.fragment); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { + module: &vs, + entry_point: Some("main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: Default::default(), + depth_stencil: None, + multisample: Default::default(), + fragment: Some(wgpu::FragmentState { + module: &fs, + entry_point: Some("main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + let group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&Default::default()); + let read = device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 256, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: None, + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &group, &[]); + pass.draw(0..3, 0..1); + } + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &read, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256), + rows_per_image: None, + }, + }, + texture.size(), + ); + queue.submit([encoder.finish()]); + read.slice(..) + .map_async(wgpu::MapMode::Read, |result| result.unwrap()); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + let mapped = read.slice(..).get_mapped_range().unwrap(); + // Independent result: translated point (1, .5, -2), perspective (aspect 2, + // vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1). + for (actual, expected) in mapped[..4].iter().zip(expected) { + assert!( + actual.abs_diff(expected) <= 1, + "{projection}: {:?}", + &mapped[..4] + ); + } + } + } + fn frame(hud: &HudBatch) -> Frame<'_> { Frame { eye: glam::DVec3::ZERO, diff --git a/crates/horizon-client-render/src/hud.rs b/crates/horizon-client-render/src/hud.rs index b539bbde6b..7405962557 100644 --- a/crates/horizon-client-render/src/hud.rs +++ b/crates/horizon-client-render/src/hud.rs @@ -145,6 +145,118 @@ pub const TRIM_SLOTS: usize = 32; pub fn decode(path: &Path) -> Result<(u32, u32, Vec)> { decode_bytes(&horizon_core::prepared_files::read(path)?) } +/// The image formats [`decode_image_bytes`] reads, told apart by their own +/// leading bytes rather than by a file name: what a server sends has no name, +/// and a name would not be trustworthy anyway. +/// +/// The game's own resources are PNG throughout, and so are a resource pack's +/// (vanilla's `NativeImage.read` takes PNG only), so [`decode_bytes`] stays +/// PNG-only. WebP and JPEG are for images fetched from the network (Modrinth +/// project icons in the resource pack browser), which are commonly either. +enum ImageFormat { + Png, + /// `RIFF....WEBP`. + WebP, + /// Starts with the SOI marker `FF D8`. + Jpeg, +} + +impl ImageFormat { + /// The format `data` begins with, or `None` for anything else. + fn sniff(data: &[u8]) -> Option { + const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + if data.starts_with(&PNG_MAGIC) { + return Some(Self::Png); + } + if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { + return Some(Self::WebP); + } + if data.starts_with(&[0xFF, 0xD8]) { + return Some(Self::Jpeg); + } + None + } +} + +/// The most pixels a fetched image may claim: its header alone sizes the +/// buffer, and a hostile one could ask for gigabytes. 4096 x 4096 RGBA is 64 MiB. +const MAX_FETCHED_PIXELS: u64 = 4096 * 4096; + +fn check_fetched_size(width: u64, height: u64) -> Result<()> { + if width * height > MAX_FETCHED_PIXELS { + return Err(format!("a {width}x{height} image is too large to decode").into()); + } + Ok(()) +} + +/// A WebP image as RGBA8. Still frames and animations both decode; an +/// animation gives its first frame, as an animated PNG would. +fn decode_webp(data: &[u8]) -> Result<(u32, u32, Vec)> { + let mut decoder = + image_webp::WebPDecoder::new(std::io::Cursor::new(data)).map_err(|e| e.to_string())?; + let (width, height) = decoder.dimensions(); + check_fetched_size(u64::from(width), u64::from(height))?; + // Four bytes a pixel whether or not the image carries alpha: the caller + // always wants RGBA. + let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4]; + if decoder.has_alpha() { + decoder.read_image(&mut rgba).map_err(|e| e.to_string())?; + } else { + let mut rgb = vec![0u8; (width as usize) * (height as usize) * 3]; + decoder.read_image(&mut rgb).map_err(|e| e.to_string())?; + for (out, pixel) in rgba + .as_chunks_mut::<4>() + .0 + .iter_mut() + .zip(rgb.as_chunks::<3>().0) + { + out.copy_from_slice(&[pixel[0], pixel[1], pixel[2], 255]); + } + } + Ok((width, height, rgba)) +} + +/// A JPEG image as RGBA8. JPEG has no alpha, so every pixel is opaque. +fn decode_jpeg(data: &[u8]) -> Result<(u32, u32, Vec)> { + let mut decoder = zune_jpeg::JpegDecoder::new(data); + // The size comes from the headers, before any pixel buffer is made. + decoder.decode_headers().map_err(|e| e.to_string())?; + let info = decoder.info().ok_or("the JPEG states no size")?; + let (width, height) = (u32::from(info.width), u32::from(info.height)); + check_fetched_size(u64::from(width), u64::from(height))?; + let pixels = decoder.decode().map_err(|e| e.to_string())?; + let count = (width as usize) * (height as usize); + // The decoder gives RGB for a colour image and one byte a pixel for a + // greyscale one; both become RGBA here. + let channels = pixels.len().checked_div(count).unwrap_or(0); + let mut rgba = Vec::with_capacity(count * 4); + match channels { + 3 => { + for p in pixels.as_chunks::<3>().0 { + rgba.extend_from_slice(&[p[0], p[1], p[2], 255]); + } + } + 1 => { + for p in &pixels { + rgba.extend_from_slice(&[*p, *p, *p, 255]); + } + } + _ => return Err("the JPEG decoded to an unexpected channel count".into()), + } + Ok((width, height, rgba)) +} + +/// An image fetched from the network (PNG, WebP or JPEG) as RGBA pixels. WebP +/// and JPEG go to their own decoders; anything else goes to the PNG reader, so +/// its error is the one reported. +pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec)> { + match ImageFormat::sniff(data) { + Some(ImageFormat::WebP) => decode_webp(data), + Some(ImageFormat::Jpeg) => decode_jpeg(data), + Some(ImageFormat::Png) | None => decode_bytes(data), + } +} + /// A PNG in memory as RGBA pixels. pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec)> { let mut decoder = png::Decoder::new(std::io::Cursor::new(data)); @@ -2722,6 +2834,43 @@ mod tests { let _ = fs::remove_dir_all(&root); } + #[test] + fn image_formats_are_told_apart_by_their_leading_bytes() { + let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0]; + assert!(matches!(ImageFormat::sniff(&png), Some(ImageFormat::Png))); + // RIFF with a WEBP tag at byte 8; RIFF alone is not enough (WAV is + // RIFF too). + let mut webp = b"RIFFWEBPVP8 ".to_vec(); + assert!(matches!(ImageFormat::sniff(&webp), Some(ImageFormat::WebP))); + webp[8..12].copy_from_slice(b"WAVE"); + assert!(ImageFormat::sniff(&webp).is_none()); + assert!(matches!( + ImageFormat::sniff(&[0xFF, 0xD8, 0xFF, 0xE0]), + Some(ImageFormat::Jpeg) + )); + // Too short to be any of them, and plainly something else. + assert!(ImageFormat::sniff(b"RIFF").is_none()); + assert!(ImageFormat::sniff(b"").is_none()); + assert!(ImageFormat::sniff(b"").is_none()); + } + + #[test] + fn a_fetched_image_may_not_claim_more_than_4096_squared() { + assert!(check_fetched_size(4096, 4096).is_ok()); + assert!(check_fetched_size(4097, 4096).is_err()); + assert!(check_fetched_size(16_383, 16_383).is_err()); + } + + /// Only the network path reads WebP and JPEG: a resource pack's texture + /// stays PNG-only, as vanilla's `NativeImage.read` is. + #[test] + fn decode_bytes_stays_png_only() { + let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0, 0x10]; + assert!(decode_bytes(&jpeg).is_err()); + let webp = b"RIFF\x00\x00\x00\x00WEBPVP8 "; + assert!(decode_bytes(webp).is_err()); + } + #[test] fn decode_bytes_ignores_crc_checksum_error() { let mut encoded = Vec::new(); diff --git a/crates/horizon-client-render/src/lib.rs b/crates/horizon-client-render/src/lib.rs index a270344bc8..d259382b19 100644 --- a/crates/horizon-client-render/src/lib.rs +++ b/crates/horizon-client-render/src/lib.rs @@ -71,6 +71,7 @@ pub mod options_file; pub mod options_fixes; pub mod options_ui; pub mod overlay; +pub mod pack_browser; pub mod pack_screen; pub mod packs; pub mod par; diff --git a/crates/horizon-client-render/src/options_ui.rs b/crates/horizon-client-render/src/options_ui.rs index 0f4d5df7c5..e49c86de6c 100644 --- a/crates/horizon-client-render/src/options_ui.rs +++ b/crates/horizon-client-render/src/options_ui.rs @@ -1405,6 +1405,9 @@ fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool { pub enum Event { /// An accepted button press or the release of a slider. ButtonClick, + /// Horizon's own: download the Modrinth hit at this index of the pack + /// screen's browser. The game owns the packs folder, not this screen. + DownloadPack(usize), /// Close the options screens (back to the pause menu or title). Close, /// Settings changed: apply and save them. @@ -1523,6 +1526,8 @@ pub struct OptionsUi { language_choice: Option, language_search: ui::EditBox, pub(crate) settings_search: ui::EditBox, + /// Horizon's `packBrowser` extra: the pack screen's Add button. + pub pack_browser: bool, /// Packs: the packs found and the selection the screen opens with. pub pack_infos: Vec, pub pack_selection: Vec, @@ -1601,6 +1606,7 @@ impl Default for OptionsUi { language_choice: None, language_search: search, settings_search, + pack_browser: false, pack_infos: Vec::new(), pack_selection: Vec::new(), pack_screen: None, @@ -3931,6 +3937,9 @@ impl OptionsUi { None => Event::OpenPackFolder, }), crate::pack_screen::Action::Done => events.extend(self.back(s)), + crate::pack_screen::Action::BrowsePick(i) => { + events.push(Event::DownloadPack(i)); + } crate::pack_screen::Action::None => {} } return events; @@ -4485,6 +4494,7 @@ impl OptionsUi { self.pack_infos.clone(), &self.pack_selection, ); + screen.browsing_allowed = self.pack_browser; screen.changed = std::mem::take(&mut self.pack_reselected); self.pack_screen = Some(screen); } diff --git a/crates/horizon-client-render/src/pack_browser.rs b/crates/horizon-client-render/src/pack_browser.rs new file mode 100644 index 0000000000..78ee00d32e --- /dev/null +++ b/crates/horizon-client-render/src/pack_browser.rs @@ -0,0 +1,639 @@ +//! Browsing Modrinth for resource packs from the Select Resource Packs +//! screen, so a pack can be added without leaving the game. +//! +//! The launcher has its own browser (`launcher/src/browser/`), but the +//! launcher is a separate workspace: nothing there is reachable from the +//! game, so this is the small client the screen needs. Search and download +//! both run on a worker thread and report back over a channel; the screen +//! only ever polls. +use std::{ + path::{Path, PathBuf}, + sync::mpsc::{Receiver, Sender, channel}, +}; + +/// Modrinth asks every client to name itself. +const AGENT: &str = concat!( + "horizon-rust/horizon/", + env!("CARGO_PKG_VERSION"), + " (in-game resource pack browser)" +); +const API: &str = "https://api.modrinth.com/v2"; +/// Hits one search asks for. The list shows them all; Modrinth's own cap is +/// 100. +pub const LIMIT: usize = 20; + +/// One search hit, as much of it as the list draws. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hit { + pub id: String, + pub title: String, + pub author: String, + pub description: String, + pub downloads: u64, + /// Modrinth's icon for the project, empty when it has none. + pub icon_url: String, +} + +/// What the worker sends back. +#[derive(Debug)] +pub enum Message { + Results(Vec), + /// A project's icon, as the PNG bytes Modrinth served. + Icon(String, Vec), + /// A pack landed in the packs folder under this file name. + Downloaded(String), + /// A technical reason in English: the screen shows it inside + /// `horizon.pack.addFailed`. + Failed(String), +} + +/// The filters the sidebar offers: the label's translation key, and the +/// Modrinth facet it adds. Facets of one group are OR-ed, groups AND-ed, so +/// each group is its own inner list. +pub const FILTERS: [(&str, &str, &str); 6] = [ + ("horizon.pack.filter.realistic", "categories", "realistic"), + ("horizon.pack.filter.simplistic", "categories", "simplistic"), + ( + "horizon.pack.filter.vanillaLike", + "categories", + "vanilla-like", + ), + ("horizon.pack.filter.x16", "resolutions", "16x"), + ("horizon.pack.filter.x32", "resolutions", "32x"), + ("horizon.pack.filter.x64", "resolutions", "64x"), +]; + +/// The search URL for `query` with the chosen filters (indices into +/// [`FILTERS`]), restricted to resource packs. An empty query is Modrinth's +/// own browse (its default ordering). +pub fn search_url(query: &str, chosen: &std::collections::BTreeSet) -> String { + // One inner list per group: within a group Modrinth ORs, between groups + // it ANDs, so picking 16x and 32x widens rather than excluding both. + let mut groups: Vec<(&str, Vec)> = Vec::new(); + for i in chosen.iter().filter(|i| **i < FILTERS.len()) { + let (_, group, value) = FILTERS[*i]; + let term = format!("\"{group}:{value}\""); + match groups.iter_mut().find(|(g, _)| *g == group) { + Some((_, terms)) => terms.push(term), + None => groups.push((group, vec![term])), + } + } + let mut facets = String::from("[[\"project_type:resourcepack\"]]"); + if !groups.is_empty() { + let extra: Vec = groups + .iter() + .map(|(_, terms)| format!("[{}]", terms.join(","))) + .collect(); + facets = format!("[[\"project_type:resourcepack\"],{}]", extra.join(",")); + } + format!( + "{API}/search?limit={LIMIT}&index=relevance&facets={}&query={}", + urlencode(&facets), + urlencode(query.trim()) + ) +} + +/// The versions of `project` that name this game version, newest first as +/// Modrinth returns them. +pub fn versions_url(project: &str, game_version: &str) -> String { + format!( + "{API}/project/{}/version?game_versions={}", + urlencode(project), + urlencode(&format!("[\"{game_version}\"]")) + ) +} + +/// Percent-encoding for a query value: everything but the unreserved set. +fn urlencode(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for byte in text.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*byte as char); + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +/// The hits of a `/search` reply. A hit missing the fields the list draws is +/// left out rather than drawn blank. +pub fn parse_search(body: &str) -> Result, String> { + let value: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?; + let hits = value + .get("hits") + .and_then(|h| h.as_array()) + .ok_or("the reply has no hits")?; + Ok(hits + .iter() + .filter_map(|hit| { + Some(Hit { + id: hit.get("project_id")?.as_str()?.to_owned(), + title: hit.get("title")?.as_str()?.to_owned(), + author: hit + .get("author") + .and_then(|a| a.as_str()) + .unwrap_or_default() + .to_owned(), + description: hit + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or_default() + .to_owned(), + downloads: hit.get("downloads").and_then(|d| d.as_u64()).unwrap_or(0), + icon_url: hit + .get("icon_url") + .and_then(|u| u.as_str()) + .unwrap_or_default() + .to_owned(), + }) + }) + .collect()) +} + +/// The primary file of the newest version: its URL, its file name and the +/// SHA-1 the index states for it. +pub fn parse_version_file(body: &str) -> Result<(String, String, Option), String> { + let value: serde_json::Value = serde_json::from_str(body).map_err(|e| e.to_string())?; + let versions = value.as_array().ok_or("the reply is not a version list")?; + for version in versions { + let Some(files) = version.get("files").and_then(|f| f.as_array()) else { + continue; + }; + // `primary` marks the pack itself; some versions ship extras beside it. + let file = files + .iter() + .find(|f| f.get("primary").and_then(|p| p.as_bool()) == Some(true)) + .or_else(|| files.first()); + let Some(file) = file else { continue }; + let (Some(url), Some(name)) = ( + file.get("url").and_then(|u| u.as_str()), + file.get("filename").and_then(|n| n.as_str()), + ) else { + continue; + }; + let sha1 = file + .get("hashes") + .and_then(|h| h.get("sha1")) + .and_then(|h| h.as_str()) + .map(str::to_owned); + return Ok((url.to_owned(), name.to_owned(), sha1)); + } + Err("no version of this pack names this game version".into()) +} + +/// A file name safe to write into the packs folder: Modrinth's own name with +/// every separator and parent reference taken out, and a `.zip` ending. +/// Without this a reply could name a path outside the folder. +pub fn safe_file_name(name: &str) -> String { + let base = name.rsplit(['/', '\\']).next().unwrap_or(name); + let mut cleaned: String = base + .chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | ' ' | '(' | ')' | '[' | ']' => c, + _ => '_', + }) + .collect(); + cleaned = cleaned.trim_matches(['.', ' ']).to_owned(); + if cleaned.is_empty() { + cleaned = "pack".into(); + } + // Windows' device names cannot be files, whatever their extension. + let stem = cleaned.split('.').next().unwrap_or_default().trim_end(); + let device = matches!( + stem.to_ascii_uppercase().as_str(), + "CON" | "PRN" | "AUX" | "NUL" + ) || (stem.len() == 4 + && ["COM", "LPT"] + .iter() + .any(|p| stem[..3].eq_ignore_ascii_case(p)) + && stem.as_bytes()[3].is_ascii_digit()); + if device { + cleaned.insert(0, '_'); + } + if !cleaned.to_ascii_lowercase().ends_with(".zip") { + cleaned.push_str(".zip"); + } + cleaned +} + +/// `name` in `dir`, numbered when something of that name is already there, so +/// a download never replaces a pack the player already has. `None` once every +/// number up to 999 is taken. +pub fn free_path(dir: &Path, name: &str) -> Option { + let path = dir.join(name); + if !path.exists() { + return Some(path); + } + let (stem, extension) = name.rsplit_once('.').unwrap_or((name, "zip")); + (2..1000) + .map(|n| dir.join(format!("{stem} ({n}).{extension}"))) + .find(|candidate| !candidate.exists()) +} + +/// The browser's state, owned by the pack screen. +pub struct Browser { + pub query: String, + pub hits: Vec, + pub selected: Option, + pub scroll: f64, + /// Set while a search or a download is in flight. + pub busy: bool, + /// Already-translated text, shown in place of the list. + pub status: Option, + /// The hit an install is running for. + pub installing: Option, + /// Hits already installed this visit, by index. + pub installed: std::collections::BTreeSet, + /// The sidebar filters that are on, as indices into [`FILTERS`]. + pub filters: std::collections::BTreeSet, + sender: Sender, + receiver: Receiver, +} + +impl std::fmt::Debug for Browser { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Browser") + .field("query", &self.query) + .field("hits", &self.hits.len()) + .field("busy", &self.busy) + .finish() + } +} + +impl Clone for Browser { + /// The screen is cloned for drawing; a clone carries the state, not the + /// worker's channel, and starts its own. + fn clone(&self) -> Self { + let (sender, receiver) = channel(); + Self { + query: self.query.clone(), + hits: self.hits.clone(), + selected: self.selected, + scroll: self.scroll, + busy: self.busy, + status: self.status.clone(), + installing: self.installing, + installed: self.installed.clone(), + filters: self.filters.clone(), + sender, + receiver, + } + } +} + +impl Default for Browser { + fn default() -> Self { + Self::new() + } +} + +impl Browser { + pub fn new() -> Self { + let (sender, receiver) = channel(); + Self { + query: String::new(), + hits: Vec::new(), + selected: None, + scroll: 0.0, + busy: false, + status: None, + installing: None, + installed: Default::default(), + filters: Default::default(), + sender, + receiver, + } + } + + /// Anything the worker has finished since the last call. The caller + /// refreshes its pack list when a [`Message::Downloaded`] comes back. + pub fn poll(&mut self) -> Option { + let message = self.receiver.try_recv().ok()?; + let was_busy = self.busy; + self.busy = false; + match &message { + Message::Results(hits) => { + self.hits = hits.clone(); + self.selected = None; + self.scroll = 0.0; + self.status = None; + } + Message::Downloaded(_) => { + self.status = None; + if let Some(i) = self.installing.take() { + self.installed.insert(i); + } + } + // An icon is the screen's to keep; it does not end a search. + Message::Icon(..) => self.busy = was_busy, + Message::Failed(reason) => { + self.status = Some(reason.clone()); + self.installing = None; + } + } + Some(message) + } + + /// Searches for the current query on a worker thread. + pub fn search(&mut self) { + if self.busy { + return; + } + self.busy = true; + self.status = None; + let url = search_url(&self.query, &self.filters); + let sender = self.sender.clone(); + spawn("pack-browser-search", move || { + let message = match get_text(&url) { + Ok(body) => match parse_search(&body) { + Ok(hits) => Message::Results(hits), + Err(reason) => Message::Failed(reason), + }, + Err(reason) => Message::Failed(reason), + }; + let _ = sender.send(message); + }); + } + + /// Fetches each hit's icon on one worker, so the list fills in as they + /// arrive. Icons are small; a hit without one is skipped. + pub fn fetch_icons(&self) { + let wanted: Vec<(String, String)> = self + .hits + .iter() + .filter(|h| !h.icon_url.is_empty()) + .map(|h| (h.id.clone(), h.icon_url.clone())) + .collect(); + if wanted.is_empty() { + return; + } + let sender = self.sender.clone(); + spawn("pack-browser-icons", move || { + for (id, url) in wanted { + match get_bytes(&url, MAX_ICON_BYTES) { + Ok(bytes) => { + if sender.send(Message::Icon(id, bytes)).is_err() { + return; + } + } + // A missing icon only leaves that row without one. + Err(e) => log::debug!("Horizon: pack icon {id} did not load: {e}"), + } + } + }); + } + + /// Downloads the selected pack into `dir` on a worker thread. + pub fn download(&mut self, index: usize, dir: &Path, game_version: &str) { + if self.busy { + return; + } + let Some(hit) = self.hits.get(index) else { + return; + }; + self.installing = Some(index); + self.busy = true; + self.status = None; + let url = versions_url(&hit.id, game_version); + let dir = dir.to_path_buf(); + let sender = self.sender.clone(); + spawn("pack-browser-download", move || { + let _ = sender.send(download_pack(&url, &dir)); + }); + } +} + +fn spawn(name: &str, work: impl FnOnce() + Send + 'static) { + if let Err(e) = std::thread::Builder::new().name(name.into()).spawn(work) { + log::warn!("Horizon: {name} did not start: {e}"); + } +} + +/// Fetches the version list, then the pack, and writes it into `dir`. +fn download_pack(versions: &str, dir: &Path) -> Message { + let (url, name, sha1) = match get_text(versions).and_then(|body| parse_version_file(&body)) { + Ok(found) => found, + Err(reason) => return Message::Failed(reason), + }; + let body = match get_bytes(&url, MAX_PACK_BYTES) { + Ok(body) => body, + Err(reason) => return Message::Failed(reason), + }; + if let Some(expected) = &sha1 { + let found = sha1_hex(&body); + if !found.eq_ignore_ascii_case(expected) { + return Message::Failed(format!( + "the download did not match its checksum ({found} for {expected})" + )); + } + } + let name = safe_file_name(&name); + if let Err(e) = std::fs::create_dir_all(dir) { + return Message::Failed(e.to_string()); + } + let Some(path) = free_path(dir, &name) else { + return Message::Failed(format!("{name}: every numbered name is taken")); + }; + // Written beside the pack and renamed, so a pack list never sees a + // half-written zip. + let partial = path.with_extension("partial"); + match std::fs::write(&partial, &body).and_then(|()| std::fs::rename(&partial, &path)) { + Ok(()) => Message::Downloaded( + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or(name), + ), + Err(e) => { + let _ = std::fs::remove_file(&partial); + Message::Failed(e.to_string()) + } + } +} + +/// A pack Modrinth serves is capped: a reply past this is refused rather than +/// read into memory. +const MAX_PACK_BYTES: usize = 512 * 1024 * 1024; +/// A project icon is a small image; one past this is not read. +const MAX_ICON_BYTES: usize = 2 * 1024 * 1024; + +/// Only HTTPS: a reply names the URLs fetched next (icons, files), and a plain +/// `http://` one could point anywhere unencrypted, the player's own network +/// included. +fn https_only(url: &str) -> Result<(), String> { + if url.starts_with("https://") { + Ok(()) + } else { + Err(format!("{url} is not an https address")) + } +} + +fn get_text(url: &str) -> Result { + ureq::get(url) + .set("User-Agent", AGENT) + .call() + .map_err(|e| e.to_string())? + .into_string() + .map_err(|e| e.to_string()) +} + +fn get_bytes(url: &str, max: usize) -> Result, String> { + use std::io::Read; + https_only(url)?; + let response = ureq::get(url) + .set("User-Agent", AGENT) + .call() + .map_err(|e| e.to_string())?; + let mut body = Vec::new(); + response + .into_reader() + .take(max as u64 + 1) + .read_to_end(&mut body) + .map_err(|e| e.to_string())?; + if body.len() > max { + return Err(format!("{url} is larger than {max} bytes")); + } + Ok(body) +} + +/// SHA-1 of `bytes` as lowercase hex, for the checksum Modrinth states beside +/// each file (the `sha1` crate the skins already use). +fn sha1_hex(bytes: &[u8]) -> String { + crate::skins::sha1(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sha1_matches_the_published_vectors() { + // FIPS 180-4's own examples, independent of this implementation. + assert_eq!(sha1_hex(b""), "da39a3ee5e6b4b0d3255bfef95601890afd80709"); + assert_eq!(sha1_hex(b"abc"), "a9993e364706816aba3e25717850c26c9cd0d89d"); + assert_eq!( + sha1_hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "84983e441c3bd26ebaae4aa1f95129e5e54670f1" + ); + // Past one block, so the padding and the length word are exercised. + assert_eq!( + sha1_hex(&b"a".repeat(1000)), + "291e9a6c66994949b57ba5e650361e98fc36b1ba" + ); + } + + #[test] + fn search_asks_only_for_resource_packs() { + let none = std::collections::BTreeSet::new(); + let url = search_url("blue", &none); + assert!(url.starts_with("https://api.modrinth.com/v2/search?")); + assert!(url.contains("project_type%3Aresourcepack")); + assert!(url.ends_with("query=blue")); + // A query with its own separators cannot add parameters. + assert!(search_url("a&b=c", &none).ends_with("query=a%26b%3Dc")); + } + + #[test] + fn filters_of_one_group_widen_and_groups_narrow() { + let chosen = |v: &[usize]| v.iter().copied().collect(); + // Two resolutions are one group: Modrinth ORs inside a list. + let both: std::collections::BTreeSet = chosen(&[3, 4]); + let url = search_url("", &both); + assert!(url.contains(&urlencode( + r#"[["project_type:resourcepack"],["resolutions:16x","resolutions:32x"]]"# + ))); + // A category and a resolution are separate lists, so they AND. + let mixed: std::collections::BTreeSet = chosen(&[0, 3]); + let url = search_url("", &mixed); + assert!(url.contains(&urlencode( + r#"[["project_type:resourcepack"],["categories:realistic"],["resolutions:16x"]]"# + ))); + // Out-of-range indices are ignored rather than panicking. + let silly: std::collections::BTreeSet = chosen(&[99]); + assert!(search_url("", &silly).contains(&urlencode(r#"[["project_type:resourcepack"]]"#))); + } + + #[test] + fn versions_are_asked_for_this_game_version() { + let url = versions_url("abc", "26.3"); + assert_eq!( + url, + "https://api.modrinth.com/v2/project/abc/version?game_versions=%5B%2226.3%22%5D" + ); + } + + #[test] + fn search_hits_are_read_and_incomplete_ones_left_out() { + let body = r#"{"hits":[ + {"project_id":"aa","title":"Blue","author":"someone","description":"d","downloads":12}, + {"title":"no id"}, + {"project_id":"bb","title":"Bare"} + ]}"#; + let hits = parse_search(body).unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].id, "aa"); + assert_eq!(hits[0].downloads, 12); + // The fields the list can do without default rather than drop the hit. + assert_eq!(hits[1].id, "bb"); + assert_eq!(hits[1].author, ""); + assert_eq!(hits[1].downloads, 0); + assert!(parse_search("not json").is_err()); + assert!(parse_search(r#"{"total":0}"#).is_err()); + } + + #[test] + fn the_primary_file_of_the_newest_version_wins() { + let body = r#"[ + {"files":[ + {"url":"https://e/x.txt","filename":"x.txt","primary":false}, + {"url":"https://e/p.zip","filename":"p.zip","primary":true, + "hashes":{"sha1":"abc"}} + ]}, + {"files":[{"url":"https://e/old.zip","filename":"old.zip","primary":true}]} + ]"#; + let (url, name, sha1) = parse_version_file(body).unwrap(); + assert_eq!((url.as_str(), name.as_str()), ("https://e/p.zip", "p.zip")); + assert_eq!(sha1.as_deref(), Some("abc")); + // No file marked primary: the first one stands in. + let single = r#"[{"files":[{"url":"https://e/a.zip","filename":"a.zip"}]}]"#; + assert_eq!(parse_version_file(single).unwrap().1, "a.zip"); + assert!(parse_version_file("[]").is_err()); + } + + #[test] + fn a_served_name_cannot_leave_the_packs_folder() { + assert_eq!(safe_file_name("../../evil.zip"), "evil.zip"); + assert_eq!(safe_file_name("a/b/c.zip"), "c.zip"); + assert_eq!(safe_file_name(r"a\b\c.zip"), "c.zip"); + assert_eq!(safe_file_name("pack"), "pack.zip"); + assert_eq!(safe_file_name(""), "pack.zip"); + assert_eq!(safe_file_name("..."), "pack.zip"); + assert_eq!(safe_file_name("Nice Pack (v2).zip"), "Nice Pack (v2).zip"); + // Keeps its ending whatever its case, rather than gaining a second one. + assert_eq!(safe_file_name("Pack.ZIP"), "Pack.ZIP"); + // Windows' device names get a prefix; a longer stem keeps its name. + assert_eq!(safe_file_name("CON"), "_CON.zip"); + assert_eq!(safe_file_name("aux.zip"), "_aux.zip"); + assert_eq!(safe_file_name("com1.tar.zip"), "_com1.tar.zip"); + assert_eq!(safe_file_name("console.zip"), "console.zip"); + assert_eq!(safe_file_name("COM10.zip"), "COM10.zip"); + } + + #[test] + fn a_download_never_replaces_a_pack_already_there() { + let dir = std::env::temp_dir().join(format!("horizon-browser-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let _ = std::fs::remove_file(dir.join("p.zip")); + let _ = std::fs::remove_file(dir.join("p (2).zip")); + assert_eq!(free_path(&dir, "p.zip"), Some(dir.join("p.zip"))); + std::fs::write(dir.join("p.zip"), b"x").unwrap(); + assert_eq!(free_path(&dir, "p.zip"), Some(dir.join("p (2).zip"))); + std::fs::write(dir.join("p (2).zip"), b"x").unwrap(); + assert_eq!(free_path(&dir, "p.zip"), Some(dir.join("p (3).zip"))); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/horizon-client-render/src/pack_screen.rs b/crates/horizon-client-render/src/pack_screen.rs index 36ef645ef7..c0463da5d5 100644 --- a/crates/horizon-client-render/src/pack_screen.rs +++ b/crates/horizon-client-render/src/pack_screen.rs @@ -9,7 +9,7 @@ use crate::{ font::Patch, hud::{HudAtlas, PACK_ICON_SIZE, PACK_ICON_SLOTS}, packs::{Position, Selectable, SelectionModel}, - screen_draw::{self as d, Draw, Measure, TextField}, + screen_draw::{self as d, Backdrop, Draw, Measure, TextField}, text::{Style, Text}, }; use std::{collections::HashMap, sync::Arc}; @@ -98,7 +98,14 @@ impl PackInfo { /// sampled into its 32x32 square. None when it does not decode, where /// `loadPackIcon` falls back to `unknown_pack`. pub fn load_icon(png: &[u8]) -> Option> { - let (w, h, rgba) = crate::hud::decode_bytes(png).ok()?; + scale_icon(crate::hud::decode_bytes(png).ok()?) +} +/// A project icon Modrinth served (PNG, WebP or JPEG, +/// [`crate::hud::decode_image_bytes`]), scaled as [`load_icon`] scales a pack's. +pub fn load_fetched_icon(bytes: &[u8]) -> Option> { + scale_icon(crate::hud::decode_image_bytes(bytes).ok()?) +} +fn scale_icon((w, h, rgba): (u32, u32, Vec)) -> Option> { if w == 0 || h == 0 { return None; } @@ -313,6 +320,9 @@ pub enum Action { OpenFolder, /// The Done button (`onClose`: commit). Done, + /// Horizon's own: a Modrinth hit the player picked, to download into the + /// packs folder. The screen does not know where that is. + BrowsePick(usize), } /// The pack screen and its state. @@ -332,14 +342,68 @@ pub struct PackScreen { confirm: Option, /// The selection changed since the screen opened. pub changed: bool, - /// The packs' icons by their [`icon_texture`]. - icons: HashMap>, + /// The packs' icons by their [`icon_texture`], and the browser's. + pub icons: HashMap>, + /// Horizon's own Add button and its Modrinth list, when the player has + /// turned `packBrowser` on. Off, the screen stays vanilla's exactly + /// (tools/test_pack_screen_parity.py). + pub browser: Option, + /// Which backdrop this screen wants drawn behind it this frame + /// ([`Backdrop`]). Vanilla's own pack screen never asks for one. + pub backdrop: Backdrop, + /// The `icons` keys that came from the browser rather than from an + /// installed pack. Each decoded icon is `PACK_ICON_SIZE` squared in RGBA + /// (64 KiB), and a search brings twenty, so without this they piled up in + /// `icons` for the rest of the session: every search added twenty more + /// and none were ever freed. + browser_icons: Vec, + /// Whether that button is drawn at all. + pub browsing_allowed: bool, + /// The browser's own search box, separate from the one that filters the + /// player's installed packs. + browser_search: TextField, + /// The browser's search box has the keyboard. + browser_focus: bool, } fn tr(key: &str) -> Text { Text::translate(key, &[]) } +/// The Add button is square. The button sprite is a nine-slice built for a +/// height of 20; drawn shorter, neither it nor its label sits right, so the +/// button keeps that height and is centred on the shorter search box. +const ADD_SIZE: i32 = 20; +/// A card in the grid: its icon beside its name, author and description. +const ROW_H: i32 = 74; +/// The air around a card's contents. +const CARD_PAD: i32 = 6; +/// The sidebar of filters down the left. +const SIDEBAR_W: i32 = 108; +/// The bar across the top holding Back and the search box. +const TOPBAR_H: i32 = 30; +/// A filter row in the sidebar. +const FILTER_H: i32 = 15; +/// The panels' colours, dark over whatever is behind them. +const PANEL: i32 = 0xF0_14_17_22u32 as i32; +const CARD: i32 = 0xFF_1E_22_30u32 as i32; +const CARD_HOVER: i32 = 0xFF_2A_30_42u32 as i32; +const SIDEBAR_BG: i32 = 0xFF_10_12_1Bu32 as i32; +/// The Install button: Modrinth's green, dimmed once a pack is in. +const GREEN: i32 = 0xFF_1B_D9_6Au32 as i32; +const GREEN_HOVER: i32 = 0xFF_3B_E8_85u32 as i32; +const INSTALLED_GREEN: i32 = 0xFF_2A_5E_3Fu32 as i32; +const GREEN_EDGE: i32 = 0xFF_0E_7A_3Cu32 as i32; +/// The icon drawn at the left of a row. +const ICON: i32 = 34; +/// The browser's search box and the button beside it. +const BROWSER_SEARCH_W: i32 = 200; +const SEARCH_BTN_W: i32 = 56; +/// The Back and Install buttons. +const BACK_W: i32 = 60; +const BACK_H: i32 = 20; +const INSTALL_W: i32 = 70; + impl PackScreen { /// `PackSelectionScreen.init`: the lists filled, the search box focused. pub fn new( @@ -374,6 +438,20 @@ impl PackScreen { focus: Focus::None, lists: [List::new(false, height - 66), List::new(true, height - 66)], confirm: None, + browser: None, + browsing_allowed: false, + backdrop: Backdrop::None, + browser_icons: Vec::new(), + browser_search: { + let mut field = TextField::new(0, 0, BROWSER_SEARCH_W, 18); + field.hint = Some(tr("horizon.pack.searchHint").with_style(&Style { + color: Some(d::GRAY), + italic: Some(true), + ..Style::default() + })); + field + }, + browser_focus: false, changed: false, }; s.reposition(m); @@ -528,6 +606,9 @@ impl PackScreen { if self.confirm.is_some() { return self.confirm_click(m, x, y); } + if let Some(action) = self.browser_click(m, x, y) { + return action; + } if self.search.contains(x, y) { self.search.click(m, x, false); self.set_focus(Focus::Search); @@ -754,6 +835,10 @@ impl PackScreen { } /// Typed text, when the search box has the focus. pub fn type_text(&mut self, m: &Measure, s: &str) { + if self.browser.is_some() { + self.browser_search.insert_text(m, s); + return; + } if self.confirm.is_none() && self.focus == Focus::Search && self.search.insert_text(m, s) { self.filter(None); } @@ -775,6 +860,10 @@ impl PackScreen { } /// Backspace in the search box. pub fn erase(&mut self, m: &Measure, count: isize) { + if self.browser.is_some() { + self.browser_search.delete_chars(m, -count); + return; + } if self.confirm.is_none() && self.focus == Focus::Search && self.search.delete_chars(m, -count) @@ -782,8 +871,41 @@ impl PackScreen { self.filter(None); } } + /// Keeps a browser icon, remembering it so [`Self::release_browser_icons`] + /// can free it again. The installed packs' own icons are not touched. + pub fn keep_browser_icon(&mut self, key: String, icon: Arc<[u8]>) { + if self.icons.insert(key.clone(), icon).is_none() { + self.browser_icons.push(key); + } + } + /// Frees every icon the browser downloaded. Called when it closes: the + /// pack lists behind it never drew these. + pub fn release_browser_icons(&mut self) { + for key in self.browser_icons.drain(..) { + self.icons.remove(&key); + } + self.icons.shrink_to_fit(); + } + /// Runs the browser's search on what is typed in its box. The screen + /// calls this for the Search button and for Enter. + pub fn browser_submit(&mut self) { + let query = self.browser_search.value.clone(); + if let Some(browser) = &mut self.browser { + browser.query = query; + browser.search(); + } + } + /// Whether the browser's own search box has the keyboard, so the game + /// sends it typing rather than treating it as a hotkey. + pub fn browser_typing(&self) -> bool { + // The browser has one text field, so it always takes typing. Clearing + // it on a click elsewhere made typing after picking a filter or a card + // go nowhere, and Search then submitted stale text. + self.browser.is_some() + } pub fn search_focused(&self) -> bool { - self.confirm.is_none() && self.focus == Focus::Search + // The browser's own box counts: the game routes typing by this. + self.browser_typing() || (self.confirm.is_none() && self.focus == Focus::Search) } /// Resize (`Screen.init` on a new size: `repositionElements`). pub fn resize(&mut self, m: &Measure, width: i32, height: i32) { @@ -870,8 +992,377 @@ impl PackScreen { now_ms, [&tr("pack.openFolder"), &tr("gui.done")], ); + self.draw_browser(&mut out, m, mouse, now_ms); out } + /// The Add button and the screen it opens. `None` leaves the click to + /// the vanilla screen beneath. + fn browser_click(&mut self, m: &Measure, x: f64, y: f64) -> Option { + if !self.browsing_allowed { + return None; + } + let point = (x as i32, y as i32); + if self.browser.is_some() { + let (bx, by) = self.back_button(); + if d::inside(point, bx, by, BACK_W, BACK_H) { + self.browser = None; + self.browser_focus = false; + // Back to the vanilla pack screen, which wants no backdrop. + self.backdrop = Backdrop::None; + self.release_browser_icons(); + return Some(Action::None); + } + let (qx, qy) = self.browser_search_box(); + if d::inside(point, qx, qy, BROWSER_SEARCH_W, 18) { + self.browser_search.x = qx; + self.browser_search.y = qy; + self.browser_search.click(m, x, false); + self.browser_focus = true; + return Some(Action::None); + } + let (sx2, sy2) = self.browser_search_button(); + if d::inside(point, sx2, sy2, SEARCH_BTN_W, 18) { + self.browser_submit(); + return Some(Action::None); + } + for i in 0..crate::pack_browser::FILTERS.len() as i32 { + let (fx, fy, fw, fh) = self.filter_box(i); + if d::inside(point, fx, fy, fw, fh) { + let query = self.browser_search.value.clone(); + if let Some(browser) = &mut self.browser { + if !browser.filters.insert(i as usize) { + browser.filters.remove(&(i as usize)); + } + // A filter is only meaningful once it is searched on. + browser.query = query; + browser.search(); + } + return Some(Action::None); + } + } + let fits = self.browser_row_count(); + let busy = self.browser.as_ref().is_some_and(|b| b.busy); + let hits = self.browser.as_ref().map_or(0, |b| b.hits.len() as i32); + for i in 0..hits.min(fits) { + let (ix, iy) = self.install_button(i); + if d::inside(point, ix, iy, INSTALL_W, BACK_H) { + if busy { + return Some(Action::None); + } + return Some(Action::BrowsePick(i as usize)); + } + } + // The screen is modal: nothing beneath it takes the click. + return Some(Action::None); + } + let (bx, by) = self.add_button(m); + if d::inside(point, bx, by, ADD_SIZE, ADD_SIZE) { + let mut browser = crate::pack_browser::Browser::new(); + // Opens on the text already in the search box, so a player who + // searched their own packs first keeps the term; it is then the + // browser's box that carries it. + browser.query = self.search.value.clone(); + browser.search(); + self.browser_search.set_value(m, &self.search.value); + self.browser_focus = true; + self.browser = Some(browser); + // Horizon's own screen, so it may ask for the menu panorama. + self.backdrop = Backdrop::Panorama; + return Some(Action::None); + } + None + } + /// The Back button, top left of the browser. + fn back_button(&self) -> (i32, i32) { + (8, 8) + } + /// The browser's search box and the button beside it. + fn browser_search_box(&self) -> (i32, i32) { + let total = BROWSER_SEARCH_W + 4 + SEARCH_BTN_W; + let left = BACK_W + 16; + let centred = (self.width - total) / 2; + (centred.max(left), 5) + } + fn browser_search_button(&self) -> (i32, i32) { + let (x, y) = self.browser_search_box(); + (x + BROWSER_SEARCH_W + 4, y) + } + /// The Install button of row `i`. + fn install_button(&self, i: i32) -> (i32, i32) { + // Top right, beside the name. It used to sit bottom right, where the + // description ran under it. + let (cx, cy, cw, _) = self.card_box(i); + (cx + cw - INSTALL_W - CARD_PAD, cy + CARD_PAD) + } + /// The browser's content column: centred, and never wider than it reads + /// comfortably. + fn browser_area(&self) -> (i32, i32, i32, i32) { + let x = SIDEBAR_W + 6; + let top = TOPBAR_H + 6; + let w = (self.width - x - 6).max(120); + let h = (self.height - top - 6).max(ROW_H); + (x, top, w, h) + } + /// Two columns where there is room for them, otherwise one. + fn browser_columns(&self) -> i32 { + let (_, _, w, _) = self.browser_area(); + if w >= 360 { 2 } else { 1 } + } + /// The card at `i`: its box in the grid. + fn card_box(&self, i: i32) -> (i32, i32, i32, i32) { + let (px, py, pw, _) = self.browser_area(); + let cols = self.browser_columns(); + let cw = (pw - 6 * (cols - 1)) / cols; + let (cx, cy) = (i % cols, i / cols); + (px + cx * (cw + 6), py + cy * (ROW_H + 6), cw, ROW_H) + } + /// The filter row `i` in the sidebar. + fn filter_box(&self, i: i32) -> (i32, i32, i32, i32) { + (6, TOPBAR_H + 18 + i * FILTER_H, SIDEBAR_W - 12, FILTER_H) + } + /// How many rows fit the column. + fn browser_row_count(&self) -> i32 { + let (_, _, _, ph) = self.browser_area(); + (((ph + 6) / (ROW_H + 6)) * self.browser_columns()).max(0) + } + /// Horizon's Add button, and the screen it opens. Nothing is drawn + /// unless the player turned `packBrowser` on, so the screen's vanilla + /// parity is untouched by default. + fn draw_browser(&self, out: &mut Vec, m: &Measure, mouse: (i32, i32), now_ms: i64) { + if !self.browsing_allowed { + return; + } + let Some(browser) = &self.browser else { + let (bx, by) = self.add_button(m); + let hover = d::inside(mouse, bx, by, ADD_SIZE, ADD_SIZE); + d::button( + out, + m, + &tr("horizon.pack.add"), + bx, + by, + ADD_SIZE, + ADD_SIZE, + true, + hover, + now_ms, + ); + if hover { + d::tooltip(out, m, &tr("horizon.pack.addInfo"), mouse); + } + return; + }; + // Modal and opaque: vanilla's own menu background tiled over the + // whole screen, then a wash, so none of the pack lists shows through. + d::sprite(out, "gui/menu_background", 0, 0, self.width, self.height); + d::fill(out, 0, 0, self.width, self.height, PANEL); + // The sidebar of filters, and the bar across the top. + d::fill(out, 0, 0, SIDEBAR_W, self.height, SIDEBAR_BG); + d::fill(out, 0, 0, self.width, TOPBAR_H, SIDEBAR_BG); + let (bx, by) = self.back_button(); + let back_hover = d::inside(mouse, bx, by, BACK_W, BACK_H); + d::button( + out, + m, + &tr("gui.back"), + bx, + by, + BACK_W, + BACK_H, + true, + back_hover, + now_ms, + ); + // The search row: its box and the button that runs it. + let (qx, qy) = self.browser_search_box(); + let mut field = self.browser_search.clone(); + field.x = qx; + field.y = qy; + field.draw(out, m, 0, None); + let (sbx, sby) = self.browser_search_button(); + d::button( + out, + m, + &tr("horizon.pack.search"), + sbx, + sby, + SEARCH_BTN_W, + 18, + !browser.busy, + d::inside(mouse, sbx, sby, SEARCH_BTN_W, 18), + now_ms, + ); + // The sidebar's heading and its filters. + let heading = tr("horizon.pack.filters").with_style(&Style { + bold: Some(true), + ..Style::default() + }); + d::string_widget(out, m, &heading, 6, TOPBAR_H + 5, 0); + for (i, (key, _, _)) in crate::pack_browser::FILTERS.iter().enumerate() { + let i = i as i32; + let (fx, fy, fw, fh) = self.filter_box(i); + let on = browser.filters.contains(&(i as usize)); + let hover = d::inside(mouse, fx, fy, fw, fh); + if hover { + d::fill(out, fx - 2, fy - 1, fx + fw + 2, fy + fh - 1, CARD_HOVER); + } + // A small box, filled when the filter is on. + d::fill(out, fx, fy, fx + 7, fy + 7, 0xFF_44_4C_63u32 as i32); + if on { + d::fill(out, fx + 1, fy + 1, fx + 6, fy + 6, 0xFF_6C_C6_4Bu32 as i32); + } + let label = tr(key).with_color(if on { d::WHITE as u32 } else { d::GRAY }); + d::string_widget(out, m, &label, fx + 11, fy - 1, 0); + } + let (px, py, pw, ph) = self.browser_area(); + let message = if browser.busy && browser.installing.is_none() { + Some(tr("horizon.pack.addWorking")) + } else if let Some(status) = &browser.status { + Some(Text::translate( + "horizon.pack.addFailed", + &[Text::literal(status.clone())], + )) + } else if browser.hits.is_empty() { + Some(tr("horizon.pack.addNone")) + } else { + None + }; + if let Some(message) = message { + let lines = d::label_lines( + m, + &message.with_color(d::GRAY), + pw - 8, + 6, + &Style::default(), + ); + d::label(out, &lines, px + 4, py + 20, pw - 8, true); + return; + } + let fits = self.browser_row_count(); + for (i, hit) in browser.hits.iter().enumerate() { + let i = i as i32; + if i >= fits { + break; + } + let (cx, cy, cw, ch) = self.card_box(i); + if cy + ch > py + ph { + break; + } + let over = d::inside(mouse, cx, cy, cw, ch); + d::fill( + out, + cx, + cy, + cx + cw, + cy + ch, + if over { CARD_HOVER } else { CARD }, + ); + // The icon, once one has arrived: `bind_icons` swaps the texture + // for the atlas slot it patched the PNG into. + let texture = icon_texture(&hit.id); + if self.icons.contains_key(&texture) { + out.push(Draw::Blit { + texture, + x: cx + CARD_PAD, + y: cy + CARD_PAD, + w: ICON, + h: ICON, + u: 0.0, + v: 0.0, + src: [PACK_ICON_SIZE as i32, PACK_ICON_SIZE as i32], + tex: [PACK_ICON_SIZE as i32, PACK_ICON_SIZE as i32], + color: d::WHITE, + }); + } + // The name and author sit in the column between the icon and + // the Install button; the description runs the card's full width + // under both, so no two of them can overlap. + let text_x = cx + CARD_PAD + ICON + CARD_PAD; + let text_w = (cx + cw - CARD_PAD - INSTALL_W - CARD_PAD - text_x).max(20); + let name = d::label_lines( + m, + &Text::literal(hit.title.clone()).with_style(&Style { + bold: Some(true), + ..Style::default() + }), + text_w, + 1, + &Style::default(), + ); + d::label(out, &name, text_x, cy + CARD_PAD + 1, text_w, false); + if !hit.author.is_empty() { + let author = d::label_lines( + m, + &Text::literal(hit.author.clone()).with_color(d::GRAY), + text_w, + 1, + &Style::default(), + ); + d::label(out, &author, text_x, cy + CARD_PAD + 12, text_w, false); + } + let body_w = (cw - 2 * CARD_PAD).max(20); + let lines = d::label_lines( + m, + &Text::literal(hit.description.clone()).with_color(d::GRAY), + body_w, + 2, + &Style::default(), + ); + // Below the icon, which is the tallest thing in the top row. + d::label( + out, + &lines, + cx + CARD_PAD, + cy + CARD_PAD + ICON + 5, + body_w, + false, + ); + let (ix, iy) = self.install_button(i); + let done = browser.installed.contains(&(i as usize)); + let running = browser.installing == Some(i as usize); + let label = if done { + tr("horizon.pack.installed") + } else if running { + tr("horizon.pack.installing") + } else { + tr("horizon.pack.install") + }; + // Drawn rather than the vanilla button sprite, so it can be the + // green the design asks for. + let hovered = d::inside(mouse, ix, iy, INSTALL_W, BACK_H); + let face = if done { + INSTALLED_GREEN + } else if hovered && !browser.busy { + GREEN_HOVER + } else { + GREEN + }; + d::fill(out, ix, iy, ix + INSTALL_W, iy + BACK_H, GREEN_EDGE); + d::fill( + out, + ix + 1, + iy + 1, + ix + INSTALL_W - 1, + iy + BACK_H - 1, + face, + ); + let lw = m.width(&label); + d::string_widget( + out, + m, + &label, + ix + (INSTALL_W - lw) / 2, + iy + (BACK_H - 8) / 2, + 0, + ); + } + } + /// The Add button, just right of the search box. + fn add_button(&self, m: &Measure) -> (i32, i32) { + let (sx, sy) = self.header(m)[2]; + // The search box is 15 tall: centre the taller button on it. + (sx + 204, sy - (ADD_SIZE - 15) / 2) + } /// The footer's two buttons, "Open Pack Folder" and "Done", over their /// labels; a label wider than its button scrolls with the clock. fn draw_footer( @@ -1205,6 +1696,85 @@ mod icon_tests { mod click_sound_tests { use super::*; + #[test] + fn a_cards_parts_do_not_overlap_each_other() { + let atlas = + crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")) + .unwrap(); + let m = Measure(&atlas.font); + // Both a wide window (two columns) and a narrow one (one column). + for (w, h) in [(480, 320), (260, 320)] { + let mut screen = + PackScreen::new(&m, tr("resourcePack.title"), w, h, false, Vec::new(), &[]); + screen.browsing_allowed = true; + let (cx, cy, cw, ch) = screen.card_box(0); + let (ix, iy) = screen.install_button(0); + // Install stays inside its card. + assert!( + ix >= cx && ix + INSTALL_W <= cx + cw, + "install escapes the card" + ); + assert!( + iy >= cy && iy + BACK_H <= cy + ch, + "install escapes the card" + ); + // The name column ends before Install begins, so the two cannot + // be drawn over one another. + let text_x = cx + CARD_PAD + ICON + CARD_PAD; + let text_w = (cx + cw - CARD_PAD - INSTALL_W - CARD_PAD - text_x).max(20); + assert!( + text_x + text_w <= ix || text_w == 20, + "the name column runs under Install" + ); + // The description starts below the icon rather than across it. + let description_top = cy + CARD_PAD + ICON + 5; + assert!( + description_top >= cy + CARD_PAD + ICON, + "text over the icon" + ); + // And it still fits in the card with both its lines. + assert!(description_top + 18 <= cy + ch, "the description overflows"); + } + } + + #[test] + fn the_browsers_search_button_submits_what_was_typed() { + let atlas = + crate::hud::HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")) + .unwrap(); + let m = Measure(&atlas.font); + let mut screen = PackScreen::new( + &m, + tr("resourcePack.title"), + 480, + 320, + false, + Vec::new(), + &[], + ); + screen.browsing_allowed = true; + // The Add button opens the browser. + let (ax, ay) = screen.add_button(&m); + screen.click(&m, f64::from(ax) + 2.0, f64::from(ay) + 2.0); + assert!(screen.browser.is_some(), "the Add button opens the browser"); + // Typing reaches the browser's own box, not the pack filter. + screen.type_text(&m, "blue"); + assert_eq!(screen.browser_search.value, "blue"); + assert_eq!(screen.search.value, "", "the pack filter is left alone"); + // The Search button sends it to the browser. + let (sx, sy) = screen.browser_search_button(); + screen.click(&m, f64::from(sx) + 2.0, f64::from(sy) + 2.0); + assert_eq!( + screen.browser.as_ref().map(|b| b.query.as_str()), + Some("blue"), + "the Search button submits the box's text" + ); + // The box and the button must not overlap, or one eats the other's + // clicks. + let (qx, _) = screen.browser_search_box(); + assert!(sx >= qx + BROWSER_SEARCH_W, "{sx} overlaps the box at {qx}"); + } + #[test] fn only_footer_and_confirmation_buttons_request_a_click_sound() { let atlas = diff --git a/crates/horizon-client-render/src/screen_draw.rs b/crates/horizon-client-render/src/screen_draw.rs index f2acb0e08e..27ffacbaa9 100644 --- a/crates/horizon-client-render/src/screen_draw.rs +++ b/crates/horizon-client-render/src/screen_draw.rs @@ -87,6 +87,30 @@ pub fn fill(out: &mut Vec, x0: i32, y0: i32, x1: i32, y1: i32, color: i32) color, }); } +/// What a screen wants drawn behind it, for screens that are Horizon's own. +/// +/// The panorama is the spinning cube the renderer draws before the HUD, not +/// something a draw list can hold, so a screen cannot simply emit it: it says +/// here that it wants one and the renderer obeys. Existing screens that +/// already show a panorama (the title and the loading screens) decide it +/// their own way, in `Game::shows_panorama`, and are untouched by this: a +/// screen that leaves this at [`Backdrop::None`] behaves exactly as before. +/// +/// This exists so a new Horizon screen can have the menu panorama without +/// each one growing its own special case in the frame loop. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Backdrop { + /// Whatever is already behind the screen: the world, or the flat menu + /// background the screen draws itself. The default, and what every + /// vanilla screen uses. + #[default] + None, + /// The menu panorama, as the title screen shows it. The screen should + /// still draw its own panels over it; the panorama only replaces what + /// would otherwise be empty background. + Panorama, +} + pub fn sprite(out: &mut Vec, sprite: &str, x: i32, y: i32, w: i32, h: i32) { out.push(Draw::Sprite { sprite: sprite.to_owned(), diff --git a/crates/horizon-client/assets/horizon/lang/en_us.json b/crates/horizon-client/assets/horizon/lang/en_us.json index a99a740822..ea1c48643a 100644 --- a/crates/horizon-client/assets/horizon/lang/en_us.json +++ b/crates/horizon-client/assets/horizon/lang/en_us.json @@ -276,6 +276,7 @@ "horizon.extras.chat_animation": "Smooth chat", "horizon.extras.chat_indicator": "Message indicator", "horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings", + "horizon.extras.pack_browser": "Resource pack browser", "horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.", "horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.", "horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.", @@ -346,6 +347,7 @@ "horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.", "horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.", "horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.", + "horizon.extras.pack_browser.tooltip": "Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth.", "horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.", "horizon.splitscreen.setup.monitor": "Monitor %s", "horizon.splitscreen.setup.monitor_unknown": "a monitor", @@ -475,6 +477,24 @@ "horizon.repro.error": "Repro: %s", "horizon.repro.no_recording": "There is no recording to export yet.", "horizon.repro.replay_started": "Replay started: %s", + "horizon.pack.add": "+", + "horizon.pack.addInfo": "Add a resource pack from Modrinth", + "horizon.pack.addWorking": "Asking Modrinth...", + "horizon.pack.addNone": "No resource packs found", + "horizon.pack.addTitle": "Add a Resource Pack", + "horizon.pack.install": "Install", + "horizon.pack.installing": "Installing", + "horizon.pack.installed": "Installed", + "horizon.pack.search": "Search", + "horizon.pack.searchHint": "Search Modrinth...", + "horizon.pack.filters": "Filters", + "horizon.pack.filter.realistic": "Realistic", + "horizon.pack.filter.simplistic": "Simplistic", + "horizon.pack.filter.vanillaLike": "Vanilla-like", + "horizon.pack.filter.x16": "16x", + "horizon.pack.filter.x32": "32x", + "horizon.pack.filter.x64": "64x", + "horizon.pack.addFailed": "The pack could not be added: %s", "horizon.extras.header.cosmetics": "Cosmetics", "horizon.extras.cosmetics_mine": "My cosmetics", "horizon.extras.cosmetics_mine.tooltip": "Shows the cosmetics you wear on your own player, in third person and the inventory.", diff --git a/crates/horizon-client/src/client_lib.rs b/crates/horizon-client/src/client_lib.rs index 63c0ad683a..8c90005257 100644 --- a/crates/horizon-client/src/client_lib.rs +++ b/crates/horizon-client/src/client_lib.rs @@ -16,7 +16,8 @@ pub use horizon_client_render::{ hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file, options_fixes, - options_ui, overlay, pack_screen, packs, par, particles, poi_sections, post_chain, + options_ui, overlay, pack_browser, pack_screen, packs, par, particles, poi_sections, + post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites, diff --git a/crates/horizon-client/src/data_cmd.rs b/crates/horizon-client/src/data_cmd.rs index c680363df2..a60b595a3b 100644 --- a/crates/horizon-client/src/data_cmd.rs +++ b/crates/horizon-client/src/data_cmd.rs @@ -198,7 +198,7 @@ pub fn scaled_value(tag: &Tag, path: &NbtPath, scale: f64) -> Result` prints for the scale (`%.2f`). pub fn scale_text(scale: f64) -> Text { - Text::literal(format!("{scale:.2}")) + Text::literal(crate::java_text::format_fixed(scale, 2)) } #[cfg(test)] @@ -311,5 +311,6 @@ mod tests { "commands.data.get.multiple" ); assert_eq!(scale_text(1.0).plain(), "1.00"); + assert_eq!(scale_text(0.125).plain(), "0.13"); } } diff --git a/crates/horizon-client/src/debug_overlay.rs b/crates/horizon-client/src/debug_overlay.rs index a64a336943..471bdacbe9 100644 --- a/crates/horizon-client/src/debug_overlay.rs +++ b/crates/horizon-client/src/debug_overlay.rs @@ -4,6 +4,7 @@ use crate::{ font::Font, hud_layout::{GuiDraw, PoseOp, fill}, + java_text, text::Text, }; @@ -143,7 +144,12 @@ pub fn position( _ => ("east", "Towards positive X"), }; vec![ - format!("XYZ: {:.3} / {:.5} / {:.3}", pos[0], pos[1], pos[2]), + format!( + "XYZ: {} / {} / {}", + java_text::format_fixed(pos[0], 3), + java_text::format_fixed(pos[1], 5), + java_text::format_fixed(pos[2], 3), + ), format!("Block: {} {} {}", block[0], block[1], block[2]), format!( "Chunk: {cx} {sy} {cz} [{} {} in r.{}.{}.mca]", @@ -153,9 +159,9 @@ pub fn position( cz >> 5 ), format!( - "Facing: {direction} ({towards}) ({:.1} / {:.1})", - wrap_degrees(yaw), - wrap_degrees(pitch) + "Facing: {direction} ({towards}) ({} / {})", + java_text::format_fixed(wrap_degrees(yaw) as f64, 1), + java_text::format_fixed(wrap_degrees(pitch) as f64, 1), ), format!("{dimension} FC: {forced}"), format!( @@ -370,7 +376,11 @@ pub fn local_difficulty_line(effective: f32) -> String { } else { (effective - 2.0) / 2.0 }; - format!("Local Difficulty: {effective:.2} // {special:.2}") + format!( + "Local Difficulty: {} // {}", + java_text::format_fixed(effective as f64, 2), + java_text::format_fixed(special as f64, 2), + ) } /// `DebugEntryGpuUtilization`: past 100% in red. @@ -556,6 +566,10 @@ mod tests { "SC: 289, MO: 3, C: 10, AM: 1, AX: 0, UWC: 0, WC: 2, WA: 4, MI: 0" ); assert_eq!(local_difficulty_line(1.5), "Local Difficulty: 1.50 // 0.00"); + assert_eq!( + local_difficulty_line(2.25), + "Local Difficulty: 2.25 // 0.13" + ); assert_eq!(local_difficulty_line(3.0), "Local Difficulty: 3.00 // 0.50"); assert_eq!(light_line(12, 14), "Client Light: 14 (12 sky, 14 block)"); } @@ -668,6 +682,10 @@ mod tests { "Section-relative: 12 08 12", ]) ); + assert_eq!( + position([1.0005, 64.0, 0.0], 0.0, 0.0, "minecraft:overworld", 0)[0], + "XYZ: 1.001 / 64.00000 / 0.000" + ); assert_eq!( position([0.0, 64.0, 0.0], 450.0, 0.0, "minecraft:the_nether", 2)[3], "Facing: west (Towards negative X) (90.0 / 0.0)" diff --git a/crates/horizon-client/src/entity_nbt.rs b/crates/horizon-client/src/entity_nbt.rs index 542a80a715..f029330696 100644 --- a/crates/horizon-client/src/entity_nbt.rs +++ b/crates/horizon-client/src/entity_nbt.rs @@ -338,6 +338,7 @@ pub fn mob_to_nbt( raw: Option<&Tag>, registry: Option<&BlockRegistry>, ) -> Tag { + use crate::mobs::Species as S; let base = Base { id: &m.kind.id, uuid, @@ -368,6 +369,18 @@ pub fn mob_to_nbt( ] { put(&mut tag, key, value); } + // AbstractFish/Axolotl.addAdditionalSaveData always write the flag. + // Tadpole.fromBucket is always true, and its setter ignores loaded input. + let from_bucket = match m.species() { + S::Tadpole => Some(true), + S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl => { + Some(m.extra.from_bucket) + } + _ => None, + }; + if let Some(value) = from_bucket { + put(&mut tag, "FromBucket", Tag::Byte(i8::from(value))); + } // Mob.addAdditionalSaveData: the death loot table, and its seed when set. if let Some(table) = &m.extra.death_loot_table { put(&mut tag, "DeathLootTable", string(table)); @@ -2159,6 +2172,34 @@ pub fn tnt_to_nbt( } tag } +/// `EvokerFangs.addAdditionalSaveData`: always save the live warmup counter. +/// Known Owner UUIDs use `EntityReference.store` encoding; unresolved +/// loaded Owner data remains in the raw compound. +pub fn evoker_fangs_to_nbt( + position: Vec3, + yaw: f32, + warmup: i32, + owner: Option<[i32; 4]>, + uuid: [i32; 4], + raw: Option<&Tag>, +) -> Tag { + let mut tag = Base { + id: "minecraft:evoker_fangs", + uuid, + position, + motion: raw.map(motion).unwrap_or(Vec3::ZERO), + rotation: [yaw, raw.map(rotation).unwrap_or([0.0; 2])[1]], + on_ground: raw.is_some_and(|t| flag(t, "OnGround")), + fall_distance: 0.0, + } + .tag(raw); + put(&mut tag, "Warmup", Tag::Int(warmup)); + if let Some(owner) = owner { + put(&mut tag, "Owner", Tag::IntArray(owner.to_vec())); + } + tag +} + pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option { let stack = item_from_nbt(tag.get("Item")?)?; let position = position(tag)?; @@ -4038,11 +4079,18 @@ fn write_axolotl_memories(m: &Mob, map: &mut Compound) { } } } -/// The variant tags `Axolotl`, `Rabbit`, `Parrot`, `Salmon` and -/// `TropicalFish` read; a missing tag is the default variant (a medium -/// salmon, otherwise index 0). +/// Saved variants and fish/axolotl bucket flags. Missing variants use a +/// medium salmon or index 0; a missing bucket flag is false. fn read_variant(m: &mut Mob, tag: &Tag) { use crate::mobs::Species as S; + // AbstractFish/Axolotl.readAdditionalSaveData: false when absent. + // Tadpole.setFromBucket ignores the input. + if matches!( + m.species(), + S::Cod | S::Salmon | S::Pufferfish | S::TropicalFish | S::Axolotl + ) { + m.extra.from_bucket = flag(tag, "FromBucket"); + } let int = |key| number(tag.get(key)).unwrap_or(0) as i32; match m.species() { S::Axolotl | S::Parrot => m.extra.variant = int("Variant").clamp(0, 4) as u8, @@ -4335,6 +4383,57 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) { #[cfg(test)] mod axolotl_nbt_tests { use super::*; + #[test] + fn aquatic_from_bucket_saves_defaults_and_tadpole_constant() { + 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; + } + let types: HashMap<_, _> = crate::mobs::MobTypes::load(&root) + .unwrap() + .types + .into_iter() + .map(|(id, kind)| (id, Arc::new(kind))) + .collect(); + for kind in [ + "cod", + "salmon", + "pufferfish", + "tropical_fish", + "axolotl", + "tadpole", + ] { + for value in [None, Some(false), Some(true)] { + let mut input = Tag::compound([ + ("id", string(&format!("minecraft:{kind}"))), + ("Pos", doubles(&[10.5, 220.0, 10.5])), + ]); + if let (Some(value), Tag::Compound(map)) = (value, &mut input) { + map.insert("FromBucket".into(), Tag::Byte(i8::from(value))); + } + let m = mob_from_nbt(&input, &types, 1, None).unwrap(); + let expected = kind == "tadpole" || value.unwrap_or(false); + for raw in [None, Some(&input)] { + let saved = mob_to_nbt(&m, [1; 4], raw, None); + assert_eq!( + saved.get("FromBucket"), + Some(&Tag::Byte(i8::from(expected))), + "{kind}, input={value:?}, raw={}", + raw.is_some() + ); + } + } + } + let pig = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0); + assert!( + mob_to_nbt(&pig, [1; 4], None, None) + .get("FromBucket") + .is_none() + ); + } + #[test] fn bucket_cooldown_and_brain_memories_survive_and_expired_cooldown_is_removed() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index fb5c971aba..70e277c3b0 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -9879,6 +9879,10 @@ impl Game<'_> { let crafted = std::mem::take(&mut menu.crafted); let brewed = std::mem::take(&mut menu.brewed); let equipped = std::mem::take(&mut menu.equipped); + let item_crafted = std::mem::take(&mut menu.item_crafted); + for (_, stack) in &item_crafted { + self.tutorial_get_item(Some(stack)); + } for (i, stack) in after.into_iter().enumerate() { if let Some(g) = game_of[i] { *self.slot_mut(g) = stack; @@ -10599,6 +10603,7 @@ impl Game<'_> { // (native_item_crafted). let player = self.native_acting_id(); self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken); + self.tutorial_get_item(Some(&taken)); self.award_stat("crafted", &made.0, made.1 as i32); self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]); if !shift || crafted >= 64 { @@ -15999,7 +16004,30 @@ impl Game<'_> { /// The frame shows the title's panorama, not a level: the title screen, or a /// LevelLoadingScreen (Reason.OTHER), which draws it too. fn shows_panorama(&self) -> bool { - self.title.is_some() || self.remote_loading() + // The two cases that always had one: the title screen and waiting on + // a server. Left exactly as they were. + self.title.is_some() || self.remote_loading() || self.screen_wants_panorama() + } + /// Whether the screen on top asked for the panorama through + /// [`horizon_client::screen_draw::Backdrop`]. + /// + /// This is the opt-in path for Horizon's own screens: a screen sets its + /// `backdrop` and the renderer draws the panorama behind it, instead of + /// every such screen needing its own branch in `shows_panorama`. Vanilla + /// screens leave the backdrop at `None` and so never reach this. + /// + /// Only in a menu: in a world the panorama would replace the world the + /// player is standing in, which is not what any of these screens want. + fn screen_wants_panorama(&self) -> bool { + use horizon_client::screen_draw::Backdrop; + if self.player.is_some() { + return false; + } + self.ui + .options + .pack_screen + .as_ref() + .is_some_and(|p| p.backdrop == Backdrop::Panorama) } /// `LevelLoadingScreen.extractBackground`. pub(super) fn draw_level_loading_background( @@ -19759,10 +19787,15 @@ fn gameplay_tables( progress: &Progress, ) -> std::result::Result { let facts_step = startup::step("core.block_facts"); - let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new( - std::fs::File::open(root.join("block-behaviours.json")).map_err(|e| e.to_string())?, - )) - .map_err(|e| e.to_string())?; + // Read whole, then parsed from the buffer: `serde_json::from_reader` + // pulls through a `BufReader` and is measurably slower on a file this + // size than one read plus `from_slice`, which is how `BlockFacts` next + // door already reads its own. Same value either way. + let behaviour_bytes = + std::fs::read(root.join("block-behaviours.json")).map_err(|e| e.to_string())?; + let behaviour_json: serde_json::Value = + serde_json::from_slice(&behaviour_bytes).map_err(|e| e.to_string())?; + drop(behaviour_bytes); let (facts, gameplay_projection) = BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel) .map_err(|e| e.to_string())?; @@ -20998,6 +21031,12 @@ impl Loading { self.menu.handoff(&mut game); game.apply_settings(); game.previous = Instant::now(); + // Minecraft.onGameLoadFinished: a start that made it this far is clean. + game.settings.file.set( + "startedCleanly", + horizon_client::options_file::V::Bool(true), + ); + game.settings.save(&Game::options_path()); log::info!( "Horizon: startup handoff {} ms (renderer {} ms, start_game {} ms)", handoff_at.elapsed().as_millis(), @@ -21102,6 +21141,25 @@ fn missing_resources(root: &std::path::Path) -> Option { )) } +/// `Minecraft.`: `startedCleanly` left false means the previous start +/// died before `onGameLoadFinished`. Vanilla then leaves fullscreen +/// ("resetting fullscreen mode") so a start that died on the display mode can +/// come back; the resource packs are rolled back only when their load fails +/// (`rollbackResourcePacks`), not here. True when the last start was unclean. +fn unclean_start_recovery(settings: &mut Settings) -> bool { + // Vanilla's field defaults true: only an explicit `false` is an unclean + // start; an options file that never had the key is not one. + let unclean = matches!( + settings.file.get("startedCleanly"), + Some(horizon_client::options_file::V::Bool(false)) + ); + if unclean { + settings.fullscreen = false; + settings.file.fullscreen_resolution = None; + } + unclean +} + /// The ordinary launch: load the resources on a worker while the window shows /// the loading screen, then swap in the game on the main thread. fn run_loading( @@ -21237,7 +21295,21 @@ pub fn run_with() -> Result<()> { } mods::register_components(&vanilla_root)?; mods::register_standalone_models(); - let early_settings = Settings::load(&Game::options_path()); + let mut early_settings = Settings::load(&Game::options_path()); + // Minecraft.: read the last start's flag, then mark this one as not + // finished until onGameLoadFinished. A dedicated server has no options.txt. + if options.execution_role.is_client() { + if unclean_start_recovery(&mut early_settings) { + log::warn!( + "Detected unexpected shutdown during last game startup: resetting fullscreen mode" + ); + } + early_settings.file.set( + "startedCleanly", + horizon_client::options_file::V::Bool(false), + ); + early_settings.save(&Game::options_path()); + } // Minecraft's constructor opens the narrator; a server has none. if options.execution_role.is_client() { narrator::start(&early_settings); @@ -21300,7 +21372,15 @@ pub fn run_with() -> Result<()> { None, None, )?; - if let Some(game) = game { + if let Some(mut game) = game { + // Minecraft.onGameLoadFinished: a start that made it this far is clean. + if game.options.execution_role.is_client() { + game.settings.file.set( + "startedCleanly", + horizon_client::options_file::V::Bool(true), + ); + game.settings.save(&Game::options_path()); + } run_event_loop(game)?; } Ok(()) @@ -21608,6 +21688,38 @@ mod tests { assert_eq!(autosave_interval(0.0), 100); } + /// `Minecraft.`: only an explicit `startedCleanly: false` is an + /// unclean start, and it leaves fullscreen; the packs stay selected. + #[test] + fn an_unclean_start_leaves_fullscreen_and_keeps_the_packs() { + let fullscreen = || Settings { + fullscreen: true, + resource_packs: vec!["pack".into()], + ..Default::default() + }; + // The key absent (a first boot) is a clean start. + let mut settings = fullscreen(); + assert!(!unclean_start_recovery(&mut settings)); + assert!(settings.fullscreen); + let mut settings = fullscreen(); + settings.file.fullscreen_resolution = Some("1920x1080@60:24".into()); + settings.file.set( + "startedCleanly", + horizon_client::options_file::V::Bool(false), + ); + assert!(unclean_start_recovery(&mut settings)); + assert!(!settings.fullscreen); + assert_eq!(settings.file.fullscreen_resolution, None); + assert_eq!(settings.resource_packs, ["pack"]); + let mut settings = fullscreen(); + settings.file.set( + "startedCleanly", + horizon_client::options_file::V::Bool(true), + ); + assert!(!unclean_start_recovery(&mut settings)); + assert!(settings.fullscreen); + } + /// `NativeScreenView.id` (the `screen_render_post` hook) is a mod's /// contract: every `Screen` has its own id, and it does not change. /// `Screen::id`'s match makes a new variant give itself one; this list diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index dc67fb222a..50c62db555 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -59,6 +59,8 @@ pub(super) enum Target { /// An entity of a type Horizon does not simulate (markers, displays, /// interactions...): kept as its saved data at a position. Other(u32), + /// Active evoker fangs, including command-loaded ones. + Fangs(u32), /// An End crystal, by its entity id (`minecraft:end_crystal`). Crystal(u32), /// The Ender Dragon, by its entity id (`minecraft:ender_dragon`). @@ -151,6 +153,7 @@ fn non_player_entity_id(t: Target) -> Option { | Target::Boat(id) | Target::Cart(id) | Target::Other(id) + | Target::Fangs(id) | Target::Crystal(id) | Target::Dragon(id) | Target::Tnt(id) @@ -576,6 +579,7 @@ impl Game<'_> { .iter() .find(|t| t.id == id) .map(|t| [t.position.x, t.position.y, t.position.z]), + Target::Fangs(id) => self.fangs.list.iter().find(|f| f.id == id).map(|f| f.pos), Target::Spawner(id) => self .ominous_spawners .iter() @@ -631,6 +635,7 @@ impl Game<'_> { Target::Falling(_) => "minecraft:falling_block".into(), Target::Rocket(_) => "minecraft:firework_rocket".into(), Target::Trident(_) => "minecraft:trident".into(), + Target::Fangs(_) => "minecraft:evoker_fangs".into(), Target::Spawner(_) => "minecraft:ominous_item_spawner".into(), Target::Cloud(_) => "minecraft:area_effect_cloud".into(), } @@ -701,6 +706,7 @@ impl Game<'_> { | Target::Boat(id) | Target::Cart(id) | Target::Other(id) + | Target::Fangs(id) | Target::Cloud(id) => self.persist_raw(id).and_then(|r| r.get("CustomName")), _ => None, }; @@ -894,6 +900,7 @@ impl Game<'_> { candidates.extend(self.item_frames.iter().map(|f| Target::Frame(f.id))); candidates.extend(self.paintings.iter().map(|p| Target::Painting(p.id))); candidates.extend(self.others.iter().map(|o| Target::Other(o.id))); + candidates.extend(self.fangs.list.iter().map(|f| Target::Fangs(f.id))); // The End crystals and the dragon (EndCrystal and EnderDragon // are entities of their own). candidates.extend(self.crystals.iter().map(|c| Target::Crystal(c.id))); @@ -936,6 +943,7 @@ impl Game<'_> { Target::Boat(id), Target::Cart(id), Target::Other(id), + Target::Fangs(id), Target::Crystal(id), Target::Dragon(id), Target::Tnt(id), @@ -963,6 +971,7 @@ impl Game<'_> { Target::Boat(id) => self.boats.iter().any(|b| b.id == id), Target::Cart(id) => self.carts.iter().any(|c| c.id == id), Target::Other(id) => self.others.iter().any(|o| o.id == id), + Target::Fangs(id) => self.fangs.list.iter().any(|f| f.id == id), Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id), Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id), Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id), @@ -1099,6 +1108,7 @@ impl Game<'_> { "ban-ip" | "pardon-ip" => self.cmd_ip_bans(id, ctx), id if id.starts_with("banlist") => self.cmd_banlist(id), id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx), + "setidletimeout" => self.cmd_setidle_timeout(ctx), "stop" => self.cmd_stop(), "save-all" => self.cmd_save_all(), "save-off" | "save-on" => self.cmd_auto_save(id == "save-on"), @@ -1606,6 +1616,14 @@ impl Game<'_> { } } } + Target::Fangs(id) => { + if let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) { + f.pos = [v.x, v.y, v.z]; + if let Some([yaw, _]) = rotation { + f.yaw = yaw; + } + } + } Target::Other(id) => { if let Some(o) = self.others.iter_mut().find(|o| o.id == id) { o.position = v; @@ -2152,6 +2170,7 @@ impl Game<'_> { self.carts.retain(|c| c.id != id); } Target::Other(id) => self.others.retain(|o| o.id != id), + Target::Fangs(id) => self.fangs.list.retain(|f| f.id != id), Target::Crystal(id) => self.kill_crystal(id), Target::Dragon(id) => self.kill_dragon(id), Target::Tnt(id) => self.tnts.retain(|t| t.id != id), diff --git a/crates/horizon-client/src/game/commands/data.rs b/crates/horizon-client/src/game/commands/data.rs index 3362703307..b6a81e2ecc 100644 --- a/crates/horizon-client/src/game/commands/data.rs +++ b/crates/horizon-client/src/game/commands/data.rs @@ -148,6 +148,11 @@ impl Game<'_> { let c = self.carts.iter().find(|c| c.id == id)?; c.to_nbt(uuid, self.persist.raw.get(&id)) } + Target::Fangs(id) => { + let uuid = self.uuid_for(id); + let f = self.fangs.list.iter().find(|f| f.id == id)?; + self.fangs_to_nbt(f, uuid) + } Target::Other(id) => { let uuid = self.uuid_for(id); let o = self.others.iter().find(|o| o.id == id)?.clone(); @@ -313,6 +318,16 @@ impl Game<'_> { slot.bob_offset = bob; } } + Target::Fangs(id) => { + let owner = self.loaded_fangs_owner(&tag); + let at = entity_nbt::position(&tag).ok_or_else(stale)?; + if let Some(f) = self.fangs.list.iter_mut().find(|f| f.id == id) { + f.pos = [at.x, at.y, at.z]; + f.yaw = entity_nbt::rotation(&tag)[0]; + f.warmup = horizon_client::save::number(tag.get("Warmup")).unwrap_or(0) as i32; + f.owner = owner; + } + } Target::Stand(id) => { let new = entity_nbt::armor_stand_from_nbt(&tag, id).ok_or_else(stale)?; if let Some(slot) = self.armor_stands.iter_mut().find(|a| a.id == id) { @@ -327,7 +342,12 @@ impl Game<'_> { } _ => return Err(stale()), } - if let Target::Mob(id) | Target::Item(id) | Target::Stand(id) | Target::Frame(id) = t { + if let Target::Mob(id) + | Target::Item(id) + | Target::Stand(id) + | Target::Frame(id) + | Target::Fangs(id) = t + { self.persist.raw.insert(id, tag); } Ok(()) @@ -366,6 +386,7 @@ impl Game<'_> { | Target::Crystal(id) | Target::Dragon(id) | Target::Other(id) + | Target::Fangs(id) | Target::Cloud(id) => Some(self.persist.raw.entry(id).or_insert_with(empty)), _ => None, } diff --git a/crates/horizon-client/src/game/commands/execute.rs b/crates/horizon-client/src/game/commands/execute.rs index c5e6c445bc..a6f0c7d942 100644 --- a/crates/horizon-client/src/game/commands/execute.rs +++ b/crates/horizon-client/src/game/commands/execute.rs @@ -140,6 +140,12 @@ impl Game<'_> { .iter() .find(|c| c.id == id) .map_or([0.0; 2], |c| [c.yaw, 0.0]), + Target::Fangs(id) => self + .fangs + .list + .iter() + .find(|f| f.id == id) + .map_or([0.0; 2], |f| [f.yaw, 0.0]), Target::Other(id) => self .others .iter() diff --git a/crates/horizon-client/src/game/commands/profiles.rs b/crates/horizon-client/src/game/commands/profiles.rs index 32c387bbec..24e03bfed9 100644 --- a/crates/horizon-client/src/game/commands/profiles.rs +++ b/crates/horizon-client/src/game/commands/profiles.rs @@ -1,11 +1,12 @@ //! `/op`, `/deop`, `/ban`, `/pardon`, `/ban-ip`, `/pardon-ip`, `/banlist`, -//! `/transfer` and `/whitelist` (OpCommand, DeOpCommands, BanPlayerCommands, -//! PardonCommand, BanIpCommands, PardonIpCommand, BanListCommands, -//! TransferCommand, WhitelistCommand): commands of a dedicated server, run on -//! its lists by an operator in game (the console has its own handlers in -//! game/dedicated.rs). Also `/stop`, `/save-all`, `/save-off` and `/save-on` -//! (StopCommand, SaveAllCommand, SaveOffCommand, SaveOnCommand), on the -//! switches the lists share. +//! `/setidletimeout`, `/transfer` and `/whitelist` (OpCommand, DeOpCommands, +//! BanPlayerCommands, PardonCommand, BanIpCommands, PardonIpCommand, +//! BanListCommands, SetPlayerIdleTimeoutCommand, TransferCommand, +//! WhitelistCommand): commands of a dedicated server, run on its lists by an +//! operator in game (the console has its own handlers in game/dedicated.rs). +//! Also `/stop`, `/save-all`, `/save-off` and `/save-on` (StopCommand, +//! SaveAllCommand, SaveOffCommand, SaveOnCommand), on the switches the lists +//! share. use super::{Arg, Context, Game, R, Target, err}; use horizon_client::{ server::user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry}, @@ -332,6 +333,23 @@ impl Game<'_> { Ok(players.len() as i32) } + /// `SetPlayerIdleTimeoutCommand.setIdleTimeout`: the minutes of idleness + /// after which a player is kicked (`multiplayer.disconnect.idling`), 0 for + /// never. The reply names the minutes, or says the timeout is off, and the + /// command's result is the number of minutes. + pub(super) fn cmd_setidle_timeout(&mut self, ctx: &Context) -> R { + let lists = self.dedicated_lists()?; + let Some(Arg::Int(minutes)) = ctx.get("minutes") else { + return Ok(0); + }; + let minutes = *minutes; + // DedicatedServer.setPlayerIdleTimeout: the live sweep reads it, and + // server.properties keeps it. + self.set_player_idle_timeout(&lists, minutes); + self.feedback(idle_timeout_reply(minutes)); + Ok(minutes) + } + /// `TransferCommand.register` and `TransferCommand.transfer`: each target's /// client is handed to `hostname:port` (`ClientboundTransferPacket`), and /// the source is told who went — the player's name for one, the count for @@ -532,3 +550,42 @@ impl Game<'_> { } } } + +/// `SetPlayerIdleTimeoutCommand.setIdleTimeout`'s reply: the minutes, or +/// `commands.setidletimeout.success.disabled` when the timeout is off. +fn idle_timeout_reply(minutes: i32) -> Text { + if minutes > 0 { + Text::translate( + "commands.setidletimeout.success", + &[Text::literal(minutes.to_string())], + ) + } else { + Text::translate("commands.setidletimeout.success.disabled", &[]) + } +} + +#[cfg(test)] +mod tests { + use super::idle_timeout_reply; + + /// `SetPlayerIdleTimeoutCommand.setIdleTimeout`: minutes above zero are + /// named in `commands.setidletimeout.success`, and zero turns the timeout + /// off with `commands.setidletimeout.success.disabled`. + #[test] + fn the_idle_timeout_reply_names_the_minutes_or_says_it_is_off() { + let reply = idle_timeout_reply(5); + assert_eq!( + reply.translation_key(), + Some("commands.setidletimeout.success"), + "{reply:?}" + ); + assert_eq!(reply.component()["with"], serde_json::json!([5])); + let off = idle_timeout_reply(0); + assert_eq!( + off.translation_key(), + Some("commands.setidletimeout.success.disabled") + ); + // The disabled message takes no argument. + assert!(off.component().get("with").is_none(), "{off:?}"); + } +} diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index f4301a1ecf..c88196eb75 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -1960,8 +1960,19 @@ impl Game<'_> { MobEvent::SoundId(name, at, volume, pitch) => { self.audio.play(&name, Some(at), volume, pitch) } - // The sculk layer tracks the game's steps itself - // (`entity_motion_events`). + MobEvent::GameEvent { + event, at, source, .. + } if event == "minecraft:flap" + && self.mobs.iter().any(|m| { + m.id == source && matches!(m.species(), Species::Chicken | Species::Parrot) + }) => + { + let ctx = self.who_context(Some(Who::Mob(source)), None); + self.post_game_event(event, at, ctx); + } + // The sculk layer tracks steps and bee/bat flaps itself + // (`entity_motion_events`, `tick_mob_sculk_events`). Birds + // above use their movement's flyDist/nextFlap threshold. MobEvent::GameEvent { .. } => {} MobEvent::AttackPlayer(amount, from) => { self.mob_hits_player(amount, from, "minecraft:mob_attack"); diff --git a/crates/horizon-client/src/game/debug_lines.rs b/crates/horizon-client/src/game/debug_lines.rs index 3629872c41..983a6db007 100644 --- a/crates/horizon-client/src/game/debug_lines.rs +++ b/crates/horizon-client/src/game/debug_lines.rs @@ -5,7 +5,9 @@ //! lines (`ServerDebug`); on another server they show what vanilla shows //! there, nothing or `??`. use super::Game; -use horizon_client::{debug_entries::ENTRIES, debug_overlay as overlay, debug_overlay::Displayer}; +use horizon_client::{ + debug_entries::ENTRIES, debug_overlay as overlay, debug_overlay::Displayer, java_text, +}; use horizon_world::{ heightmap::{HeightmapState, HeightmapType}, worldgen::terrain::TerrainBlockStates, @@ -125,7 +127,10 @@ impl Game<'_> { let speed = self.known_player_movement(); d.group( "position", - [format!("Speed: {:.3} blocks/tick", speed.length())], + [format!( + "Speed: {} blocks/tick", + java_text::format_fixed(speed.length(), 3) + )], ); } } diff --git a/crates/horizon-client/src/game/dedicated.rs b/crates/horizon-client/src/game/dedicated.rs index ca5a423f8f..512ac7dd58 100644 --- a/crates/horizon-client/src/game/dedicated.rs +++ b/crates/horizon-client/src/game/dedicated.rs @@ -433,6 +433,10 @@ pub(super) struct Lists { pub enforce_whitelist: bool, /// `op-permission-level`: what `/op` grants (operatorUserPermissions). pub op_level: i32, + /// `MinecraftServer.playerIdleTimeout`: the minutes of idleness after + /// which a player is kicked, `player-idle-timeout` at the start and + /// `/setidletimeout` after it. + pub player_idle_timeout: i32, /// `/stop` (MinecraftServer.halt): the server loop ends after this tick. pub stop: bool, /// `MinecraftServer.isAutoSave`: all of Horizon's server levels share this save flag. @@ -448,6 +452,7 @@ impl Lists { use_whitelist: false, enforce_whitelist: false, op_level: 4, + player_idle_timeout: 0, stop: false, autosave: true, }; @@ -665,8 +670,6 @@ struct ServerState { empty_ticks: i32, /// The mutable `DedicatedServer.pauseWhenEmptySeconds` setting. pause_when_empty_seconds: i32, - /// The mutable `DedicatedServer.playerIdleTimeout` setting, in minutes. - player_idle_timeout: i32, /// When the last passes of the loop ended, for the tick rate horizon-server shows. ticks: std::collections::VecDeque, paused: bool, @@ -863,6 +866,7 @@ impl Game<'_> { lists.use_whitelist = props.white_list; lists.enforce_whitelist = props.enforce_whitelist; lists.op_level = props.op_permission_level; + lists.player_idle_timeout = props.player_idle_timeout; let lists = Arc::new(Mutex::new(lists)); self.server_lists = Some(lists.clone()); log("INFO", &format!("Preparing level \"{level_name}\"")); @@ -1075,7 +1079,6 @@ impl Game<'_> { console, empty_ticks: 0, pause_when_empty_seconds: props.pause_when_empty_seconds, - player_idle_timeout: props.player_idle_timeout, ticks: Default::default(), paused: false, broadcast_console: props.broadcast_console_to_ops, @@ -1178,8 +1181,11 @@ impl Game<'_> { { host.hub.send_low_disk_space_warning(); } - if state.player_idle_timeout > 0 { - self.kick_idle(state.player_idle_timeout); + // `PlayerList.tick` reads `MinecraftServer.playerIdleTimeout` + // every tick, so `/setidletimeout` changes it while the server runs. + let idle_timeout = state.lists.lock().unwrap().player_idle_timeout; + if idle_timeout > 0 { + self.kick_idle(idle_timeout); } // MinecraftServer.tickServer records each tick's time, for /tick query (and the status). self.clock.record(tick_start.elapsed()); @@ -1872,8 +1878,8 @@ impl Game<'_> { } /// `DedicatedServer.setPlayerIdleTimeout`: update the live idle sweep and persist its minutes. - fn set_player_idle_timeout(&mut self, state: &mut ServerState, minutes: i32) { - state.player_idle_timeout = minutes; + pub(super) fn set_player_idle_timeout(&mut self, lists: &Arc>, minutes: i32) { + lists.lock().unwrap().player_idle_timeout = minutes; self.store_property("player-idle-timeout", minutes.to_string()); } diff --git a/crates/horizon-client/src/game/dedicated/management.rs b/crates/horizon-client/src/game/dedicated/management.rs index eecf85e094..18c8fe82f9 100644 --- a/crates/horizon-client/src/game/dedicated/management.rs +++ b/crates/horizon-client/src/game/dedicated/management.rs @@ -204,7 +204,7 @@ impl Game<'_> { return json!({ "value": state.force_game_mode }); } "serversettings/player_idle_timeout" => { - return json!({ "value": state.player_idle_timeout }); + return json!({ "value": state.lists.lock().unwrap().player_idle_timeout }); } "serversettings/player_idle_timeout/set" => { let Some(minutes) = body["value"] @@ -215,10 +215,11 @@ impl Game<'_> { }; rpc(format!( "Update player idle timeout from {} minutes to {} minutes", - state.player_idle_timeout, minutes + state.lists.lock().unwrap().player_idle_timeout, + minutes )); - self.set_player_idle_timeout(state, minutes); - return json!({ "value": state.player_idle_timeout }); + self.set_player_idle_timeout(&state.lists.clone(), minutes); + return json!({ "value": state.lists.lock().unwrap().player_idle_timeout }); } "serversettings/max_players" => { return json!({ "value": state.max_players }); diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index 98343ff890..ba0c6bc884 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -975,9 +975,22 @@ impl Game<'_> { self.handle_mob_events(events) } /// A mob a bucket emptied: `setFromBucket` keeps it, then it calls out. + /// Fish and axolotls carry the flag itself (saved as `FromBucket`, read by + /// `requiresCustomPersistence`); the others stay persistent instead. pub(super) fn mob_placed(&mut self, id: u32) -> Result<()> { if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) { - m.persistent = true; + if matches!( + m.species(), + Species::Cod + | Species::Salmon + | Species::Pufferfish + | Species::TropicalFish + | Species::Axolotl + ) { + m.extra.from_bucket = true; + } else { + m.persistent = true; + } } self.mob_ambient(id) } diff --git a/crates/horizon-client/src/game/entity.rs b/crates/horizon-client/src/game/entity.rs index 72e0743bc5..448d0d6467 100644 --- a/crates/horizon-client/src/game/entity.rs +++ b/crates/horizon-client/src/game/entity.rs @@ -561,6 +561,7 @@ impl Game<'_> { .chain(self.item_frames.iter().map(|e| e.id)) .chain(self.paintings.iter().map(|e| e.id)) .chain(self.others.iter().map(|e| e.id)) + .chain(self.fangs.list.iter().map(|e| e.id)) .chain(self.crystals.iter().map(|e| e.id)) .chain(self.dragon.iter().map(|e| e.id)) .chain(self.ominous_spawners.iter().map(|e| e.id)) diff --git a/crates/horizon-client/src/game/fangs.rs b/crates/horizon-client/src/game/fangs.rs index b8e2370048..e38eb68c5d 100644 --- a/crates/horizon-client/src/game/fangs.rs +++ b/crates/horizon-client/src/game/fangs.rs @@ -10,9 +10,9 @@ pub(super) struct Fangs { pub id: u32, pub pos: [f64; 3], pub yaw: f32, - warmup: i32, + pub warmup: i32, pub life: i32, - owner: u32, + pub owner: u32, pub started: bool, } #[derive(Default)] @@ -40,6 +40,24 @@ impl Game<'_> { started: false, }); } + pub(super) fn fangs_to_nbt(&self, f: &Fangs, uuid: [i32; 4]) -> horizon_world::nbt::Tag { + horizon_client::entity_nbt::evoker_fangs_to_nbt( + Vec3::new(f.pos[0], f.pos[1], f.pos[2]), + f.yaw, + f.warmup, + self.persist.uuids.get(&f.owner).copied(), + uuid, + self.persist.raw.get(&f.id), + ) + } + pub(super) fn loaded_fangs_owner(&self, tag: &horizon_world::nbt::Tag) -> u32 { + match tag.get("Owner") { + Some(horizon_world::nbt::Tag::IntArray(v)) => v.as_slice().try_into().ok(), + _ => None, + } + .and_then(|u| self.mob_by_uuid(u)) + .unwrap_or(0) + } /// `EvokerFangs.handleEntityEvent`: only event 4 starts a client attack. pub(super) fn remote_fangs_event(&mut self, id: u32, event: i8) { if event == 4 @@ -68,7 +86,7 @@ impl Game<'_> { pub(super) fn tick_fangs(&mut self) { let mut list = std::mem::take(&mut self.fangs.list); list.retain_mut(|f| { - f.warmup -= 1; + f.warmup = f.warmup.wrapping_sub(1); if f.warmup >= 0 { return true; } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 050a14dc5e..c379390f7c 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -2461,6 +2461,9 @@ impl Game<'_> { // PlayerEvent.LoadFromFile: the player's data is read and applied, // the player not yet placed. self.native_player_load_from_file(g.uuid, &g.name, &playerdata::directory(&root)); + // PrepareSpawnTask: with no saved rotation the player faces the world + // spawn's angle (`/setworldspawn ... `). + playerdata::face_world_spawn(g, known, self.level.world_spawn_angle); if !known || force { g.game_mode = default_mode; } diff --git a/crates/horizon-client/src/game/levels.rs b/crates/horizon-client/src/game/levels.rs index cd15291eb0..0826c2a833 100644 --- a/crates/horizon-client/src/game/levels.rs +++ b/crates/horizon-client/src/game/levels.rs @@ -53,6 +53,7 @@ pub(super) struct Parked { armor_stands: Vec, cushions: Vec, others: Vec, + fangs: super::fangs::FangState, pub(super) boats: Vec, pub(super) carts: Vec, jukebox_sounds: HashMap<(i32, i32, i32), u64>, @@ -199,6 +200,7 @@ impl Game<'_> { armor_stands: Vec::new(), cushions: Vec::new(), others: Vec::new(), + fangs: Default::default(), boats: Vec::new(), carts: Vec::new(), jukebox_sounds: HashMap::new(), @@ -255,6 +257,7 @@ impl Game<'_> { swap(&mut self.armor_stands, &mut p.armor_stands); swap(&mut self.cushions, &mut p.cushions); swap(&mut self.others, &mut p.others); + swap(&mut self.fangs, &mut p.fangs); swap(&mut self.boats, &mut p.boats); swap(&mut self.carts, &mut p.carts); swap(&mut self.jukebox_sounds, &mut p.jukebox_sounds); diff --git a/crates/horizon-client/src/game/native_entities.rs b/crates/horizon-client/src/game/native_entities.rs index f7c351773b..2b7edc3278 100644 --- a/crates/horizon-client/src/game/native_entities.rs +++ b/crates/horizon-client/src/game/native_entities.rs @@ -112,6 +112,7 @@ fn target_id(t: Target) -> Option { | Target::Boat(id) | Target::Cart(id) | Target::Other(id) + | Target::Fangs(id) | Target::Crystal(id) | Target::Dragon(id) | Target::Tnt(id) diff --git a/crates/horizon-client/src/game/options.rs b/crates/horizon-client/src/game/options.rs index 2999bc0854..56267d2adb 100644 --- a/crates/horizon-client/src/game/options.rs +++ b/crates/horizon-client/src/game/options.rs @@ -372,6 +372,16 @@ impl Game<'_> { let _ = std::fs::create_dir_all(&dir); super::packs::open_folder(&dir); } + // Horizon's own Add button: the screen picked a hit, the game + // owns the folder it lands in. + Event::DownloadPack(i) => { + let dir = super::packs::packs_dir(); + if let Some(screen) = &mut self.ui.options.pack_screen + && let Some(browser) = &mut screen.browser + { + browser.download(i, &dir, horizon_core::VANILLA_VERSION); + } + } Event::DataPacks(ids) => { let resources = self.vanilla_root(); if let Some(title) = &mut self.title { diff --git a/crates/horizon-client/src/game/packs.rs b/crates/horizon-client/src/game/packs.rs index 791fe6a2e1..50d7821434 100644 --- a/crates/horizon-client/src/game/packs.rs +++ b/crates/horizon-client/src/game/packs.rs @@ -7,7 +7,7 @@ use horizon_client::{ assets::Assets, gfx::PreparedTextures, hud::HudAtlas, - lang, + lang, pack_browser, pack_screen::{self, Compat, PackInfo, Source as PackSource}, packs::{self, Pack, Position}, streaming::WorkerContext, @@ -169,7 +169,9 @@ impl Game<'_> { /// (`PackSelectionScreen` makes its `PackSelectionModel` from them). pub(super) fn refresh_pack_lists(&mut self) { let infos = pack_infos(&self.vanilla_root(), &self.server_packs.active); + let browsing = self.settings.extras.pack_browser; let o = &mut self.ui.options; + o.pack_browser = browsing; o.pack_infos = infos; o.pack_selection = self.settings.resource_packs.clone(); o.pack_screen = None; @@ -183,6 +185,41 @@ impl Game<'_> { /// a worker under the progress overlay (`LoadingOverlay` over /// `ReloadableResourceManager.createReload`'s background executor), so /// the window keeps drawing; `drive_pack_reload` applies it. + /// Horizon's pack browser: what its worker finished since the last + /// frame. A pack that landed becomes an entry in the available list. + pub(super) fn drive_pack_browser(&mut self) { + let Some(screen) = &mut self.ui.options.pack_screen else { + return; + }; + let Some(browser) = &mut screen.browser else { + return; + }; + let Some(message) = browser.poll() else { + return; + }; + if let pack_browser::Message::Results(_) = &message { + // The rows draw their icons as they land. + browser.fetch_icons(); + } + if let pack_browser::Message::Icon(id, png) = &message { + let key = pack_screen::icon_texture(id); + if let Some(icon) = pack_screen::load_fetched_icon(png) { + // Tracked, so closing the browser frees it again. + screen.keep_browser_icon(key, icon); + } + return; + } + if let pack_browser::Message::Downloaded(name) = message { + log::info!("Horizon: added the resource pack {name}"); + // Rebuilding the screen would shut the list; keep it and only + // refresh what the lists read. + let selection = self.settings.resource_packs.clone(); + let infos = pack_infos(&self.vanilla_root(), &self.server_packs.active); + self.ui.options.pack_infos = infos; + self.ui.options.pack_selection = selection; + self.ui.options.pack_screen = None; + } + } 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. diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 4edac1d858..df15e70266 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -417,6 +417,22 @@ impl Game<'_> { tag: Tag, ) -> std::result::Result<(), Tag> { match kind { + "minecraft:evoker_fangs" => { + if let Some(at) = entity_nbt::position(&tag) { + self.next_entity += 1; + let id = self.next_entity; + let delay = horizon_client::save::number(tag.get("Warmup")).unwrap_or(0) as i32; + let owner = self.loaded_fangs_owner(&tag); + self.insert_fangs( + id, + [at.x, at.y, at.z], + entity_nbt::rotation(&tag)[0], + delay, + owner, + ); + self.remember(id, uuid, tag); + } + } "minecraft:ominous_item_spawner" => self.load_ominous_item_spawner(tag), "minecraft:ender_dragon" => self.load_dragon(tag, uuid), "minecraft:end_crystal" => self.load_crystal(tag, uuid), @@ -1313,6 +1329,20 @@ impl Game<'_> { out.entry(chunk_of(at)).or_default().push(tag); } } + for f in &self.fangs.list { + let at = Vec3::new(f.pos[0], f.pos[1], f.pos[2]); + if keys.contains(&chunk_of(at)) { + let uuid = self + .persist + .uuids + .get(&f.id) + .copied() + .unwrap_or_else(random_uuid); + out.entry(chunk_of(at)) + .or_default() + .push(self.fangs_to_nbt(f, uuid)); + } + } for o in &self.others { if keys.contains(&chunk_of(o.position)) { let uuid = self @@ -1364,6 +1394,13 @@ impl Game<'_> { } keep }); + self.fangs.list.retain(|f| { + let keep = !gone(Vec3::new(f.pos[0], f.pos[1], f.pos[2])); + if !keep { + removed.push(f.id); + } + keep + }); self.others.retain(|o| { let keep = !gone(o.position); if !keep { diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index facf85552d..a1fd1333aa 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -2968,9 +2968,10 @@ impl Game<'_> { /// `ClientPacketListener.handleUpdateAttributes` for a mob, each value from /// `AttributeInstance.calculateValue`: its maximum health (the hearts drawn - /// over its mount), and a player's or mannequin's `minecraft:scale`, which - /// scales its model (`LivingEntityRenderState.scale`) as the local player's - /// does: NPC plugins make their fake players bigger with it. + /// over its mount), and its `minecraft:scale`, which sizes its box + /// (`LivingEntity.getDimensions`) and model (`LivingEntityRenderState.scale`) + /// as the local player's does: NPC plugins make their fake players bigger + /// with it, datapacks scale any mob. fn remote_mob_attributes(&mut self, id: i32, values: &[play::AttributeSnapshot]) { let catalog = &self.motion.attributes; let value = |name: &str| { diff --git a/crates/horizon-client/src/game/scoreboard.rs b/crates/horizon-client/src/game/scoreboard.rs index c5deafcd74..3429661135 100644 --- a/crates/horizon-client/src/game/scoreboard.rs +++ b/crates/horizon-client/src/game/scoreboard.rs @@ -184,6 +184,7 @@ impl Game<'_> { | Target::Rocket(id) | Target::Trident(id) | Target::Other(id) + | Target::Fangs(id) | Target::Cloud(id) => Some(uuid_text(self.uuid_for(id))), // It keeps its UUID with itself. Target::Spawner(id) => self @@ -230,6 +231,7 @@ impl Game<'_> { | Target::Rocket(id) | Target::Trident(id) | Target::Other(id) + | Target::Fangs(id) | Target::Cloud(id) => self.persist.raw.get(&id), Target::Player => self.level.player_extra.as_ref(), // A server's player: the saved compound the guest's file is written from. diff --git a/crates/horizon-client/src/game/sculk.rs b/crates/horizon-client/src/game/sculk.rs index b1b66e5712..d75edd6550 100644 --- a/crates/horizon-client/src/game/sculk.rs +++ b/crates/horizon-client/src/game/sculk.rs @@ -1978,9 +1978,8 @@ pub(super) fn darkness_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32 (factor, factor) } -/// `Entity.processFlappingMovement`'s per-tick gate, before its air-below -/// test: `Bee.isFlapping` (flying, every other tick) and `Bat.isFlapping` -/// (awake, every tenth tick). +/// `Bee.isFlapping` (flying, every other tick) and `Bat.isFlapping` +/// (awake, every tenth tick). Bird distance thresholds run in mob movement. fn flaps(species: Species, resting: bool, on_ground: bool, age: u32) -> bool { match species { Species::Bee => !on_ground && age.is_multiple_of(2), diff --git a/crates/horizon-client/src/game/server_packs.rs b/crates/horizon-client/src/game/server_packs.rs index e23c29a32e..473d62418c 100644 --- a/crates/horizon-client/src/game/server_packs.rs +++ b/crates/horizon-client/src/game/server_packs.rs @@ -350,6 +350,7 @@ impl Game<'_> { return; } self.drive_pack_reload(); + self.drive_pack_browser(); // 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). diff --git a/crates/horizon-client/src/game/startup_menu.rs b/crates/horizon-client/src/game/startup_menu.rs index 3922ce705d..5ef722962c 100644 --- a/crates/horizon-client/src/game/startup_menu.rs +++ b/crates/horizon-client/src/game/startup_menu.rs @@ -535,6 +535,14 @@ impl Loading { let _ = std::fs::create_dir_all(&dir); packs::open_folder(&dir); } + Event::DownloadPack(i) => { + let dir = packs::packs_dir(); + if let Some(screen) = &mut self.menu.options.pack_screen + && let Some(browser) = &mut screen.browser + { + browser.download(i, &dir, horizon_core::VANILLA_VERSION); + } + } Event::DataPacks(ids) => { let resources = self.vanilla_root.clone(); if let Some(title) = &mut self.menu.title { diff --git a/crates/horizon-client/src/game/tutorial.rs b/crates/horizon-client/src/game/tutorial.rs index ee9e801bcb..dba1ea34b9 100644 --- a/crates/horizon-client/src/game/tutorial.rs +++ b/crates/horizon-client/src/game/tutorial.rs @@ -561,3 +561,42 @@ impl super::Game<'_> { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn craft_planks_hint_toast_appears_after_delay_and_clears_on_planks() { + let mut tutorial = Tutorial::new(Step::CraftPlanks); + tutorial.waiting = 1199; + assert_eq!(tutorial.hint(), None); + + tutorial.waiting = 1200; + assert_eq!(tutorial.hint(), Some((Hint::CraftPlanks, None))); + + // Receiving an unrelated item (e.g. log or stick) does not complete the step + let changed = tutorial.get_item(false, true, false); + assert!(!changed); + assert_eq!(tutorial.step, Step::CraftPlanks); + assert_eq!(tutorial.hint(), Some((Hint::CraftPlanks, None))); + + // Receiving planks completes the tutorial step and dismisses the hint toast + let changed = tutorial.get_item(false, false, true); + assert!(changed); + assert_eq!(tutorial.step, Step::None); + assert_eq!(tutorial.hint(), None); + } + + #[test] + fn initial_tick_with_planks_clears_craft_planks_step() { + let mut tutorial = Tutorial::new(Step::CraftPlanks); + let previous = Previous { + plank: true, + ..Default::default() + }; + assert!(tutorial.tick(true, &previous)); + assert_eq!(tutorial.step, Step::None); + assert_eq!(tutorial.hint(), None); + } +} diff --git a/crates/horizon-client/src/lan/menu.rs b/crates/horizon-client/src/lan/menu.rs index 0e0afa739e..33fd3a7909 100644 --- a/crates/horizon-client/src/lan/menu.rs +++ b/crates/horizon-client/src/lan/menu.rs @@ -11,7 +11,7 @@ use crate::{ world::World, }; -/// What the menus need to know about items. +/// What the menus need to know about items and the slots' rules. pub trait Rules { fn items(&self) -> &Items; fn max_stack(&self, item: &str) -> u32; @@ -22,6 +22,18 @@ pub trait Rules { fn equip_slot(&self, item: &str) -> Option<&str> { self.items().get(item)?.equip_slot.as_deref() } + /// `Slot.mayPickup`: whether the stack in menu slot `slot` may be taken + /// out. A block container's slots and the player's own allow it; a mod's + /// menu can refuse. + fn may_pickup(&self, _slot: usize, _stack: &ItemStack) -> bool { + true + } + /// `Player.canDropItems`: a client that drops items faster than its + /// throttle allows stops sending THROW clicks (`LocalPlayer.canDropItems` + /// is its `dropSpamThrottler`'s answer). + fn can_drop_items(&self) -> bool { + true + } } /// An open block container. @@ -252,6 +264,23 @@ impl View<'_> { } rules.max_stack(&stack.item).max(1) } + /// `Slot.mayPickup`: whether menu slot `i` gives its stack up. The + /// armour slots refuse while a survival player wears a binding curse + /// (`ArmorSlot.mayPickup`: `PREVENT_ARMOR_CHANGE` and no creative); the + /// menu's rules answer for their own slots. + fn may_pickup(&self, i: usize, rules: &dyn Rules) -> bool { + let stack = self.get(i); + if self.container.is_none() + && (5..=8).contains(&i) + && !self.guest.creative() + && stack + .as_ref() + .is_some_and(|s| s.enchantment_level("minecraft:binding_curse") > 0) + { + return false; + } + stack.is_none_or(|s| rules.may_pickup(i, &s)) + } /// The ranges quick move uses: (container start..end, main, hotbar). fn player_ranges(&self) -> (std::ops::Range, std::ops::Range) { match &self.container { @@ -437,8 +466,15 @@ pub fn click( } input::PICKUP => pickup(view, slot, button, rules), input::QUICK_MOVE => { + // doClick calls quickMoveStack again while the clicked slot + // still holds the item it moved, because moveItemStackTo fills + // one empty slot per call. move_to here fills every empty slot + // of its range in one pass and leaves the same remainder, so + // one call is vanilla's loop: a full inventory keeps its stack, + // and a stack too big for one slot spreads over several. if let Ok(i) = usize::try_from(slot) && i < view.len() + && view.may_pickup(i, rules) { quick_move(view, i, rules); } @@ -458,7 +494,8 @@ pub fn click( } } input::THROW => { - if view.guest.carried.is_none() + if rules.can_drop_items() + && view.guest.carried.is_none() && let Ok(i) = usize::try_from(slot) && view.enabled(i) && let Some(mut stack) = view.get(i) @@ -517,6 +554,11 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) { } } (Some(mut s), None) => { + // doClick's PICKUP arm takes from the slot only while + // `Slot.mayPickup` allows it. + if !view.may_pickup(i, rules) { + return; + } let take = if right { s.count.div_ceil(2) } else { s.count }; let taken = ItemStack { count: take, @@ -527,6 +569,12 @@ fn pickup(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) { view.guest.carried = Some(taken); } (Some(mut s), Some(mut c)) => { + // Merging into, swapping with and topping the hand up from the + // slot are all takes (`allowModification`: mayPickup and + // mayPlace). + if !view.may_pickup(i, rules) { + return; + } if !view.may_place(i, &c, rules) { // A slot that takes nothing gives onto the same carried item, // as far as the hand holds (`tryRemove`). @@ -679,10 +727,20 @@ fn swap(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) { } fn pickup_all(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) { - let _ = slot; + let Ok(i) = usize::try_from(slot) else { + return; + }; + if i >= view.len() { + return; + } let Some(mut carried) = view.guest.carried.clone() else { return; }; + // doClick returns when the clicked slot holds a stack it may pick up, so + // a double-click collects from an empty slot or one that refuses. + if view.get(i).is_some() && view.may_pickup(i, rules) { + return; + } let max = rules.max_stack(&carried.item).max(1); // Button 0 walks the slots from the first, button 1 from the last. let order: Vec = if button == 0 { @@ -699,6 +757,10 @@ fn pickup_all(view: &mut View, slot: i32, button: i32, rules: &dyn Rules) { if !view.enabled(i) || (view.container.is_none() && i == 0) { continue; } + // Each swept slot must give its stack up too. + if !view.may_pickup(i, rules) { + continue; + } let Some(mut s) = view.get(i) else { continue; }; @@ -1297,6 +1359,172 @@ mod tests { ); } + /// Rules that refuse a pickup in some menu slots and can stop drops. + struct StrictRules { + items: Items, + refuse: &'static [usize], + drops: bool, + } + impl StrictRules { + fn new(refuse: &'static [usize]) -> Self { + Self { + items: Items::for_tests(Default::default()), + refuse, + drops: true, + } + } + } + impl Rules for StrictRules { + fn items(&self) -> &Items { + &self.items + } + fn max_stack(&self, _: &str) -> u32 { + 64 + } + fn armor_index(&self, item: &str) -> Option { + (item == "minecraft:diamond_chestplate").then_some(1) + } + fn may_pickup(&self, slot: usize, _: &ItemStack) -> bool { + !self.refuse.contains(&slot) + } + fn can_drop_items(&self) -> bool { + self.drops + } + } + + /// `Slot.mayPickup`: a pickup and a quick move leave a slot's stack alone + /// when the menu's rules refuse it there, while the slot next to it still + /// gives its own stack up. + #[test] + fn guest_pickup_and_quick_move_need_the_slots_consent() { + let rules = StrictRules::new(&[0]); + let mut g = guest(); + let mut chest = vec![ + Some(ItemStack::new("minecraft:stone", 5)), + Some(ItemStack::new("minecraft:dirt", 2)), + ]; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut chest), + shulker: false, + hidden: &[], + }; + // The refused slot takes no click and gives nothing to the cursor. + click(&mut view, &mut drag, 0, 0, input::PICKUP, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + assert_eq!(view.guest.carried, None); + // A quick move out of it takes nothing with it either. + click(&mut view, &mut drag, 0, 0, input::QUICK_MOVE, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + assert!(view.get(2).is_none()); + // The slot next to it takes both clicks. + click(&mut view, &mut drag, 1, 0, input::PICKUP, &rules); + assert!(view.get(1).is_none()); + assert_eq!( + view.guest.carried.as_ref().map(|s| (&*s.item, s.count)), + Some(("minecraft:dirt", 2)) + ); + let back = view.guest.carried.take(); + view.set(1, back); + click(&mut view, &mut drag, 1, 0, input::QUICK_MOVE, &rules); + // A container's quick move fills the inventory from its end. + assert_eq!( + view.get(37).as_ref().map(|s| &*s.item), + Some("minecraft:dirt") + ); + } + + /// `doClick`'s PICKUP_ALL arm returns when the clicked slot holds a stack + /// it may pick up, so a double-click collects from an empty slot, or from + /// one that refuses, only. + #[test] + fn guest_pickup_all_starts_from_an_empty_or_refusing_slot() { + let rules = StrictRules::new(&[]); + let mut g = guest(); + let mut chest = vec![Some(ItemStack::new("minecraft:stone", 5)), None]; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut chest), + shulker: false, + hidden: &[], + }; + // One stone short of a full stack, over a slot that holds stone. + view.guest.carried = Some(ItemStack::new("minecraft:stone", 63)); + click(&mut view, &mut drag, 0, 1, input::PICKUP_ALL, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(63)); + // The empty slot next to it does collect that one stone. + click(&mut view, &mut drag, 1, 1, input::PICKUP_ALL, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(4)); + assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(64)); + // A refusing slot is where a collect may start: the stone comes from + // the slot that allows it, the refused one is left alone. + let refusing = StrictRules::new(&[0]); + view.set(0, Some(ItemStack::new("minecraft:stone", 5))); + view.set(1, Some(ItemStack::new("minecraft:stone", 5))); + view.guest.carried = Some(ItemStack::new("minecraft:stone", 63)); + click(&mut view, &mut drag, 0, 1, input::PICKUP_ALL, &refusing); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + assert_eq!(view.get(1).map(|s| s.count), Some(4)); + assert_eq!(view.guest.carried.as_ref().map(|s| s.count), Some(64)); + } + + /// `Player.canDropItems`: a client that has dropped too fast sends THROW + /// clicks the menu does not act on. + #[test] + fn guest_throw_needs_can_drop_items() { + let mut rules = StrictRules::new(&[]); + rules.drops = false; + let mut g = guest(); + let mut chest = vec![Some(ItemStack::new("minecraft:stone", 5))]; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut chest), + shulker: false, + hidden: &[], + }; + let (drops, _) = click(&mut view, &mut drag, 0, 0, input::THROW, &rules); + assert!(drops.is_empty()); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + rules.drops = true; + let (drops, _) = click(&mut view, &mut drag, 0, 0, input::THROW, &rules); + assert_eq!(drops.iter().fold(0, |n, s| n + s.count), 1); + assert_eq!(view.get(0).map(|s| s.count), Some(4)); + } + + /// `ArmorSlot.mayPickup`: a survival guest cannot take cursed armour off; + /// a creative one can, by a click and by a shift-click. + #[test] + fn guest_survival_cannot_remove_cursed_armour() { + let rules = StrictRules::new(&[]); + for (game_mode, taken) in [ + (crate::lan::guest::SURVIVAL, false), + (crate::lan::guest::CREATIVE, true), + ] { + for mode in [input::PICKUP, input::QUICK_MOVE] { + let mut g = guest(); + let mut cursed = ItemStack::new("minecraft:diamond_chestplate", 1); + cursed.set_crafting_enchantments(vec![("minecraft:binding_curse".into(), 1)]); + g.armor[1] = Some(cursed); + g.game_mode = game_mode; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: None, + shulker: false, + hidden: &[], + }; + // Menu slot 6 is the chest slot. + click(&mut view, &mut drag, 6, 0, mode, &rules); + assert_eq!(view.get(6).is_some(), !taken); + assert_eq!(view.guest.carried.is_some() || view.get(9).is_some(), taken); + } + } + } + struct ArmorRules; impl Rules for ArmorRules { fn items(&self) -> &Items { @@ -1354,6 +1582,67 @@ mod tests { assert!(view.get(45).is_none()); } + /// `doClick` repeats `quickMoveStack` while the clicked slot keeps the + /// item, because `moveItemStackTo` fills one empty slot per call. This + /// engine's `move_to` fills every empty slot of its range in one pass, so a + /// single quick move must spread a stack over several slots with the same + /// remainder vanilla's loop leaves, and a full inventory must leave the + /// stack where it is. + #[test] + fn one_quick_move_spreads_a_stack_like_vanillas_repeat() { + struct SmallRules; + impl Rules for SmallRules { + fn items(&self) -> &Items { + unreachable!("a quick move reads no item catalog"); + } + fn max_stack(&self, item: &str) -> u32 { + if item == "minecraft:snowball" { 16 } else { 64 } + } + fn armor_index(&self, _: &str) -> Option { + None + } + } + let rules = SmallRules; + let mut g = guest(); + let mut chest = vec![Some(ItemStack::new("minecraft:snowball", 40))]; + let mut drag = None; + let mut view = View { + guest: &mut g, + container: Some(&mut chest), + shulker: false, + hidden: &[], + }; + // A container's quick move fills the inventory from its end: 16 + // snowballs in the last hotbar slot (menu slot 36), 16 in the one + // before it, 8 in the third, and nothing left in the chest. + click(&mut view, &mut drag, 0, 0, input::QUICK_MOVE, &rules); + assert!(view.get(0).is_none()); + assert_eq!(view.get(36).map(|s| s.count), Some(16)); + assert_eq!(view.get(35).map(|s| s.count), Some(16)); + assert_eq!(view.get(34).map(|s| s.count), Some(8)); + // Two slots' worth of room only: the loop vanilla runs would stop + // after the second call, so 8 stay in the chest. + for i in 0..36 { + view.guest.inventory[i] = Some(ItemStack::new("minecraft:dirt", 1)); + } + view.set(0, Some(ItemStack::new("minecraft:snowball", 40))); + // The fill starts at the hotbar's end: menu slots 36 and 35. + view.guest.inventory[8] = None; + view.guest.inventory[7] = None; + click(&mut view, &mut drag, 0, 0, input::QUICK_MOVE, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(8)); + assert_eq!(view.get(36).map(|s| s.count), Some(16)); + assert_eq!(view.get(35).map(|s| s.count), Some(16)); + // A full inventory moves nothing at all. + for i in 0..36 { + view.guest.inventory[i] = Some(ItemStack::new("minecraft:dirt", 1)); + } + view.set(0, Some(ItemStack::new("minecraft:snowball", 5))); + let (drops, changed) = click(&mut view, &mut drag, 0, 0, input::QUICK_MOVE, &rules); + assert_eq!(view.get(0).map(|s| s.count), Some(5)); + assert!(drops.is_empty() && changed.is_empty()); + } + /// `AbstractContainerMenu.isContainerClickButton`: a pickup and a quick /// move exist for buttons 0 and 1 only, so a modified client's other /// buttons take nothing out of a slot, move nothing to the hotbar and diff --git a/crates/horizon-client/src/lan/playerdata.rs b/crates/horizon-client/src/lan/playerdata.rs index 734362bf21..45d4901529 100644 --- a/crates/horizon-client/src/lan/playerdata.rs +++ b/crates/horizon-client/src/lan/playerdata.rs @@ -109,6 +109,17 @@ pub fn save_owner(guest: &Guest, level: &mut Level, world: &Path) { save(guest, world); } +/// A player new to the world faces the world spawn's angle (`PrepareSpawnTask`: +/// the saved rotation, else `respawnData.yaw/pitch`). +pub fn face_world_spawn(guest: &mut Guest, known: bool, [yaw, pitch]: [f32; 2]) { + if known { + return; + } + guest.yaw = yaw; + guest.pitch = pitch; + guest.head_yaw = yaw; +} + /// Give a guest the player state read into `level`. pub fn apply(guest: &mut Guest, level: &Level) { if let Some(p) = level.position { @@ -508,6 +519,25 @@ mod tests { ) } + #[test] + fn a_new_player_faces_the_world_spawn_angle() { + let mut g = guest(); + face_world_spawn(&mut g, false, [90.0, -15.0]); + assert_eq!((g.yaw, g.pitch, g.head_yaw), (90.0, -15.0, 90.0)); + } + + #[test] + fn a_saved_rotation_beats_the_world_spawn_angle() { + let mut g = guest(); + let level = Level { + rotation: [-135.0, 30.0], + ..Level::default() + }; + apply(&mut g, &level); + face_world_spawn(&mut g, true, [90.0, -15.0]); + assert_eq!((g.yaw, g.pitch, g.head_yaw), (-135.0, 30.0, -135.0)); + } + fn holding(uuid: u128, item: &str, count: u32) -> Guest { let mut g = guest(); g.uuid = uuid; diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index f3793a508c..16e7dd9100 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -118,7 +118,8 @@ use std::{collections::HashMap, fs, path::Path, sync::Arc}; pub struct Polygon { pub normal: [f32; 3], pub vertices: [[f32; 5]; 4], - /// The model's render type culls back faces (`entityCutoutCull`: the bat). + /// The model's render type culls back faces (`entityCutoutCull`: the bat, + /// the baby turtle). pub cull_back: bool, } impl Polygon { @@ -253,7 +254,7 @@ impl Part { for (i, x) in pose.iter().enumerate().take(9) { p[i] = *x; } - let polygons = v["polygons"] + let polygons: Vec = v["polygons"] .as_array() .into_iter() .flatten() @@ -430,6 +431,12 @@ pub(crate) fn exact_attributes(bytes: &[u8]) -> HashMap bool { + matches!(key, "minecraft:bat#main" | "minecraft:turtle_baby#main") +} + pub struct MobTypes { pub types: HashMap, } @@ -490,9 +497,13 @@ impl MobTypes { for prefix in def.layer_prefixes() { let start = format!("minecraft:{prefix}#"); for (k, v) in all.iter().filter(|(k, _)| k.starts_with(&start)) { + let mut part = Part::parse("root", v); + if culls_back_faces(k) { + part.set_cull_back(); + } layers.insert( k.trim_start_matches("minecraft:").to_owned(), - Arc::new(Part::parse("root", v)), + Arc::new(part), ); } } @@ -536,8 +547,7 @@ impl MobTypes { |model| { Arc::new({ let mut part = Part::parse("root", model); - // BatModel: `RenderTypes::entityCutoutCull`. - if def.name == "bat" { + if culls_back_faces(&model_key) { part.set_cull_back(); } part @@ -855,6 +865,8 @@ pub fn anger_left(anger_end: i64, game_time: i64) -> i32 { /// Species-specific persistent state (variants, colours, taming, ageing...). #[derive(Clone, Debug, Default)] pub struct Extra { + /// Saved fish/axolotl bucket flag; tadpoles save a constant true value. + pub from_bucket: bool, /// Command-created armor instances, shared by commands and runtime damage. pub armor_attributes: Option>, /// `Mob.lootTable`: the table its death rolls instead of its type's @@ -870,9 +882,10 @@ pub struct Extra { pub immovable: bool, /// `Mannequin.DATA_DESCRIPTION`: the text under its name. pub description: MannequinDescription, - /// A player's or mannequin's `minecraft:scale`, which scales the whole model - /// (`LivingEntityRenderState.scale`): the local player's own, and on a - /// server the others' from their attribute updates; 0 is the default, 1. + /// `LivingEntity`'s `minecraft:scale` (`getScale`), which scales its box + /// (`getDimensions`), eye height and model (`LivingEntityRenderState.scale`): + /// the local player's own, and on a server every living entity's from its + /// attribute updates; 0 is the default, 1 (`Mob::model_scale`). pub model_scale: f32, /// Players: crouching and riding (HumanoidModel's poses). pub crouching: bool, diff --git a/crates/horizon-client/src/mobs/ai.rs b/crates/horizon-client/src/mobs/ai.rs index 8179684bf5..6657ebecc3 100644 --- a/crates/horizon-client/src/mobs/ai.rs +++ b/crates/horizon-client/src/mobs/ai.rs @@ -1841,10 +1841,12 @@ pub fn despawn_check(m: &mut Mob, w: &MobWorld) -> bool { } /// `Mob.requiresCustomPersistence` and its overrides: a passenger or a leashed /// mob, a raider in a raid, an enderman carrying a block, a tame nautilus, a -/// sulfur cube holding a block or let out of a bucket. The spawn caps do not -/// count these mobs either (`NaturalSpawner.createState`). +/// sulfur cube holding a block or let out of a bucket, a fish or axolotl +/// let out of a bucket (`AbstractFish`/`Axolotl`: `|| fromBucket()`). The spawn +/// caps do not count these mobs either (`NaturalSpawner.createState`). pub fn requires_custom_persistence(m: &Mob) -> bool { super::riders::keeps_alive(m) + || m.extra.from_bucket || m.extra.leash.is_some() || super::raider::state(m).is_some_and(|s| s.raid_id.is_some()) || m.species() == Species::Enderman && m.extra.carried.is_some() @@ -2005,6 +2007,23 @@ pub fn voice_sounds(m: &Mob) -> [&'static str; 3] { "entity.shulker.hurt_closed", m.kind.sounds[2], ], + // Creaking.getHurtSound: bound to a heart it sways, unbound it hands + // the blow to `Monster.getHurtSound`. `Monster`'s own sounds are the + // hostile pair, which the giant keeps too. + Species::Creaking => [ + m.kind.sounds[0], + if super::creaking::heart_bound(m) { + "entity.creaking.sway" + } else { + "entity.hostile.hurt" + }, + m.kind.sounds[2], + ], + Species::Giant => [ + m.kind.sounds[0], + "entity.hostile.hurt", + "entity.hostile.death", + ], // Turtle.getHurtSound and getDeathSound by age. Species::Turtle if m.is_baby() => [ m.kind.sounds[0], diff --git a/crates/horizon-client/src/mobs/creaking.rs b/crates/horizon-client/src/mobs/creaking.rs index 52b10ee724..6ac841c855 100644 --- a/crates/horizon-client/src/mobs/creaking.rs +++ b/crates/horizon-client/src/mobs/creaking.rs @@ -434,3 +434,85 @@ mod portal_tests { assert!(crate::mobs::mob_can_use_portal(&wolf)); } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::mobs::sensor_scan_tests::kind; + use std::sync::Arc; + + /// A creaking with no heart bound to it, at the origin. + fn creaking() -> Mob { + let mut kind = kind(); + let k = Arc::make_mut(&mut kind); + k.id = "minecraft:creaking".into(); + k.species = Species::Creaking; + // The creaking row of the species table, hurt sound and all. + k.sounds = [ + "entity.creaking.ambient", + "entity.generic.hurt", + "entity.creaking.death", + ]; + Mob::new(1, kind, Vec3::ZERO, 0.0) + } + + /// The sounds a hit on `m` left in its outbox. + fn hurt_sounds(m: &Mob) -> Vec<&'static str> { + m.outbox + .iter() + .filter_map(|e| match e { + MobEvent::Sound(s, ..) => Some(*s), + _ => None, + }) + .collect() + } + + #[test] + fn a_creaking_with_no_heart_hurts_like_a_monster() { + // Creaking.hurtServer: without a home the blow goes to + // Monster.hurtServer, whose getHurtSound is the hostile hurt — not + // the generic one. + let mut m = creaking(); + assert!(m.damage(1.0, Damage::player([0.0, 0.0, 1.0]))); + assert_eq!( + hurt_sounds(&m), + ["entity.hostile.hurt"], + "an unbound creaking is hurt like a monster" + ); + } + + #[test] + fn a_heart_bound_creaking_sways_when_a_blow_gets_through() { + // Only #bypasses_invulnerability reaches a bound creaking + // (Creaking.hurtServer hands those straight to Monster), and that is + // the blow whose hurt sound Creaking.getHurtSound answers with the sway. + let mut m = creaking(); + m.extra.home = Some([0, 64, 0]); + assert!(m.damage(1.0, Damage::of(DamageKind::OutOfWorld))); + assert_eq!( + hurt_sounds(&m), + ["entity.creaking.sway"], + "a heart-bound creaking sways" + ); + } + + #[test] + fn a_giant_hurts_and_dies_like_a_monster() { + // Giant overrides none of Monster's sounds, so its hurt and death are + // the hostile pair. + let mut m = creaking(); + let k = Arc::make_mut(&mut m.kind); + k.id = "minecraft:giant".into(); + k.species = Species::Giant; + k.sounds = ["", "entity.generic.hurt", "entity.generic.death"]; + m.health = 100.0; + assert!(m.damage(1.0, Damage::player([0.0, 0.0, 1.0]))); + assert_eq!(hurt_sounds(&m), ["entity.hostile.hurt"], "a giant's hurt"); + m.outbox.clear(); + // The invulnerability frames are over: the next blow is a full one. + m.invulnerable = 0; + assert!(m.damage(200.0, Damage::player([0.0, 0.0, 1.0]))); + assert!(!m.alive()); + assert_eq!(hurt_sounds(&m), ["entity.hostile.death"], "a giant's death"); + } +} diff --git a/crates/horizon-client/src/mobs/farm.rs b/crates/horizon-client/src/mobs/farm.rs index b2f209644b..1c49b0563e 100644 --- a/crates/horizon-client/src/mobs/farm.rs +++ b/crates/horizon-client/src/mobs/farm.rs @@ -109,6 +109,16 @@ pub fn chicken_flap(m: &mut Mob) { a.flapping *= 0.9; a.flap += a.flapping * 2.0; } +/// `Chicken.isFlapping` and `Chicken.onFlap`: movement crosses a strict +/// distance threshold, then schedules the next wing beat without a sound. +pub fn chicken_process_flapping(m: &mut Mob) -> bool { + if m.ai.steps.fly_dist > m.ai.steps.next_flap { + m.ai.steps.next_flap = m.ai.steps.fly_dist + m.anim.flap_speed / 2.0; + true + } else { + false + } +} /// Farm animal climate from the biome tags (`spawns_warm/cold_variant_farm_animals`). #[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum Climate { @@ -150,6 +160,23 @@ mod tests { Mob::new(1, crate::mobs::sensor_scan_tests::kind(), Vec3::ZERO, 0.0) } + #[test] + fn chicken_flap_threshold_is_strict_and_advances_once() { + let mut m = chicken(); + m.anim.flap_speed = 1.0; + m.ai.steps.fly_dist = 1.0; + assert!(!chicken_process_flapping(&mut m)); + m.ai.steps.fly_dist = 1.2; + assert!(chicken_process_flapping(&mut m)); + assert!((m.ai.steps.next_flap - 1.7).abs() < 1.0e-6); + assert!(!chicken_process_flapping(&mut m)); + m.ai.steps.fly_dist = m.ai.steps.next_flap; + assert!(!chicken_process_flapping(&mut m)); + m.ai.steps.fly_dist = 1.8; + assert!(chicken_process_flapping(&mut m)); + assert!((m.ai.steps.next_flap - 2.3).abs() < 1.0e-6); + } + #[test] fn a_falling_chicken_flaps_and_a_landed_one_folds_its_wings() { let mut m = chicken(); diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 16b97be5c8..ea767681f1 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -407,6 +407,24 @@ impl Mob { pub fn eye_y(&self) -> f64 { self.position.y + self.eye_height() } + /// `LivingEntity.getScale`: the `minecraft:scale` the server sent on this + /// living entity (`extra.model_scale`, 0 when none came), through the + /// species' `sanitizeScale` override. Every box, eye height, seat and + /// shadow radius takes it. + pub fn model_scale(&self) -> f32 { + let scale = if self.extra.model_scale > 0.0 { + self.extra.model_scale + } else { + 1.0 + }; + match self.species() { + // HappyGhast.sanitizeScale: min(scale, 1), it never grows. + super::Species::HappyGhast => scale.min(1.0), + // Shulker.sanitizeScale: min(scale, 3). + super::Species::Shulker => scale.min(3.0), + _ => scale, + } + } /// Entity dimensions: the type's, a class's BABY_DIMENSIONS, or the /// slime size scaling. /// `EntityDimensions` (float width, height and eye height) widened to double. @@ -427,15 +445,12 @@ impl Mob { /// `Pose.LONG_JUMPING` (what `LongJumpUtil` checks an arc with). pub fn awake_dims_in(&self, long_jumping: bool) -> (f64, f64, f64) { let k = &self.kind; - // Avatar.POSES: a player's or mannequin's box follows its pose, and - // LivingEntity.getDimensions scales it by `minecraft:scale` - // (`getScale`): a fake player's giant NPC is clickable on its body. + // `LivingEntity.getDimensions` is `getDefaultDimensions(pose).scale( + // getScale())` on every living entity: the species' box (a player's or + // mannequin's follows its pose, Avatar.POSES) times its + // `minecraft:scale` (`getScale`), width, height and eye height alike. + let scale = self.model_scale(); if matches!(&*k.id, "minecraft:player" | "minecraft:mannequin") { - let scale = if self.extra.model_scale > 0.0 { - self.extra.model_scale - } else { - 1.0 - }; let d = self.extra.avatar_pose.dimensions(scale); return ( f64::from(d.width), @@ -484,6 +499,13 @@ impl Mob { let s = self.extra.size as f32; (w, h, e) = (w * s, h * s, e * s); } + // Shulker.getDefaultDimensions: the last scale, over everything + // above, is the lid opening upward on a shell attached to the floor. + if k.species == super::Species::Shulker + && let Some(s) = super::shulker::peek_height_scale(self) + { + (h, e) = (h * s, e * s); + } // Salmon.getDefaultDimensions: the variant's bounding box scale (small // 0.5, medium 1.0, large 1.5) over the whole fish box. if k.species == super::Species::Salmon { @@ -497,7 +519,13 @@ impl Mob { let f = super::polar_bear::stand_box_scale(self); (h, e) = (h * f, e * f); } - (f64::from(w), f64::from(h), f64::from(e)) + // `EntityDimensions.scale(getScale())`: width and height by the scale, + // the eye height by its height factor (the same one). + ( + f64::from(w * scale), + f64::from(h * scale), + f64::from(e * scale), + ) } pub fn eye_height(&self) -> f64 { self.dims().2 @@ -843,9 +871,22 @@ impl Mob { source: self.id, state: horizon_world::registry::BlockStateId(0), }); - } else if w.facts.is_air(state) && self.species() == super::Species::Parrot { - // Entity.processFlappingMovement: a move ending in the air. - super::parrot::process_flapping(self, events); + } else if w.facts.is_air(state) { + // Entity.processFlappingMovement: onFlap runs before the game + // event. Silent birds still emit it, independently of sound. + let flapped = match self.species() { + super::Species::Chicken => super::farm::chicken_process_flapping(self), + super::Species::Parrot => super::parrot::process_flapping(self, events), + _ => false, + }; + if flapped { + events.push(MobEvent::GameEvent { + event: "minecraft:flap", + at: [self.position.x, self.position.y, self.position.z], + source: self.id, + state: horizon_world::registry::BlockStateId(0), + }); + } } } /// `Entity.nextStep`: the next whole metre walked; a warden's steps come @@ -2122,4 +2163,91 @@ mod tests { let straddling = landing_dust_at(Vec3::new(5.1, 65.0, 7.5), [4, 64, 7]); assert_eq!(straddling, Vec3::new(5.0, 65.0, 7.5)); } + /// `LivingEntity.getDimensions` is `getDefaultDimensions(pose).scale( + /// getScale())` on every living entity, not only on a player or mannequin + /// whose pose sets the box (`Avatar.POSES`): a cow's `EntityType.COW` box + /// (0.9 x 1.4, its eyes at 1.3) at `minecraft:scale` 2 is 1.8 x 2.8 with + /// the eyes at 2.6, and the box `Mob.bounds` hands the hitbox raycast, the + /// attack range and the leash pins grows with it. + #[test] + fn a_scale_attribute_grows_every_living_entitys_box() { + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + let mut cow = Mob::new(1, crate::mobs::sensor_scan_tests::kind(), Vec3::ZERO, 0.0); + let (w, h, e) = cow.dims(); + assert!( + close(w, 0.9) && close(h, 1.4) && close(e, 1.3), + "no scale: {w}x{h}x{e}" + ); + cow.extra.model_scale = 2.0; + let (w, h, e) = cow.dims(); + assert!( + close(w, 1.8) && close(h, 2.8) && close(e, 2.6), + "scale 2: {w}x{h}x{e}" + ); + let b = cow.bounds(); + assert!( + close(b.min_position().x, -0.9) + && close(b.max_position().x, 0.9) + && close(b.max_position().y, 2.8), + "scale 2 box: {b:?}" + ); + // `RangedAttribute("attribute.name.scale", 1.0, 0.0625, 16.0)`: its + // floor shrinks the cow to 0.05625 x 0.0875, eyes at 0.08125. + cow.extra.model_scale = 0.0625; + let (w, h, e) = cow.dims(); + assert!( + close(w, 0.05625) && close(h, 0.0875) && close(e, 0.08125), + "scale 0.0625: {w}x{h}x{e}" + ); + } + /// `LivingEntity.getDimensions` answers a sleeping entity with + /// `SLEEPING_DIMENSIONS` before it ever reaches `getScale`: a sleeper's + /// 0.2 box and eye height stay 0.2 whatever its `minecraft:scale`. + #[test] + fn a_sleeping_mob_keeps_its_bed_box_unscaled() { + use crate::mobs::Species; + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:villager".into(); + kind.species = Species::Villager; + let mut villager = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + // Villager.sleeping: the bed's facing code, not 0. + villager.extra.ints[1] = 1; + villager.extra.model_scale = 4.0; + let (w, h, e) = villager.dims(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!( + close(w, 0.2) && close(h, 0.2) && close(e, 0.2), + "asleep: {w}x{h}x{e}" + ); + } + /// `LivingEntity.sanitizeScale`: a shulker takes at most 3 and a happy + /// ghast at most 1, and the box takes the capped value (`EntityType.SHULKER` + /// is 1 x 1 with its eyes at 0.5). + #[test] + fn a_species_caps_the_scale_it_takes() { + use crate::mobs::Species; + let mob = |id: &str, species: Species| { + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = id.into(); + kind.species = species; + (kind.width, kind.height, kind.eye_height) = (1.0, 1.0, 0.5); + Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0) + }; + let mut shulker = mob("minecraft:shulker", Species::Shulker); + shulker.extra.model_scale = 4.0; + assert_eq!(shulker.model_scale(), 3.0); + let (w, h, e) = shulker.dims(); + let close = |a: f64, b: f64| (a - b).abs() < 1e-6; + assert!( + close(w, 3.0) && close(h, 3.0) && close(e, 1.5), + "shulker: {w}x{h}x{e}" + ); + let mut ghast = mob("minecraft:happy_ghast", Species::HappyGhast); + ghast.extra.model_scale = 2.0; + assert_eq!(ghast.model_scale(), 1.0); + // Every other species takes the attribute as it came. + let mut cow = mob("minecraft:cow", Species::Cow); + cow.extra.model_scale = 4.0; + assert_eq!(cow.model_scale(), 4.0); + } } diff --git a/crates/horizon-client/src/mobs/parrot.rs b/crates/horizon-client/src/mobs/parrot.rs index a6c9a69922..f1a42916e3 100644 --- a/crates/horizon-client/src/mobs/parrot.rs +++ b/crates/horizon-client/src/mobs/parrot.rs @@ -219,15 +219,18 @@ pub fn calculate_flapping(m: &mut Mob) { } } -/// `Parrot.isFlapping` and `onFlap` (from `Entity.processFlappingMovement`, -/// when a move ends in the air): a wing beat each half `flapSpeed` flown. -pub fn process_flapping(m: &mut Mob, events: &mut Vec) { +/// `Parrot.isFlapping` and `Parrot.onFlap`: a wing beat each half +/// `flapSpeed` flown. The caller emits the movement game event on a flap. +pub fn process_flapping(m: &mut Mob, events: &mut Vec) -> bool { if m.ai.steps.fly_dist > m.ai.steps.next_flap { if !m.silent { let at = [m.position.x, m.position.y, m.position.z]; events.push(MobEvent::Sound("entity.parrot.fly", at, 0.15, 1.0)); } m.ai.steps.next_flap = m.ai.steps.fly_dist + m.anim.flap_speed / 2.0; + true + } else { + false } } @@ -349,15 +352,27 @@ mod tests { m.anim.flap_speed = 1.0; let mut events = Vec::new(); m.ai.steps.fly_dist = 0.5; - process_flapping(&mut m, &mut events); + assert!(!process_flapping(&mut m, &mut events)); assert!(events.is_empty(), "nextFlap starts at 1"); + m.ai.steps.fly_dist = 1.0; + assert!(!process_flapping(&mut m, &mut events), "strict threshold"); m.ai.steps.fly_dist = 1.2; - process_flapping(&mut m, &mut events); + assert!(process_flapping(&mut m, &mut events)); assert!(matches!( events.as_slice(), [MobEvent::Sound("entity.parrot.fly", _, v, _)] if *v == 0.15 )); assert!((m.ai.steps.next_flap - 1.7).abs() < 1.0e-6); + events.clear(); + assert!( + !process_flapping(&mut m, &mut events), + "no second flap without moving" + ); + m.silent = true; + m.ai.steps.fly_dist = 1.8; + assert!(process_flapping(&mut m, &mut events), "silent still flaps"); + assert!(events.is_empty()); + assert!((m.ai.steps.next_flap - 2.3).abs() < 1.0e-6); } /// `ServerPlayer.playShoulderEntityAmbientSound`: with a mob to imitate diff --git a/crates/horizon-client/src/mobs/raider.rs b/crates/horizon-client/src/mobs/raider.rs index 46765c8021..8bbdd34700 100644 --- a/crates/horizon-client/src/mobs/raider.rs +++ b/crates/horizon-client/src/mobs/raider.rs @@ -184,13 +184,15 @@ pub fn find_patrol_target_from(m: &mut Mob, [x, y, z]: [i32; 3]) { s.patrol_target = Some([x + dx, y, z + dz]); s.patrolling = true; } -/// The leader's ominous banner and its drop chance of 2.0 (always dropped). +/// The leader's ominous banner and saved head drop chance of 2.0. /// /// Also keeps the full ominous stack in `armor[0]` so `SetEquipment` sends /// the patterned banner to clients (`Raid.getOminousBannerInstance`). pub fn wear_banner(m: &mut Mob) { m.extra.head = Some(BANNER.into()); m.extra.armor[0] = Some(ominous_banner()); + // Raid.setLeader and PatrollingMonster.finalizeSpawn: EquipmentSlot.HEAD. + super::finalize::set_drop_chance(m, 5, 2.0); } /// Why a mob was spawned (`EntitySpawnReason`), as far as raiders care. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -733,7 +735,7 @@ mod tests { assert!(!can_be_leader(Species::Witch) && !can_be_leader(Species::Ravager)); } #[test] - fn wear_banner_keeps_the_ominous_stack_for_clients() { + fn wear_banner_keeps_the_stack_and_head_drop_chance() { use crate::mobs::sensor_scan_tests::kind; use std::sync::Arc; let mut k = (*kind()).clone(); @@ -742,6 +744,7 @@ mod tests { let mut m = Mob::new(1, Arc::new(k), Vec3::ZERO, 0.0); m.extra.raider = Some(Box::default()); state_mut(&mut m).patrol_leader = true; + super::super::finalize::set_drop_chance(&mut m, 1, 0.65); wear_banner(&mut m); assert_eq!(m.extra.head.as_deref(), Some(BANNER)); assert!( @@ -751,6 +754,17 @@ mod tests { .is_some_and(|d| d.ominous_banner) ); assert!(is_captain(&m)); + let saved = crate::entity_nbt::mob_to_nbt(&m, [0; 4], None, None); + let chances = saved.get("drop_chances").unwrap(); + assert_eq!( + chances.get("head"), + Some(&horizon_world::nbt::Tag::Float(2.0_f32.to_bits())) + ); + assert_eq!( + chances.get("offhand"), + Some(&horizon_world::nbt::Tag::Float(0.65_f32.to_bits())), + "equipping the banner preserves other slots' explicit chances" + ); // Client that only saw SetEquipment: no PatrolLeader, still a captain. state_mut(&mut m).patrol_leader = false; assert!(is_captain(&m)); diff --git a/crates/horizon-client/src/mobs/render.rs b/crates/horizon-client/src/mobs/render.rs index 4f443d9c89..04b5f5c3b4 100644 --- a/crates/horizon-client/src/mobs/render.rs +++ b/crates/horizon-client/src/mobs/render.rs @@ -1094,8 +1094,8 @@ fn enderman_render_offset(m: &Mob, alpha: f32) -> glam::DVec3 { } let frame = (m.age as f32 + alpha).to_bits(); let mut random = LegacyRandom::new((i64::from(m.id) << 32) ^ i64::from(frame)); - // `0.02 * state.scale`, the enderman's scale being 1. - let d = 0.02; + // `0.02 * state.scale`. + let d = 0.02 * f64::from(m.model_scale()); let x = random.next_gaussian() * d; let z = random.next_gaussian() * d; glam::DVec3::new(x, 0.0, z) @@ -1113,27 +1113,48 @@ pub fn base_transform_at(m: &Mob, alpha: f32, origin: glam::Mat4) -> glam::Mat4 let sleep = (m.species() == Species::Villager || avatar(m)) .then(|| super::villager::sleep_pose(m)) .flatten(); - let rotation = match sleep { - // setupRotations for Pose.SLEEPING, with the head moved toward the pillow. + // `LivingEntityRenderer.submit` moves a sleeper toward the pillow before + // it scales the model, so the bed offset is not scaled and the pose after + // it is. Keep the two apart. + let (sleep_offset, rotation) = match sleep { Some((angle, step)) => { // The eye height of the standing pose (LivingEntityRenderer // extracts `getEyeHeight(Pose.STANDING)` for a bed). let eye = if avatar(m) { - m.kind.eye_height as f32 + // `getEyeHeight(Pose.STANDING)` on an avatar is the standing + // box's eye height, `minecraft:scale` included. + m.kind.eye_height as f32 * m.model_scale() } else { m.awake_dims().2 as f32 }; let head = eye - 0.1; - glam::Mat4::from_translation(glam::Vec3::new(-step[0] * head, 0.0, -step[1] * head)) - * glam::Mat4::from_rotation_y(angle.to_radians()) - * glam::Mat4::from_rotation_z(90.0_f32.to_radians()) - * glam::Mat4::from_rotation_y(270.0_f32.to_radians()) + ( + glam::Mat4::from_translation(glam::Vec3::new( + -step[0] * head, + 0.0, + -step[1] * head, + )), + // setupRotations for Pose.SLEEPING. + glam::Mat4::from_rotation_y(angle.to_radians()) + * glam::Mat4::from_rotation_z(90.0_f32.to_radians()) + * glam::Mat4::from_rotation_y(270.0_f32.to_radians()), + ) } - None => super::aquatic::rotations(m, alpha, body) - .unwrap_or_else(|| glam::Mat4::from_rotation_y((180.0 - body).to_radians())), + None => ( + glam::Mat4::IDENTITY, + super::aquatic::rotations(m, alpha, body) + .unwrap_or_else(|| glam::Mat4::from_rotation_y((180.0 - body).to_radians())), + ), }; - // PufferfishRenderer.setupRotations: bobs before LivingEntityRenderer's. - let mut base = origin * pufferfish_bob(m, alpha) * rotation; + // `LivingEntityRenderer.submit`: `poseStack.scale(state.scale, ...)` for + // every living entity, after the sleeping offset and before + // setupRotations, so a pose translation scales with the model but the bed + // it lies in does not. + let mut base = origin + * sleep_offset + * glam::Mat4::from_scale(glam::Vec3::splat(m.model_scale())) + * pufferfish_bob(m, alpha) + * rotation; if m.death_time > 0 && !super::creaking::tearing_down(m) { let f = ((m.death_time as f32 + alpha - 1.0) / 20.0 * 1.6) .sqrt() @@ -1291,14 +1312,10 @@ fn red_overlay(m: &Mob) -> bool { } fn species_scale(m: &Mob, alpha: f32) -> glam::Mat4 { if avatar(m) { - // AvatarRenderer.scale, shared by the inventory and world renderer, on - // top of the entity's own `scale` (`LivingEntityRenderer.submit`). - let own = if m.extra.model_scale > 0.0 { - m.extra.model_scale - } else { - 1.0 - }; - return glam::Mat4::from_scale(glam::Vec3::splat(0.9375 * own)); + // AvatarRenderer.scale, shared by the inventory and world renderer. + // The entity's own `scale` is `LivingEntityRenderer.submit`'s, applied + // by `base_transform_at` before setupRotations. + return glam::Mat4::from_scale(glam::Vec3::splat(0.9375)); } match m.species() { Species::Wither => { @@ -3955,4 +3972,118 @@ mod random_texture_tests { assert_eq!(drawn.len(), 4, "still drawn"); assert!(drawn.iter().all(|uv| uv[0] <= 0.5 && uv[1] <= 0.5)); } + + /// `EndermanRenderer.getRenderOffset` moves a creepy enderman by + /// `0.02 * state.scale` blocks sideways: at `minecraft:scale` 2 the same + /// gaussian offset doubles, and at the default it is the old 0.02. + #[test] + fn a_scaled_creepy_endermans_render_offset_scales() { + use horizon_core::geometry::Vec3; + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:enderman".into(); + kind.species = Species::Enderman; + let mut m = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + // Enderman.isCreepy: the angry flag that adds the offset. + m.extra.flag = true; + m.extra.model_scale = 1.0; + let one = enderman_render_offset(&m, 0.5); + assert!(one.length() > 0.0, "the offset is the renderer's gaussian"); + m.extra.model_scale = 2.0; + let two = enderman_render_offset(&m, 0.5); + assert!((two.x - 2.0 * one.x).abs() < 1.0e-12, "{two:?} vs {one:?}"); + assert!((two.z - 2.0 * one.z).abs() < 1.0e-12, "{two:?} vs {one:?}"); + assert_eq!(two.y, 0.0); + } + + /// `LivingEntityRenderer.submit` translates a sleeper toward its pillow + /// before `poseStack.scale(state.scale)`: the bed offset is not scaled and + /// the pose after it is. The uniform scale after `setupRotations` instead + /// would leave the pose's own translations unscaled; the one before the + /// sleeping translation would scale the bed. + #[test] + fn a_scaled_sleepers_bed_offset_is_not_scaled() { + use crate::mobs::Species; + use horizon_core::geometry::Vec3; + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:villager".into(); + kind.species = Species::Villager; + (kind.width, kind.height, kind.eye_height) = (0.6, 1.95, 1.62); + let mut m = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + // Villager.sleeping: the bed faces north, so the head goes to +z. + m.extra.ints[1] = 1; + // The eye height of the standing pose, as `submit` extracts it. + let bed = |m: &Mob| { + let eye = m.awake_dims().2 as f32; + glam::Vec3::new(0.0, 0.0, eye - 0.1) + }; + let unscaled = base_transform_at(&m, 0.5, glam::Mat4::IDENTITY); + let unscaled_pose = glam::Mat4::from_translation(-bed(&m)) * unscaled; + m.extra.model_scale = 2.0; + let scaled = base_transform_at(&m, 0.5, glam::Mat4::IDENTITY); + let scaled_pose = glam::Mat4::from_translation(-bed(&m)) * scaled; + let expected = glam::Mat4::from_scale(glam::Vec3::splat(2.0)) * unscaled_pose; + let got = scaled_pose.to_cols_array(); + let want = expected.to_cols_array(); + for i in 0..16 { + assert!( + (got[i] - want[i]).abs() < 1.0e-4, + "element {i}: {} != {}\nscaled: {got:?}\nunscaled x2: {want:?}", + got[i], + want[i] + ); + } + } + + /// A sleeping avatar's bed offset is `getEyeHeight(Pose.STANDING) - 0.1`, + /// and the standing eye height carries `minecraft:scale`: at scale 2 the + /// bed offset is 3.14, not the type's 1.52, and the pose after it doubles. + #[test] + fn a_scaled_sleeping_avatar_uses_its_scaled_eye_height() { + use horizon_core::geometry::Vec3; + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:player".into(); + (kind.width, kind.height, kind.eye_height) = (0.6, 1.8, 1.62); + let mut m = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + m.extra.ints[1] = 1; + let bed = |m: &Mob| { + let eye = m.kind.eye_height as f32 * m.model_scale(); + glam::Vec3::new(0.0, 0.0, eye - 0.1) + }; + let unscaled = base_transform_at(&m, 0.5, glam::Mat4::IDENTITY); + let unscaled_pose = glam::Mat4::from_translation(-bed(&m)) * unscaled; + m.extra.model_scale = 2.0; + let scaled = base_transform_at(&m, 0.5, glam::Mat4::IDENTITY); + let scaled_pose = glam::Mat4::from_translation(-bed(&m)) * scaled; + let expected = glam::Mat4::from_scale(glam::Vec3::splat(2.0)) * unscaled_pose; + let got = scaled_pose.to_cols_array(); + let want = expected.to_cols_array(); + for i in 0..16 { + assert!( + (got[i] - want[i]).abs() < 1.0e-4, + "element {i}: {} != {}\nscaled: {got:?}\nunscaled x2: {want:?}", + got[i], + want[i] + ); + } + } + + /// The avatar's 0.9375 `AvatarRenderer.scale` and the entity's own + /// `minecraft:scale` are two factors: at scale 2 the model is 1.875 times + /// the model's, not 3.75, so the submitted scale is not applied twice. + #[test] + fn an_avatar_at_scale_two_scales_its_model_once() { + use horizon_core::geometry::Vec3; + let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone(); + kind.id = "minecraft:player".into(); + let mut m = Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0); + m.extra.model_scale = 2.0; + let transform = base_transform_at(&m, 0.5, glam::Mat4::IDENTITY); + for axis in [glam::Vec3::X, glam::Vec3::Y, glam::Vec3::Z] { + let length = transform.transform_vector3(axis).length(); + assert!( + (length - 1.875).abs() < 1.0e-5, + "{axis:?} scales by {length}, not 2 * 0.9375" + ); + } + } } diff --git a/crates/horizon-client/src/mobs/riders.rs b/crates/horizon-client/src/mobs/riders.rs index 0a4084a662..77152d85f9 100644 --- a/crates/horizon-client/src/mobs/riders.rs +++ b/crates/horizon-client/src/mobs/riders.rs @@ -18,13 +18,9 @@ const BOARDING_COOLDOWN: i32 = 60; const MAX_PASSENGERS_ON_ONE_HOGLIN: i32 = 3; /// `LivingEntity.getScale`: the `minecraft:scale` the server sent on any -/// living entity (`extra.model_scale`, 0 when none came), 1 when unset. +/// living entity, 1 when unset (`Mob::model_scale`). pub(super) fn scale(m: &Mob) -> f64 { - if m.extra.model_scale > 0.0 { - f64::from(m.extra.model_scale) - } else { - 1.0 - } + f64::from(m.model_scale()) } /// Players and mannequins (`Avatar`). fn avatar(m: &Mob) -> bool { @@ -57,11 +53,10 @@ fn base_attachment(v: &Mob) -> [f64; 3] { Species::Nautilus | Species::ZombieNautilus => [0.0, 1.1375, 0.0], Species::Camel | Species::CamelHusk => super::camel::passenger_attachment(v), // The default attachments: the top of the bounding box, before - // `passenger_attachment`'s scale — a player's or mannequin's - // `height()` carries `minecraft:scale` since `getDimensions` grew it; - // any other mob's `height()` stays unscaled (`awake_dims_in`). - _ if avatar(v) => [0.0, f64::from(v.height()) / scale(v), 0.0], - _ => [0.0, f64::from(v.height()), 0.0], + // `passenger_attachment`'s scale — `height()` carries + // `minecraft:scale` since `getDimensions` grew every living entity's + // box, so the box's own height divides it out again. + _ => [0.0, f64::from(v.height()) / scale(v), 0.0], } } /// The rider's own `VEHICLE` attachment height (`ridingOffset`): how far @@ -394,8 +389,8 @@ mod tests { assert!(close(seat.y, 54.1)); } /// A scaled vehicle that is not an avatar: `remote_mob_attributes` puts - /// `minecraft:scale` on any living entity, but `awake_dims_in` grows only - /// avatars' boxes, so the seat still rides at the scaled box height. + /// `minecraft:scale` on any living entity, so the seat rides at the scaled + /// box height on a cow as on a fake player. #[test] fn a_scaled_vehicle_seat_stays_scaled() { let close = |a: f64, b: f64| (a - b).abs() < 1e-6; diff --git a/crates/horizon-client/src/mobs/shulker.rs b/crates/horizon-client/src/mobs/shulker.rs index b37dc28fc4..d0a030510e 100644 --- a/crates/horizon-client/src/mobs/shulker.rs +++ b/crates/horizon-client/src/mobs/shulker.rs @@ -24,6 +24,17 @@ use std::f32::consts::PI; // the current peek amount. pub const PEEK: usize = 0; pub const NO_COLOR: u8 = 16; +/// `Direction.DOWN`'s 3D data value. +const DOWN: u8 = 0; + +/// `Shulker.getDefaultDimensions`: a shell attached to the block below it +/// (attach face `DOWN`, its lid opening upward) grows `1 + currentPeekAmount` +/// tall as the lid opens. Width is +/// untouched, and so is every other attach face. +pub fn peek_height_scale(m: &Mob) -> Option { + let peek = m.anim.v[PEEK]; + (m.extra.variant3 == DOWN && peek > 0.0).then_some(1.0 + peek) +} pub fn register(m: &mut Mob) { m.no_gravity = true; @@ -607,6 +618,10 @@ mod tests { k.id = "minecraft:shulker".into(); k.species = Species::Shulker; k.max_head_y_rot = 180.0; + // EntityTypes.SHULKER: sized(1.0F, 1.0F).eyeHeight(0.5F). + k.width = 1.0; + k.height = 1.0; + k.eye_height = 0.5; let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0); m.extra.variant3 = 2; m @@ -654,4 +669,52 @@ mod tests { ); assert_eq!(m.body_yaw, 0.0); } + + #[test] + fn an_open_shell_on_the_floor_stands_taller() { + // Shulker.getDefaultDimensions: a shell on Direction.DOWN (attach + // face 0) with currentPeekAmount p is `scale(1, 1 + p)` of its + // 1 x 1 x 0.5 box — the width is untouched, the height and the eyes + // grow. A client eases the peek 0.05 a tick towards the synced raw + // peek, 100 while ShulkerAttackGoal holds the lid open. + let mut m = shulker(); + m.extra.variant3 = DOWN; + m.extra.counter = 100; + for _ in 0..10 { + client_tick(&mut m); + } + assert!((m.anim.v[PEEK] - 0.5).abs() < 1e-6, "{}", m.anim.v[PEEK]); + let (w, h, e) = m.dims(); + assert!( + (w - 1.0).abs() < 1e-9 && (h - 1.5).abs() < 1e-5 && (e - 0.75).abs() < 1e-5, + "a half open shell is 1 x 1.5 with its eyes at 0.75: {w} x {h} x {e}" + ); + // The same box `Mob::bounds_at` and the culling build from `height`. + assert!((f64::from(m.height()) - 1.5).abs() < 1e-5, "{}", m.height()); + } + + #[test] + fn only_a_shell_on_the_floor_grows() { + // Every other attach face keeps the plain 1 x 1 x 0.5 box, however + // far the lid opens. + for face in [1u8, 2, 3, 4, 5] { + let mut m = shulker(); + m.extra.variant3 = face; + m.extra.counter = 100; + for _ in 0..20 { + client_tick(&mut m); + } + assert_eq!(m.anim.v[PEEK], 1.0); + let (w, h, e) = m.dims(); + assert!( + (w - 1.0).abs() < 1e-9 && (h - 1.0).abs() < 1e-9 && (e - 0.5).abs() < 1e-9, + "a shell on face {face} stays 1 x 1: {w} x {h} x {e}" + ); + } + // A closed one on the floor does not grow either. + let mut m = shulker(); + m.extra.variant3 = DOWN; + let (_, h, _) = m.dims(); + assert!((h - 1.0).abs() < 1e-9, "{h}"); + } } diff --git a/crates/horizon-client/src/mobs/skin.rs b/crates/horizon-client/src/mobs/skin.rs index eff08b7dc3..d12227169f 100644 --- a/crates/horizon-client/src/mobs/skin.rs +++ b/crates/horizon-client/src/mobs/skin.rs @@ -22,7 +22,7 @@ struct Stored { /// The model's bounds in its parts' own space, per part: min and max of /// its own vertices (none: no polygons). bounds: Vec>, - /// The model's render type culls back faces (the bat). + /// The model's render type culls back faces (the bat, the baby turtle). cull: bool, } @@ -646,12 +646,13 @@ mod tests { assert!(taken[0].is_subset(&taken[1])); } - /// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and - /// feet put two quads in one place, and only culling tells them apart. The - /// bat's vertices ask for the shaded cull encoding, other mobs keep -127, - /// on both paths (the whole-mob test above holds them bit-equal). + /// `BatModel` and `BabyTurtleModel` draw with `entityCutoutCull`: their + /// zero-depth ears, wings, feet and legs put two quads in one place, and + /// only culling tells them apart. Their vertices ask for the shaded cull + /// encoding, other mobs keep -127, on both paths (the whole-mob test above + /// holds them bit-equal). #[test] - fn only_the_bat_asks_for_culled_back_faces() { + fn only_culled_render_types_ask_for_culled_back_faces() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../.local/horizon-client/26.3"); if !root.join("entity-models.json").exists() { @@ -659,12 +660,37 @@ mod tests { return; } let types = MobTypes::load(&root).unwrap(); - for (id, w) in [ - ("minecraft:bat", crate::mesher::SHADED_CULL_W), - ("minecraft:cow", -127), - ("minecraft:cat", -127), - ] { - let mut part = vec![&*types.types[id].model]; + let turtle = &types.types["minecraft:turtle"]; + let baby_turtle = turtle.layer("turtle_baby#main").expect("baby turtle model"); + let cases: [(&str, &std::sync::Arc, i8, u32); 5] = [ + ( + "minecraft:bat", + &types.types["minecraft:bat"].model, + crate::mesher::SHADED_CULL_W, + 1, + ), + ( + "minecraft:turtle_baby", + baby_turtle, + crate::mesher::SHADED_CULL_W, + 1, + ), + ("minecraft:turtle", &turtle.model, -127, 0), + ( + "minecraft:cow", + &types.types["minecraft:cow"].model, + -127, + 0, + ), + ( + "minecraft:cat", + &types.types["minecraft:cat"].model, + -127, + 0, + ), + ]; + for (id, model, w, _) in cases { + let mut part = vec![&**model]; let mut seen = 0; while let Some(p) = part.pop() { for poly in &p.polygons { @@ -679,11 +705,11 @@ mod tests { // The GPU path flags the job, the CPU path the polygons. let mut models = SkinModels::default(); let mut frame = SkinFrame::default(); - for (id, cull) in [("minecraft:bat", 1), ("minecraft:cow", 0)] { + for (id, model, _, cull) in cases { models.begin(&mut frame, 0); let mut skin = Skin::new(&mut models, &mut frame); assert!(skin.draw( - &types.types[id].model, + model, glam::Mat4::IDENTITY, &Poses::default(), Paint { diff --git a/crates/horizon-client/src/mobs/villager.rs b/crates/horizon-client/src/mobs/villager.rs index a71efd6f21..8e2ffbc7f0 100644 --- a/crates/horizon-client/src/mobs/villager.rs +++ b/crates/horizon-client/src/mobs/villager.rs @@ -380,12 +380,16 @@ pub fn gift_table(m: &Mob) -> String { // ---- Gossip, food, breeding and golems. /// `VillagerFood` nutrition (`DataComponents.VILLAGER_FOOD`). +/// +/// The item registry's value: a data pack's or a mod's item with the component +/// counts, as one with vanilla's four does. Until a registry is loaded (tests) +/// those four. pub fn food_points(item: &str) -> i32 { - match item { + horizon_items::items::villager_food(item).unwrap_or(match item { "minecraft:bread" => 4, "minecraft:potato" | "minecraft:carrot" | "minecraft:beetroot" => 1, _ => 0, - } + }) } /// The farmer's `requestedItems` (`VillagerProfession.FARMER`). Food is picked /// up and traded separately, through its villager food value. diff --git a/crates/horizon-client/src/shadows.rs b/crates/horizon-client/src/shadows.rs index c27ed38da1..965101f819 100644 --- a/crates/horizon-client/src/shadows.rs +++ b/crates/horizon-client/src/shadows.rs @@ -68,12 +68,7 @@ pub fn mob_shadow_radius(m: &Mob) -> f32 { S::Pufferfish => return 0.1 + 0.1 * m.extra.size as f32, _ => {} } - let scale = if m.extra.model_scale > 0.0 { - m.extra.model_scale - } else { - 1.0 - }; - let radius = shadow_params(&m.kind.id).0 * scale; + let radius = shadow_params(&m.kind.id).0 * m.model_scale(); if matches!(&*m.kind.id, "minecraft:player" | "minecraft:mannequin") || !m.is_baby() { return radius; } diff --git a/crates/horizon-command/src/tests.rs b/crates/horizon-command/src/tests.rs index 0a99dbcee8..966060cff6 100644 --- a/crates/horizon-command/src/tests.rs +++ b/crates/horizon-command/src/tests.rs @@ -829,7 +829,7 @@ fn the_dedicated_commands_complete_from_the_servers_lists() { fn only_a_dedicated_server_has_the_dedicated_commands() { // Commands.CommandSelection: an integrated server (single player, a LAN // world) registers no /op, /ban, /ban-ip, /banlist, /pardon, /pardon-ip, - // /deop, /transfer, /whitelist, /save-all, /save-off, /save-on or /stop, + // /deop, /setidletimeout, /transfer, /whitelist, /save-all, /save-off, /save-on or /stop, // so /help and Tab don't offer them. let dedicated = [ "ban", @@ -842,6 +842,7 @@ fn only_a_dedicated_server_has_the_dedicated_commands() { "save-all", "save-off", "save-on", + "setidletimeout", "stop", "transfer", "whitelist", @@ -975,6 +976,56 @@ fn a_dedicated_server_keeps_seed_and_version_for_game_masters() { } } } +#[test] +fn a_dedicated_servers_admins_set_the_idle_timeout() { + // SetPlayerIdleTimeoutCommand.register: LEVEL_ADMINS, and + // IntegerArgumentType.integer(0) — zero turns the timeout off, and a + // negative count of minutes is out of range. + let server = vanilla::game_tree(false, false, true); + let integrated = vanilla::game_tree(false, false, false); + assert!(integrated.find("setidletimeout").is_none()); + let at = |level: u8| Registries { + permission: level, + ..reg() + }; + // Below LEVEL_ADMINS the node is not there at all. + for level in [0, 1, 2] { + let e = server + .parse("setidletimeout 5", &at(level)) + .executable() + .unwrap_err(); + assert_eq!( + e.message.plain(), + "command.unknown.command", + "level {level}" + ); + } + // The minutes are required: an incomplete command. + let e = server + .parse("setidletimeout", &at(3)) + .executable() + .unwrap_err(); + assert_eq!(e.message.plain(), "command.unknown.command"); + for line in ["setidletimeout 0", "setidletimeout 5"] { + assert_eq!( + server.parse(line, &at(3)).executable().unwrap().1, + "setidletimeout" + ); + } + // IntegerArgumentType.integer(0): argument.integer.low for -1 below 0. + let e = server + .parse("setidletimeout -1", &at(3)) + .executable() + .unwrap_err(); + assert_eq!(e.message.plain(), "argument.integer.low"); + // Past i32 the number itself is unreadable. + let e = server + .parse("setidletimeout 2147483648", &at(3)) + .executable() + .unwrap_err(); + assert_eq!(e.message.plain(), "parsing.int.invalid"); +} + #[test] fn time_argument_rounds_like_math_round_float() { let ticks = |input: &str| { diff --git a/crates/horizon-command/src/tree_extra.rs b/crates/horizon-command/src/tree_extra.rs index 6d7b1df527..c3499fcb90 100644 --- a/crates/horizon-command/src/tree_extra.rs +++ b/crates/horizon-command/src/tree_extra.rs @@ -2,7 +2,7 @@ //! `/posteffect`, `/fetchprofile`, `/spectate`, `/publish`, `/unpublish`, //! `/waypoint`, `/debug`, `/jfr` and `/test`; on a dedicated server only, //! `/op`, `/deop`, `/ban`, `/ban-ip`, `/banlist`, `/pardon`, `/pardon-ip`, -//! `/transfer` and `/whitelist`. +//! `/setidletimeout`, `/transfer` and `/whitelist`. use super::{ Dispatcher, Suggests, args::ArgType, @@ -266,6 +266,19 @@ pub fn register_dedicated(d: &mut Dispatcher, root: usize) { let reason = d.argument(t, "reason", ArgType::Message); d.executes(reason, "ban"); + // SetPlayerIdleTimeoutCommand: minutes, 0 to never kick for idleness. + let idle = d.literal(root, "setidletimeout"); + d.requires(idle, ADMINS); + let minutes = d.argument( + idle, + "minutes", + ArgType::Integer { + min: 0, + max: i32::MAX, + }, + ); + d.executes(minutes, "setidletimeout"); + // TransferCommand: [ []], the port 1 to 65535. let transfer = d.literal(root, "transfer"); d.requires(transfer, ADMINS); diff --git a/crates/horizon-command/src/vanilla_order.rs b/crates/horizon-command/src/vanilla_order.rs index 367ff080f6..8c6633a5b3 100644 --- a/crates/horizon-command/src/vanilla_order.rs +++ b/crates/horizon-command/src/vanilla_order.rs @@ -94,6 +94,7 @@ pub const ORDER: &[(&str, &[&str])] = &[ "save-all", "save-off", "save-on", + "setidletimeout", "stop", "transfer", "whitelist", diff --git a/crates/horizon-items/src/items.rs b/crates/horizon-items/src/items.rs index ad86950c12..9fc1af3a89 100644 --- a/crates/horizon-items/src/items.rs +++ b/crates/horizon-items/src/items.rs @@ -193,6 +193,8 @@ pub struct ItemInfo { pub disable_blocking_seconds: f32, /// `weapon.item_damage_per_attack`, when the item is a weapon. pub weapon_damage: Option, + /// `villager_food.nutrition`: what a villager gets from eating it. + pub villager_food: Option, /// `use_effects.speed_multiplier` (UseEffects.DEFAULT: 0.2). pub use_speed: f32, /// `use_effects.can_sprint` (default false): sprinting may start while the @@ -598,6 +600,30 @@ fn entity_spec(tags: &HashMap>, spec: &str) -> HashSet HashSet::from([spec.to_owned()]), } } +/// The items with a `villager_food` component and their nutrition, from the +/// last item registry loaded: what the mobs' villagers read, since a mob does +/// not hold the registry (`None` until one is loaded). +static VILLAGER_FOOD: std::sync::RwLock, i32>>> = + std::sync::RwLock::new(None); +fn set_villager_food(items: &HashMap, ItemInfo>) { + let food = items + .values() + .filter_map(|i| Some((i.id.clone(), i.villager_food?))) + .collect(); + *VILLAGER_FOOD + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(food); +} +/// `item`'s `villager_food` nutrition (`DataComponents.VILLAGER_FOOD`): its +/// value in the loaded registry (0 for an item without the component), or +/// `None` while no registry is loaded. +pub fn villager_food(item: &str) -> Option { + let food = VILLAGER_FOOD + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + food.as_ref().map(|f| f.get(item).copied().unwrap_or(0)) +} + impl Items { pub fn resists_fire(&self, stack: &ItemStack, source: &str) -> bool { if let Some(data) = stack.data() @@ -836,6 +862,9 @@ impl Items { .find(|(item, _)| *item == name) .map(|(_, rest)| (*rest).to_owned()) .or_else(|| mod_remainders.get(name).cloned()), + villager_food: c["minecraft:villager_food"]["nutrition"] + .as_i64() + .map(|n| n as i32), use_speed: c["minecraft:use_effects"]["speed_multiplier"] .as_f64() .unwrap_or(0.2) as f32, @@ -898,6 +927,7 @@ impl Items { // game's own: one map with the loot data of the same root and packs. let loot = horizon_world::loot::shared_tables(root, overlay)?; let registry_order = registry_order(root, &order); + set_villager_food(&items); Ok(Self { items, order, @@ -2792,3 +2822,39 @@ mod held_drop_tests { } } } + +#[cfg(test)] +mod villager_food_tests { + use super::*; + + /// A villager's food is the item's `villager_food` component, read from the registry: + /// vanilla's four (bread 4, the rest 1) and nothing else. + #[test] + fn the_registry_tells_a_villagers_food() { + let Some(items) = prepared_items() else { + return; + }; + let food: Vec<(&str, Option)> = [ + "minecraft:bread", + "minecraft:carrot", + "minecraft:potato", + "minecraft:beetroot", + "minecraft:apple", + ] + .map(|i| (i, items.get(i).and_then(|i| i.villager_food))) + .into(); + assert_eq!( + food, + [ + ("minecraft:bread", Some(4)), + ("minecraft:carrot", Some(1)), + ("minecraft:potato", Some(1)), + ("minecraft:beetroot", Some(1)), + ("minecraft:apple", None), + ] + ); + // Loading the registry hands the mobs the same table. + assert_eq!(villager_food("minecraft:bread"), Some(4)); + assert_eq!(villager_food("minecraft:apple"), Some(0)); + } +} diff --git a/crates/horizon-protocol/Cargo.toml b/crates/horizon-protocol/Cargo.toml index 377b6d997e..8600602134 100644 --- a/crates/horizon-protocol/Cargo.toml +++ b/crates/horizon-protocol/Cargo.toml @@ -11,6 +11,7 @@ horizon-core = { path = "../horizon-core" } horizon-world = { path = "../horizon-world" } flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] } serde_json = "1.0.151" +log = "0.4" # The shared secret and RSA padding (OS randomness) and the server hash. getrandom = "0.3" sha1 = "0.10" diff --git a/crates/horizon-protocol/src/adapter.rs b/crates/horizon-protocol/src/adapter.rs index 643abe3152..a326536789 100644 --- a/crates/horizon-protocol/src/adapter.rs +++ b/crates/horizon-protocol/src/adapter.rs @@ -1,9 +1,13 @@ //! Speaking to servers of an older release: the first layer of the native protocol adapter (roadmap //! `older-servers`). Horizon's packets are 26.3's; a server on another protocol numbers them differently. //! This maps packet ids per connection state, both ways; a [`Session`] follows one connection's states -//! and renumbers what goes through it. It does not translate packet contents yet. +//! and renumbers what goes through it. [`ids`] renumbers the registry ids inside packets as the shared +//! readers decode them, and `translate` rewrites the packets whose layout changed. +pub mod ids; pub mod session; +mod translate; pub mod v26_2; +mod v26_2_ids; pub use session::Session; @@ -57,6 +61,22 @@ impl ServerVersion { self.map(state, Direction::Serverbound, id) } + /// How this version numbers block states and registries, against the pinned one. `None` for the pinned + /// version itself. + pub fn ids(self) -> Option<&'static ids::Ids> { + // Before 26.3 a tag slot display was a bare tag id. + static V26_2: ids::Ids = ids::Ids { + quirks: ids::Quirks { + tag_slot_display_is_id: true, + }, + ..v26_2_ids::IDS + }; + match self { + Self::Current => None, + Self::V26_2 => Some(&V26_2), + } + } + fn map(self, state: State, direction: Direction, id: i32) -> Option { let table = match (self, direction) { (Self::Current, _) => return Some(id), diff --git a/crates/horizon-protocol/src/adapter/ids.rs b/crates/horizon-protocol/src/adapter/ids.rs new file mode 100644 index 0000000000..c93c20f134 --- /dev/null +++ b/crates/horizon-protocol/src/adapter/ids.rs @@ -0,0 +1,252 @@ +//! Registry ids an older server numbers differently: the tables `tools/generate_registry_id_adapter.py` +//! writes, and the per-thread context the item and particle readers consult while a packet from such a +//! server is decoded (an id there decides how the bytes after it read, so it must be renumbered as it is +//! read, not afterwards). +use std::cell::Cell; + +/// How one registry's ids differ between an older version and the pinned one. +#[derive(Clone, Copy, Debug)] +pub enum Remap { + /// Numbered alike. + Same, + /// Runs `(from, to, length)`: ids `from..from + length` map to `to..to + length`. `up` goes from the older + /// version to the pinned one, `down` back. An id in no run has no entry on the other side. + Runs { + up: &'static [(u16, u16, u16)], + down: &'static [(u16, u16, u16)], + }, +} + +fn lookup(runs: &[(u16, u16, u16)], id: i32) -> Option { + let id = u16::try_from(id).ok()?; + let i = runs + .partition_point(|&(from, _, _)| from <= id) + .checked_sub(1)?; + let (from, to, length) = runs[i]; + (id - from < length).then(|| i32::from(to + (id - from))) +} + +impl Remap { + /// The pinned id of the older version's `id`; `None` when the pinned version has no such entry. + pub fn up(self, id: i32) -> Option { + match self { + Self::Same => Some(id), + Self::Runs { up, .. } => lookup(up, id), + } + } + /// The older version's id of the pinned `id`; `None` when the older version has no such entry. + pub fn down(self, id: i32) -> Option { + match self { + Self::Same => Some(id), + Self::Runs { down, .. } => lookup(down, id), + } + } +} + +/// Block states and every registry the protocol sends by id, for one older version. +#[derive(Clone, Copy, Debug)] +pub struct Ids { + /// How the older version lays out what the shared readers decode. + pub quirks: Quirks, + /// How many block states the older version and the pinned one have. + pub states: (u32, u32), + pub block_state: Remap, + pub item: Remap, + pub entity_type: Remap, + pub particle_type: Remap, + pub sound_event: Remap, + pub data_component_type: Remap, + pub command_argument_type: Remap, + pub custom_stat: Remap, + pub map_decoration_type: Remap, + pub block: Remap, + pub block_entity_type: Remap, + pub menu: Remap, + pub mob_effect: Remap, + pub attribute: Remap, + pub stat_type: Remap, + pub fluid: Remap, + pub game_event: Remap, + pub recipe_serializer: Remap, + pub slot_display: Remap, + pub recipe_display: Remap, + pub recipe_book_category: Remap, + pub villager_type: Remap, + pub villager_profession: Remap, + pub position_source_type: Remap, + pub consume_effect_type: Remap, + pub enchantment_effect_component_type: Remap, + /// EntityDataSerializers' registration order (not a registry). + pub entity_data_serializer: Remap, +} + +/// Layout differences inside structures the pinned readers decode while ids are renumbered up. +#[derive(Clone, Copy, Debug, Default)] +pub struct Quirks { + /// `SlotDisplay` type 6 holds a bare tag id (TagSlotDisplay) instead of a HolderSet (26.2). + pub tag_slot_display_is_id: bool, +} +impl Quirks { + pub const NONE: Self = Self { + tag_slot_display_is_id: false, + }; +} + +/// The older version's quirks while its packets are decoded on this thread; none otherwise. +pub fn quirks() -> Quirks { + match ACTIVE.get() { + Some((ids, Way::Up)) => ids.quirks, + _ => Quirks::NONE, + } +} + +impl Ids { + /// Bits per entry of a chunk section's global block palette: the older version's, then the pinned one's + /// (`Mth.ceillog2` of the state count). + pub fn state_bits(&self) -> (u8, u8) { + let bits = |n: u32| (u32::BITS - n.saturating_sub(1).leading_zeros()) as u8; + (bits(self.states.0), bits(self.states.1)) + } +} + +/// Which way ids are being renumbered on this thread. +#[derive(Clone, Copy, Debug)] +pub enum Way { + /// Decoding what an older server sent: its ids become the pinned version's. + Up, + /// Encoding for an older server: the pinned ids become its own. + Down, +} + +thread_local! { + static ACTIVE: Cell> = const { Cell::new(None) }; +} + +/// Renumber ids `way` on this thread until the guard drops. Readers and writers that pick a layout by an id +/// (item stacks, data components, particles) consult it through [`item`], [`component`] and [`particle`]. +pub fn renumbering(ids: &'static Ids, way: Way) -> Guard { + Guard(ACTIVE.replace(Some((ids, way)))) +} + +/// Restores the previous context when dropped. +pub struct Guard(Option<(&'static Ids, Way)>); +impl Drop for Guard { + fn drop(&mut self) { + ACTIVE.set(self.0); + } +} + +fn apply(id: i32, pick: impl Fn(&Ids) -> Remap) -> i32 { + match ACTIVE.get() { + None => id, + // An id the other side lacks becomes -1, which every reader turns away or reads as nothing. + Some((ids, Way::Up)) => pick(ids).up(id).unwrap_or(-1), + Some((ids, Way::Down)) => pick(ids).down(id).unwrap_or(-1), + } +} + +/// An item id as read or about to be written on this thread. +pub fn item(id: i32) -> i32 { + apply(id, |ids| ids.item) +} +/// A data component type id as read or about to be written on this thread. +pub fn component(id: i32) -> i32 { + apply(id, |ids| ids.data_component_type) +} +/// A particle type id as read or about to be written on this thread. +pub fn particle(id: i32) -> i32 { + apply(id, |ids| ids.particle_type) +} +/// A block state id as read or about to be written on this thread. +pub fn block_state(id: i32) -> i32 { + apply(id, |ids| ids.block_state) +} +/// An entity type id as read or about to be written on this thread. +pub fn entity_type(id: i32) -> i32 { + apply(id, |ids| ids.entity_type) +} +/// A sound event id as read or about to be written on this thread. +pub fn sound_event(id: i32) -> i32 { + apply(id, |ids| ids.sound_event) +} +/// An entity data serializer id as read on this thread. +pub fn entity_data_serializer(id: i32) -> i32 { + apply(id, |ids| ids.entity_data_serializer) +} +/// A slot display type id as read on this thread. +pub fn slot_display(id: i32) -> i32 { + apply(id, |ids| ids.slot_display) +} +/// The ids on this thread now, if any. +pub fn active() -> Option<(&'static Ids, Way)> { + ACTIVE.get() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn runs_map_inside_and_nothing_outside() { + let runs = Remap::Runs { + up: &[(0, 0, 3), (3, 5, 2)], + down: &[(0, 0, 3), (5, 3, 2)], + }; + assert_eq!(runs.up(2), Some(2)); + assert_eq!(runs.up(3), Some(5)); + assert_eq!(runs.up(4), Some(6)); + assert_eq!(runs.up(5), None); + assert_eq!(runs.up(-1), None); + assert_eq!(runs.down(3), None); + assert_eq!(runs.down(6), Some(4)); + } + + #[test] + fn the_context_lasts_while_its_guard_does() { + static IDS: Ids = super::super::v26_2_ids::IDS; + assert_eq!(item(926), 926); + { + // diamond: 926 in 26.2, 1012 in 26.3. + let _up = renumbering(&IDS, Way::Up); + assert_eq!(item(926), 1012); + { + let _down = renumbering(&IDS, Way::Down); + assert_eq!(item(1012), 926); + } + assert_eq!(item(926), 1012); + } + assert_eq!(item(926), 926); + } + + #[test] + fn the_26_2_tables_match_the_reports() { + // Read off both versions' registries and blocks reports by hand. + let ids = crate::adapter::ServerVersion::V26_2.ids().unwrap(); + assert_eq!(ids.item.up(926), Some(1012)); // diamond + assert_eq!(ids.item.up(1), Some(1)); // stone + assert_eq!(ids.block_state.up(13330), Some(15177)); // oak_slab type=top waterlogged=true + assert_eq!(ids.block_state.up(3987), Some(5543)); // chest, first state + assert_eq!(ids.block_state.up(8), Some(8)); // grass_block + assert_eq!(ids.state_bits(), (15, 16)); + } + + #[test] + fn an_item_stack_is_read_and_written_in_the_servers_numbering() { + use crate::buf::{Codec, Reader, Writer}; + use crate::item::{Slot, Stack}; + let ids = crate::adapter::ServerVersion::V26_2.ids().unwrap(); + // Three diamonds as a 26.2 server sends them: item 926, no components. + let sent = [3, 0x9e, 0x07, 0, 0]; + let stack = { + let _up = renumbering(ids, Way::Up); + Slot::read(&mut Reader::new(&sent)).unwrap().unwrap() + }; + assert_eq!((stack.item, stack.count), (1012, 3)); + let mut w = Writer::new(); + { + let _down = renumbering(ids, Way::Down); + Slot::write(&Some(Stack::new(1012, 3)), &mut w); + } + assert_eq!(w.data, sent); + } +} diff --git a/crates/horizon-protocol/src/adapter/session.rs b/crates/horizon-protocol/src/adapter/session.rs index e48cf86c83..fc650e5e31 100644 --- a/crates/horizon-protocol/src/adapter/session.rs +++ b/crates/horizon-protocol/src/adapter/session.rs @@ -1,5 +1,5 @@ //! One connection to a server of an older release: which state each direction is in, followed from the -//! packets themselves, and every packet renumbered on its way through. +//! packets themselves, and every packet renumbered and translated on its way through. //! //! The states change where vanilla's `Connection` switches its codecs: the handshake's intent picks status //! or login for both directions, `login_finished` and `login_acknowledged` move each direction to @@ -7,7 +7,7 @@ //! back to configuration. use std::sync::atomic::{AtomicU8, Ordering}; -use super::ServerVersion; +use super::{ServerVersion, translate}; use crate::{ Result, State, buf::{Reader, Writer}, @@ -62,7 +62,7 @@ impl Session { self.writing.store(to_u8(state), Ordering::Release); } - /// A packet the server sent, as the pinned version numbers it. `None` drops it: Horizon + /// A packet the server sent, as the pinned version numbers and lays it out. `None` drops it: Horizon /// knows no such packet. pub fn clientbound(&self, id: i32, payload: Vec) -> Result)>> { let state = self.reading(); @@ -81,18 +81,32 @@ impl Session { } _ => {} } - Ok(Some((pinned, payload))) + // A packet the translation can't read goes on as it came, as an unknown packet would: Horizon's + // own reader then skips it or reports it, rather than the connection dropping here. + match translate::clientbound(self, state, pinned, &payload) { + Ok(Some((id, Some(translated)))) => Ok(Some((id, translated))), + Ok(Some((id, None))) => Ok(Some((id, payload))), + Ok(None) => Ok(None), + Err(e) => { + log::warn!( + "Horizon: could not translate {} packet {pinned} ({state:?}): {e}", + self.version.name() + ); + Ok(Some((pinned, payload))) + } + } } - /// A packet body (VarInt id + payload) Horizon sends, as this server numbers it. `None`: + /// A packet body (VarInt id + payload) Horizon sends, as this server numbers and lays it out. `None`: /// the server has no such packet, so nothing is sent. pub fn serverbound(&self, body: &[u8]) -> Result>> { let state = self.writing(); let mut r = Reader::new(body); let id = r.varint()?; + let payload = r.rest(); match (state, id) { (State::Handshake, ids::handshake::serverbound::INTENTION) => { - return self.intention(r.rest()).map(Some); + return self.intention(payload).map(Some); } (State::Login, ids::login::serverbound::LOGIN_ACKNOWLEDGED) => { self.set_writing(State::Configuration) @@ -105,7 +119,13 @@ impl Session { } _ => {} } - self.version.serverbound_body(state, body) + let Some((mapped, payload)) = translate::serverbound(self, state, id, payload)? else { + return Ok(None); + }; + let mut w = Writer::new(); + w.varint(mapped); + w.bytes(&payload); + Ok(Some(w.data)) } /// The handshake states this server's protocol, and its intent moves both directions on. diff --git a/crates/horizon-protocol/src/adapter/translate.rs b/crates/horizon-protocol/src/adapter/translate.rs new file mode 100644 index 0000000000..f455824781 --- /dev/null +++ b/crates/horizon-protocol/src/adapter/translate.rs @@ -0,0 +1,708 @@ +//! What changes inside packets between an older release and the pinned one, after their ids are renumbered: +//! registry ids (block states, items, entity types, ...) and the packets whose fields changed. A packet not +//! named here passes byte for byte. +//! +//! Most packets that carry registry ids are decoded with the older version's ids renumbered as they are read +//! ([`ids::renumbering`]) and encoded again for Horizon; ids in plain fields are renumbered between the two. +//! A packet whose layout changed is read in the older layout by hand here. +use super::{ + ids::{self, Ids, Way}, + session::Session, +}; +use crate::{ + Result, State, + buf::{Codec, Reader, Writer}, + chunk::{Paletted, Section, Strategy}, + ids as packet_ids, + packets::{configuration, play}, +}; + +impl Ids { + /// The table of a registry tags are sent for, by its name; registries the protocol syncs from the + /// server's own data (biomes, enchantments, ...) are numbered by that data and alike on both sides. + pub fn by_registry(&self, registry: &str) -> ids::Remap { + match registry { + "minecraft:block" => self.block, + "minecraft:item" => self.item, + "minecraft:entity_type" => self.entity_type, + "minecraft:fluid" => self.fluid, + "minecraft:game_event" => self.game_event, + "minecraft:particle_type" => self.particle_type, + "minecraft:sound_event" => self.sound_event, + "minecraft:mob_effect" => self.mob_effect, + "minecraft:attribute" => self.attribute, + "minecraft:block_entity_type" => self.block_entity_type, + "minecraft:menu" => self.menu, + "minecraft:villager_type" => self.villager_type, + "minecraft:villager_profession" => self.villager_profession, + "minecraft:data_component_type" => self.data_component_type, + _ => ids::Remap::Same, + } + } +} + +/// A clientbound packet, renumbered to the pinned `id`, as the pinned version lays it out: the pinned id +/// it now has (an older packet can become a different one) and its new payload, `None` when the payload +/// stays as it is. `None` altogether drops the packet. +pub fn clientbound( + session: &Session, + state: State, + id: i32, + payload: &[u8], +) -> Result>)>> { + let Some(ids) = session.version.ids() else { + return Ok(Some((id, None))); + }; + use packet_ids::play::clientbound as cb; + let out = match (state, id) { + (State::Configuration, packet_ids::configuration::clientbound::UPDATE_TAGS) => { + let mut p = configuration::Clientbound::decode(id, payload)?; + if let configuration::Clientbound::UpdateTags(t) = &mut p { + tags(ids, &mut t.registries); + } + strip_id(p.encode()) + } + (State::Play, cb::UPDATE_TAGS) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::UpdateTags(t) = &mut p { + tags(ids, &mut t.registries); + } + strip_id(p.encode()) + } + (State::Play, cb::LOGIN) => login(payload)?, + (State::Play, cb::RESPAWN) => respawn(payload)?, + (State::Play, cb::LEVEL_CHUNK_WITH_LIGHT) => chunk(ids, payload)?, + (State::Play, cb::LIGHT_UPDATE) => light_update(payload)?, + (State::Play, cb::UPDATE_ADVANCEMENTS) => advancements(ids, payload)?, + (State::Play, cb::LEVEL_PARTICLES) => particles(ids, payload)?, + (State::Play, cb::EXPLODE) => { + // 26.3 adds whether the blast's sound plays, last; 26.2 always played it. + let mut body = payload.to_vec(); + body.push(1); + redecode(ids, id, &body)? + } + (State::Play, cb::MOVE_ENTITY_POS) => move_entity(payload, true, false)?, + (State::Play, cb::MOVE_ENTITY_POS_ROT) => move_entity(payload, true, true)?, + (State::Play, cb::MOVE_ENTITY_ROT) => move_entity(payload, false, true)?, + (State::Play, cb::ENTITY_POSITION_SYNC) => position_sync(payload)?, + (State::Play, cb::ANIMATE) => { + return animate(payload).map(|(id, body)| Some((id, Some(body)))); + } + (State::Play, cb::ADD_ENTITY) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::AddEntity(e) = &mut p { + let falling_block = ids.entity_type.down(packet_ids_entity::FALLING_BLOCK); + if Some(e.kind) == falling_block { + e.data = ids.block_state.up(e.data).unwrap_or(0); + } + e.kind = ids.entity_type.up(e.kind).unwrap_or(e.kind); + } + strip_id(p.encode()) + } + (State::Play, cb::BLOCK_ENTITY_DATA) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::BlockEntityData(b) = &mut p { + b.kind = ids.block_entity_type.up(b.kind).unwrap_or(b.kind); + } + strip_id(p.encode()) + } + (State::Play, cb::BLOCK_UPDATE) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::BlockUpdate(b) = &mut p { + b.state = ids.block_state.up(b.state).unwrap_or(0); + } + strip_id(p.encode()) + } + (State::Play, cb::SECTION_BLOCKS_UPDATE) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::SectionBlocksUpdate(s) = &mut p { + for change in &mut s.changes { + let state = ids.block_state.up((*change >> 12) as i32).unwrap_or(0); + *change = i64::from(state) << 12 | (*change & 0xfff); + } + } + strip_id(p.encode()) + } + (State::Play, cb::LEVEL_EVENT) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::LevelEvent(e) = &mut p { + // LevelEvent.PARTICLES_DESTROY_BLOCK and the brush's BLOCK_FINISHED_BRUSHING carry a state. + if matches!(e.kind, 2001 | 3008) { + e.data = ids.block_state.up(e.data).unwrap_or(0); + } + } + strip_id(p.encode()) + } + (State::Play, cb::UPDATE_ATTRIBUTES) => { + let mut p = decode_play(ids, id, payload)?; + if let play::Clientbound::UpdateAttributes(a) = &mut p { + // An attribute the pinned version lacks can't be: 26.3 only added some. + for v in &mut a.values { + v.attribute = ids.attribute.up(v.attribute).unwrap_or(v.attribute); + } + } + strip_id(p.encode()) + } + ( + State::Play, + cb::SET_ENTITY_DATA + | cb::CONTAINER_SET_CONTENT + | cb::CONTAINER_SET_SLOT + | cb::SET_CURSOR_ITEM + | cb::SET_PLAYER_INVENTORY + | cb::SET_EQUIPMENT + | cb::MERCHANT_OFFERS + | cb::SOUND + | cb::SOUND_ENTITY + | cb::RECIPE_BOOK_ADD + | cb::PLACE_GHOST_RECIPE, + ) => redecode(ids, id, payload)?, + _ => return Ok(Some((id, None))), + }; + Ok(Some((id, Some(out)))) +} + +/// Pinned entity type ids the translation looks for. +mod packet_ids_entity { + /// `minecraft:falling_block` in 26.3. + pub const FALLING_BLOCK: i32 = 52; +} + +/// A serverbound packet with Horizon's `id`, as the older server numbers and lays it out: its id there and +/// the payload. `None`: nothing is sent. +pub fn serverbound( + session: &Session, + state: State, + id: i32, + payload: &[u8], +) -> Result)>> { + let renumbered = |id: i32, payload: Vec| { + Ok(session + .version + .serverbound_id(state, id) + .map(|mapped| (mapped, payload))) + }; + let Some(ids) = session.version.ids() else { + return renumbered(id, payload.to_vec()); + }; + use packet_ids::play::serverbound as sb; + match (state, id) { + // 26.3 also states where the client was teleported to; 26.2 only the teleport's id. + (State::Play, sb::ACCEPT_TELEPORTATION) => { + let mut r = Reader::new(payload); + let mut w = Writer::new(); + w.varint(r.varint()?); + renumbered(id, w.data) + } + // 26.3 inserted CHANGE_DESTROY_DIRECTION at 1; 26.2 has no such action. + (State::Play, sb::PLAYER_ACTION) => { + let mut r = Reader::new(payload); + let action = r.varint()?; + let action = match action { + 0 => 0, + 1 => return Ok(None), + n => n - 1, + }; + let mut w = Writer::new(); + w.varint(action); + w.bytes(r.rest()); + renumbered(id, w.data) + } + // 26.3's punch took over 26.2's swing of the main hand. + (State::Play, sb::PUNCH) => { + let mut w = Writer::new(); + w.varint(0); + Ok(Some((V26_2_SWING, w.data))) + } + (State::Play, sb::SET_CREATIVE_MODE_SLOT | sb::CONTAINER_CLICK) => { + let p = play::Serverbound::decode(id, payload)?; + let payload = { + let _down = ids::renumbering(ids, Way::Down); + strip_id(p.encode()) + }; + renumbered(id, payload) + } + _ => renumbered(id, payload.to_vec()), + } +} + +/// 26.2's serverbound `swing` (play), which 26.3 replaced with `punch`. +const V26_2_SWING: i32 = 63; + +/// A play packet decoded with the older version's ids renumbered as they are read. +fn decode_play(ids: &'static Ids, id: i32, payload: &[u8]) -> Result { + let _up = ids::renumbering(ids, Way::Up); + play::Clientbound::decode(id, payload) +} + +/// The packet's payload once its registry ids are renumbered by decoding it. +fn redecode(ids: &'static Ids, id: i32, payload: &[u8]) -> Result> { + let p = decode_play(ids, id, payload)?; + Ok(strip_id(p.encode())) +} + +fn strip_id(body: Vec) -> Vec { + let mut r = Reader::new(&body); + match r.varint() { + Ok(_) => r.rest().to_vec(), + Err(_) => body, + } +} + +fn tags(ids: &Ids, registries: &mut [configuration::RegistryTags]) { + for registry in registries { + let remap = ids.by_registry(®istry.registry); + for tag in &mut registry.tags { + tag.ids = tag.ids.iter().filter_map(|&id| remap.up(id)).collect(); + } + } +} + +/// 26.2's CommonPlayerSpawnInfo, read into Horizon's: the game types went from bytes (-1 for no previous +/// one) to VarInts (an optional one as id + 1). +fn spawn_info(r: &mut Reader) -> Result { + let dimension_type = r.varint()?; + let dimension = r.string(32767)?; + let seed = r.i64()?; + let game_type = i32::from(r.i8()?); + let previous = r.i8()?; + let debug = r.bool()?; + let flat = r.bool()?; + let last_death = >::read(r)?; + Ok(play::SpawnInfo { + dimension_type, + dimension, + seed, + game_type, + previous_game_type: (previous >= 0).then_some(i32::from(previous)), + debug, + flat, + last_death, + portal_cooldown: r.varint()?, + sea_level: r.varint()?, + }) +} + +fn login(payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let mut w = Writer::new(); + w.i32(r.i32()?); + w.bool(r.bool()?); + let n = r.count(r.remaining())?; + w.varint(n as i32); + for _ in 0..n { + w.string(&r.string(32767)?); + } + for _ in 0..3 { + w.varint(r.varint()?); + } + for _ in 0..3 { + w.bool(r.bool()?); + } + play::SpawnInfo::write(&spawn_info(&mut r)?, &mut w); + w.bytes(r.rest()); + Ok(w.data) +} + +fn respawn(payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let mut w = Writer::new(); + play::SpawnInfo::write(&spawn_info(&mut r)?, &mut w); + w.bytes(r.rest()); + Ok(w.data) +} + +/// The column's sections with their block states renumbered and packed for the pinned palette width (a +/// section on the older global palette uses fewer bits per entry), and its light masks as 26.3 sends them. +/// Biomes are the server's own registry and stay as they are. +fn chunk(ids: &'static Ids, payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let x = r.i32()?; + let z = r.i32()?; + let heightmaps = crate::chunk::Heightmaps::read(&mut r)?; + let buffer = r.byte_array(r.remaining())?; + let mut block_entities = >::read(&mut r)?; + // A block entity type the pinned version dropped (26.1's bed) goes; the block still draws. + block_entities.retain_mut(|b| match ids.block_entity_type.up(b.kind) { + Some(kind) => { + b.kind = kind; + true + } + None => false, + }); + let light = light(&mut r)?; + let (old_bits, new_bits) = ids.state_bits(); + let mut sections = Reader::new(&buffer); + let mut out = Vec::new(); + while sections.remaining() > 0 { + let mut s = Section::read(&mut sections, old_bits, 0)?; + s.states = renumber_states(ids, &s.states, new_bits); + out.push(s); + } + let mut w = Writer::new(); + play::LevelChunkWithLight::write( + &play::LevelChunkWithLight { + x, + z, + chunk: crate::chunk::ChunkData { + heightmaps, + buffer: crate::chunk::ChunkData::from_sections(&out), + block_entities, + }, + light, + }, + &mut w, + ); + Ok(w.data) +} + +/// 26.2's ClientboundLightUpdatePacketData: its four masks are `BitSet.toLongArray` (a VarInt count of +/// longs); 26.3 sends `toByteArray`, the same bits as little-endian bytes without trailing zeros. +fn light(r: &mut Reader) -> Result { + let mut mask = || -> Result> { + let longs = r.long_array()?; + let mut bytes: Vec = longs.iter().flat_map(|l| l.to_le_bytes()).collect(); + let len = bytes.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1); + bytes.truncate(len); + Ok(bytes) + }; + let sky_mask = mask()?; + let block_mask = mask()?; + let empty_sky_mask = mask()?; + let empty_block_mask = mask()?; + Ok(crate::chunk::LightData { + sky_mask, + block_mask, + empty_sky_mask, + empty_block_mask, + sky: crate::chunk::Layers::read(r)?, + block: crate::chunk::Layers::read(r)?, + }) +} + +fn light_update(payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let x = r.varint()?; + let z = r.varint()?; + let light = light(&mut r)?; + let mut w = Writer::new(); + play::LightUpdate::write(&play::LightUpdate { x, z, light }, &mut w); + Ok(w.data) +} + +fn renumber_states(ids: &Ids, states: &Paletted, new_bits: u8) -> Paletted { + let up = |id: i32| ids.block_state.up(id).unwrap_or(0); + if !states.palette.is_empty() { + // A palette: renumbering its entries is enough. + let mut out = states.clone(); + out.palette.iter_mut().for_each(|p| *p = up(*p)); + return out; + } + let values: Vec = (0..crate::chunk::BLOCKS) + .map(|i| up(states.get(i))) + .collect(); + Paletted::from_values(&values, Strategy::Blocks, new_bits) +} + +/// 26.2 keeps an advancement's tab position in its display (two floats after the background); 26.3 sends +/// it after the whole advancement, displayed or not. What follows the added advancements is unchanged. +fn advancements(ids: &'static Ids, payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let mut w = Writer::new(); + w.bool(r.bool()?); + let n = r.count(r.remaining())?; + w.varint(n as i32); + for _ in 0..n { + w.string(&r.string(32767)?); + let parent = r.bool()?; + w.bool(parent); + if parent { + w.string(&r.string(32767)?); + } + let mut position = (0.0, 0.0); + let display = r.bool()?; + w.bool(display); + if display { + w.nbt(&r.nbt()?); + w.nbt(&r.nbt()?); + let icon = { + let _up = ids::renumbering(ids, Way::Up); + crate::item::Template::read(&mut r)? + }; + crate::item::Template::write(&icon, &mut w); + w.varint(r.varint()?); + let flags = r.i32()?; + w.i32(flags); + if flags & 1 != 0 { + w.string(&r.string(32767)?); + } + position = (r.f32()?, r.f32()?); + } + let groups = r.count(r.remaining())?; + w.varint(groups as i32); + for _ in 0..groups { + let k = r.count(r.remaining())?; + w.varint(k as i32); + for _ in 0..k { + w.string(&r.string(32767)?); + } + } + w.bool(r.bool()?); + w.f32(position.0); + w.f32(position.1); + } + w.bytes(r.rest()); + Ok(w.data) +} + +/// 26.2: flags, position, spread, one max speed, an Int count, then the particle. 26.3 sends the particle +/// first, a speed per axis, a VarInt count and how the spread is drawn (0, gaussian, as 26.2 did). +fn particles(ids: &'static Ids, payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let override_limiter = r.bool()?; + let always_show = r.bool()?; + let pos = [r.f64()?, r.f64()?, r.f64()?]; + let dist = [r.f32()?, r.f32()?, r.f32()?]; + let speed = r.f32()?; + let count = r.i32()?; + let particle = { + let _up = ids::renumbering(ids, Way::Up); + crate::entity_data::Particle::read(&mut r)? + }; + let mut w = Writer::new(); + play::LevelParticles::write( + &play::LevelParticles { + particle, + override_limiter, + always_show, + pos, + dist, + max_speed: [speed; 3], + count, + randomization: 0, + }, + &mut w, + ); + Ok(w.data) +} + +/// 26.2 sends a move as three Short deltas (1/4096 of a block) then on-ground, and a turn as the two angles +/// then on-ground; 26.3 packs on-ground with the delta's step count, and sends it before the angles. +fn move_entity(payload: &[u8], pos: bool, rot: bool) -> Result> { + let mut r = Reader::new(payload); + let id = r.varint()?; + let delta = if pos { + Some(play::VecDelta::Linear([r.i16()?, r.i16()?, r.i16()?])) + } else { + None + }; + let rotation = if rot { Some((r.i8()?, r.i8()?)) } else { None }; + let on_ground = r.bool()?; + let m = play::MoveEntity { + id, + delta, + rotation, + on_ground, + }; + let mut w = Writer::new(); + match (pos, rot) { + (true, false) => play::MoveEntityPos::write(&play::MoveEntityPos(m), &mut w), + (true, true) => play::MoveEntityPosRot::write(&play::MoveEntityPosRot(m), &mut w), + _ => play::MoveEntityRot::write(&play::MoveEntityRot(m), &mut w), + } + Ok(w.data) +} + +/// 26.2: the entity, PositionMoveRotation (position, motion, the two angles), on-ground. 26.3 sends where it +/// goes as a path, then the angles; the motion is no longer sent. +fn position_sync(payload: &[u8]) -> Result> { + let mut r = Reader::new(payload); + let id = r.varint()?; + let position = [r.f64()?, r.f64()?, r.f64()?]; + let _motion = [r.f64()?, r.f64()?, r.f64()?]; + let yaw = r.f32()?; + let pitch = r.f32()?; + let on_ground = r.bool()?; + let mut w = Writer::new(); + play::EntityPositionSync::write( + &play::EntityPositionSync { + id, + path: play::PositionPath::Linear(position), + yaw, + pitch, + on_ground, + }, + &mut w, + ); + Ok(w.data) +} + +/// 26.2's animate swings hands too (0 main, 3 off), which 26.3 sends as `swing_animation`; the others were +/// renumbered (wake up 2 -> 0, critical hits 4, 5 -> 1, 2). +fn animate(payload: &[u8]) -> Result<(i32, Vec)> { + let mut r = Reader::new(payload); + let id = r.varint()?; + let action = r.u8()?; + let mut w = Writer::new(); + if action == 0 || action == 3 { + // A plain swing: SwingAnimationType WHACK over the default 6 ticks. + play::SwingAnimation::write( + &play::SwingAnimation { + id, + hand: i32::from(action == 3), + kind: 1, + duration: 6, + }, + &mut w, + ); + return Ok((packet_ids::play::clientbound::SWING_ANIMATION, w.data)); + } + let action = match action { + 2 => 0, + 4 => 1, + 5 => 2, + other => other, + }; + w.varint(id); + w.u8(action); + Ok((packet_ids::play::clientbound::ANIMATE, w.data)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids() -> &'static Ids { + super::super::ServerVersion::V26_2.ids().unwrap() + } + + #[test] + fn a_26_2_chunk_section_on_the_global_palette_is_repacked_wider() { + let ids = ids(); + // Every block a different state from 0..4096: past 8 bits, so the global palette at 15 bits. + let values: Vec = (0..4096).map(|i| 13000 + i).collect(); + let old = Paletted::from_values(&values, Strategy::Blocks, 15); + assert_eq!(old.bits, 15); + let new = renumber_states(ids, &old, 16); + assert_eq!(new.bits, 16); + // oak_slab's first state, 13330 in 26.2, is 15177 in 26.3. + assert_eq!(new.get(330), 15177); + assert_eq!(new.get(0), ids.block_state.up(13000).unwrap()); + } + + #[test] + fn a_26_2_move_reads_as_26_3s() { + let mut w = Writer::new(); + w.varint(7); + w.i16(4096); + w.i16(0); + w.i16(-2048); + w.bool(true); + let body = move_entity(&w.data, true, false).unwrap(); + let p = play::Clientbound::decode(packet_ids::play::clientbound::MOVE_ENTITY_POS, &body) + .unwrap(); + assert_eq!( + p, + play::Clientbound::MoveEntityPos(play::MoveEntityPos(play::MoveEntity { + id: 7, + delta: Some(play::VecDelta::Linear([4096, 0, -2048])), + rotation: None, + on_ground: true, + })) + ); + let mut w = Writer::new(); + w.varint(7); + w.i8(10); + w.i8(-20); + w.bool(false); + let body = move_entity(&w.data, false, true).unwrap(); + let p = play::Clientbound::decode(packet_ids::play::clientbound::MOVE_ENTITY_ROT, &body) + .unwrap(); + assert_eq!( + p, + play::Clientbound::MoveEntityRot(play::MoveEntityRot(play::MoveEntity { + id: 7, + delta: None, + rotation: Some((10, -20)), + on_ground: false, + })) + ); + } + + #[test] + fn a_26_2_swing_becomes_a_swing_animation() { + let (id, body) = animate(&[5, 3]).unwrap(); + assert_eq!(id, packet_ids::play::clientbound::SWING_ANIMATION); + let p = play::Clientbound::decode(id, &body).unwrap(); + assert_eq!( + p, + play::Clientbound::SwingAnimation(play::SwingAnimation { + id: 5, + hand: 1, + kind: 1, + duration: 6 + }) + ); + assert_eq!( + animate(&[5, 4]).unwrap(), + (packet_ids::play::clientbound::ANIMATE, vec![5, 1]) + ); + } + + #[test] + fn falling_blocks_are_known_in_both_numberings() { + let ids = ids(); + assert_eq!( + ids.entity_type + .down(packet_ids_entity::FALLING_BLOCK) + .and_then(|o| ids.entity_type.up(o)), + Some(packet_ids_entity::FALLING_BLOCK) + ); + } + + #[test] + fn a_26_2_light_mask_of_longs_becomes_26_3s_bytes() { + let mut w = Writer::new(); + w.varint(3); + w.varint(-2); + // Sky mask: sections 0 and 2 (one long, 0b101); block mask: section 9 (bit 9); both empty masks none. + for mask in [&[0b101_i64][..], &[1 << 9], &[], &[]] { + w.varint(mask.len() as i32); + mask.iter().for_each(|&l| w.i64(l)); + } + w.varint(0); + w.varint(0); + let body = light_update(&w.data).unwrap(); + let p = + play::Clientbound::decode(packet_ids::play::clientbound::LIGHT_UPDATE, &body).unwrap(); + let play::Clientbound::LightUpdate(l) = p else { + panic!("{p:?}"); + }; + assert_eq!((l.x, l.z), (3, -2)); + assert_eq!(l.light.sky_mask, vec![0b101]); + assert_eq!(l.light.block_mask, vec![0, 0b10]); + assert!(l.light.empty_sky_mask.is_empty()); + } + + #[test] + fn a_26_3_destroy_direction_change_is_not_sent_to_26_2() { + let session = Session::new(super::super::ServerVersion::V26_2); + let action = |n: i32| { + let mut w = Writer::new(); + w.varint(n); + w.i64(0); + w.u8(1); + serverbound( + &session, + State::Play, + packet_ids::play::serverbound::PLAYER_ACTION, + &w.data, + ) + .unwrap() + .map(|(_, payload)| payload[0]) + }; + assert_eq!(action(0), Some(0)); // start destroying + assert_eq!(action(1), None); // change_destroy_direction, new in 26.3 + assert_eq!(action(3), Some(2)); // stop destroying: 3 in 26.3, 2 in 26.2 + } +} diff --git a/crates/horizon-protocol/src/adapter/v26_2_ids.rs b/crates/horizon-protocol/src/adapter/v26_2_ids.rs new file mode 100644 index 0000000000..d3ec776819 --- /dev/null +++ b/crates/horizon-protocol/src/adapter/v26_2_ids.rs @@ -0,0 +1,193 @@ +//! Registry ids of 26.2 against the pinned 26.3: block states and the registries +//! the protocol sends by id. Generated by tools/generate_registry_id_adapter.py from both versions' +//! registries and blocks reports; do not edit by hand. + +/// Block states: 32366 in 26.2, 35723 pinned. +pub const OLD_STATES: u32 = 32366; +pub const PINNED_STATES: u32 = 35723; +#[rustfmt::skip] +pub const BLOCK_STATE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 27), (27, 28, 58), (85, 88, 78), (163, 169, 35), (198, 207, 27), (225, 237, 27), (252, 267, 252), (504, 603, 1683), + (2187, 2302, 65), (2252, 2368, 57), (2309, 3801, 931), (3240, 4796, 2383), (5623, 7211, 276), (5899, 7495, 712), (6611, 8271, 136), (6747, 8415, 132), + (6879, 8549, 811), (7690, 9424, 1803), (9493, 11235, 1146), (10639, 12382, 252), (10891, 12658, 1481), (12372, 14219, 1012), (13384, 15237, 18), (13402, 15322, 6), + (13408, 15255, 66), (13474, 16688, 6), (13480, 15321, 1), (13481, 15328, 259), (13740, 15619, 288), (14028, 15939, 544), (14572, 16547, 141), (14713, 16694, 333), + (15046, 18403, 17320), +]; +#[rustfmt::skip] +pub const BLOCK_STATE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 27), (28, 27, 58), (88, 85, 78), (169, 163, 35), (207, 198, 27), (237, 225, 27), (267, 252, 252), (603, 504, 1683), + (2302, 2187, 65), (2368, 2252, 57), (3801, 2309, 931), (4796, 3240, 2383), (7211, 5623, 276), (7495, 5899, 712), (8271, 6611, 136), (8415, 6747, 132), + (8549, 6879, 811), (9424, 7690, 1803), (11235, 9493, 1146), (12382, 10639, 252), (12658, 10891, 1481), (14219, 12372, 1012), (15237, 13384, 18), (15255, 13408, 66), + (15321, 13480, 1), (15322, 13402, 6), (15328, 13481, 259), (15619, 13740, 288), (15939, 14028, 544), (16547, 14572, 141), (16688, 13474, 6), (16694, 14713, 333), + (18403, 15046, 17320), +]; +/// `minecraft:item`: 1537 in 26.2, 1658 pinned. +#[rustfmt::skip] +pub const ITEM_UP: &[(u16, u16, u16)] = &[ + (0, 0, 72), (72, 73, 13), (85, 87, 85), (170, 173, 14), (184, 188, 11), (195, 200, 12), (207, 213, 11), (218, 227, 14), + (232, 242, 24), (256, 298, 21), (277, 320, 30), (307, 351, 34), (341, 386, 40), (381, 427, 97), (478, 525, 180), (658, 737, 130), + (788, 868, 16), (804, 885, 13), (817, 899, 21), (838, 921, 20), (858, 942, 51), (909, 995, 116), (1025, 1112, 12), (1037, 1125, 45), + (1082, 1186, 49), (1131, 1236, 3), (1134, 1255, 403), +]; +#[rustfmt::skip] +pub const ITEM_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 72), (73, 72, 13), (87, 85, 85), (173, 170, 14), (188, 184, 11), (200, 195, 12), (213, 207, 11), (227, 218, 14), + (242, 232, 24), (298, 256, 21), (320, 277, 30), (351, 307, 34), (386, 341, 40), (427, 381, 97), (525, 478, 180), (737, 658, 130), + (868, 788, 16), (885, 804, 13), (899, 817, 21), (921, 838, 20), (942, 858, 51), (995, 909, 116), (1112, 1025, 12), (1125, 1037, 45), + (1186, 1082, 49), (1236, 1131, 3), (1255, 1134, 403), +]; +/// `minecraft:entity_type`: 158 in 26.2, 161 pinned. +#[rustfmt::skip] +pub const ENTITY_TYPE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 33), (33, 34, 72), (105, 108, 53), +]; +#[rustfmt::skip] +pub const ENTITY_TYPE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 33), (34, 33, 72), (108, 105, 53), +]; +/// `minecraft:particle_type`: 125 in 26.2, 128 pinned. +#[rustfmt::skip] +pub const PARTICLE_TYPE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 43), (43, 46, 82), +]; +#[rustfmt::skip] +pub const PARTICLE_TYPE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 43), (46, 43, 82), +]; +/// `minecraft:sound_event`: 1968 in 26.2, 1991 pinned. +#[rustfmt::skip] +pub const SOUND_EVENT_UP: &[(u16, u16, u16)] = &[ + (0, 0, 492), (492, 496, 1476), +]; +#[rustfmt::skip] +pub const SOUND_EVENT_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 492), (496, 492, 1476), +]; +/// `minecraft:data_component_type`: 111 in 26.2, 122 pinned. +#[rustfmt::skip] +pub const DATA_COMPONENT_TYPE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 40), (41, 42, 1), (42, 45, 3), (46, 48, 36), (82, 88, 29), +]; +#[rustfmt::skip] +pub const DATA_COMPONENT_TYPE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 40), (42, 41, 1), (45, 42, 3), (48, 46, 36), (88, 82, 29), +]; +/// `minecraft:command_argument_type`: 57 in 26.2, 62 pinned. +#[rustfmt::skip] +pub const COMMAND_ARGUMENT_TYPE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 55), (55, 58, 1), (56, 61, 1), +]; +#[rustfmt::skip] +pub const COMMAND_ARGUMENT_TYPE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 55), (58, 55, 1), (61, 56, 1), +]; +/// `minecraft:custom_stat`: 77 in 26.2, 78 pinned. +#[rustfmt::skip] +pub const CUSTOM_STAT_UP: &[(u16, u16, u16)] = &[ + (0, 0, 61), (61, 62, 16), +]; +#[rustfmt::skip] +pub const CUSTOM_STAT_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 61), (62, 61, 16), +]; +/// `minecraft:map_decoration_type`: 35 in 26.2, 40 pinned. +#[rustfmt::skip] +pub const MAP_DECORATION_TYPE_UP: &[(u16, u16, u16)] = &[ + (0, 0, 35), +]; +#[rustfmt::skip] +pub const MAP_DECORATION_TYPE_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 35), +]; +/// `minecraft:block`: 1196 in 26.2, 1286 pinned. +#[rustfmt::skip] +pub const BLOCK_UP: &[(u16, u16, u16)] = &[ + (0, 0, 23), (23, 24, 11), (34, 36, 24), (58, 61, 12), (70, 74, 9), (79, 84, 9), (88, 94, 9), (97, 106, 29), + (126, 136, 8), (134, 145, 22), (156, 199, 34), (190, 234, 29), (219, 264, 14), (233, 279, 12), (245, 292, 10), (255, 303, 15), + (270, 319, 55), (325, 375, 72), (397, 448, 24), (421, 473, 31), (452, 505, 69), (521, 575, 87), (608, 663, 3), (611, 678, 1), + (612, 666, 11), (623, 716, 1), (624, 677, 1), (625, 679, 11), (636, 691, 9), (645, 701, 9), (654, 711, 5), (659, 717, 67), + (726, 816, 470), +]; +#[rustfmt::skip] +pub const BLOCK_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 23), (24, 23, 11), (36, 34, 24), (61, 58, 12), (74, 70, 9), (84, 79, 9), (94, 88, 9), (106, 97, 29), + (136, 126, 8), (145, 134, 22), (199, 156, 34), (234, 190, 29), (264, 219, 14), (279, 233, 12), (292, 245, 10), (303, 255, 15), + (319, 270, 55), (375, 325, 72), (448, 397, 24), (473, 421, 31), (505, 452, 69), (575, 521, 87), (663, 608, 3), (666, 612, 11), + (677, 624, 1), (678, 611, 1), (679, 625, 11), (691, 636, 9), (701, 645, 9), (711, 654, 5), (716, 623, 1), (717, 659, 67), + (816, 726, 470), +]; +/// `minecraft:recipe_serializer`: 21 in 26.2, 22 pinned. +#[rustfmt::skip] +pub const RECIPE_SERIALIZER_UP: &[(u16, u16, u16)] = &[ + (0, 0, 20), (20, 21, 1), +]; +#[rustfmt::skip] +pub const RECIPE_SERIALIZER_DOWN: &[(u16, u16, u16)] = &[ + (0, 0, 20), (21, 20, 1), +]; +/// Every table above, as the adapter reads them. +pub const IDS: super::ids::Ids = super::ids::Ids { + quirks: super::ids::Quirks::NONE, + states: (OLD_STATES, PINNED_STATES), + block_state: super::ids::Remap::Runs { + up: BLOCK_STATE_UP, + down: BLOCK_STATE_DOWN, + }, + item: super::ids::Remap::Runs { + up: ITEM_UP, + down: ITEM_DOWN, + }, + entity_type: super::ids::Remap::Runs { + up: ENTITY_TYPE_UP, + down: ENTITY_TYPE_DOWN, + }, + particle_type: super::ids::Remap::Runs { + up: PARTICLE_TYPE_UP, + down: PARTICLE_TYPE_DOWN, + }, + sound_event: super::ids::Remap::Runs { + up: SOUND_EVENT_UP, + down: SOUND_EVENT_DOWN, + }, + data_component_type: super::ids::Remap::Runs { + up: DATA_COMPONENT_TYPE_UP, + down: DATA_COMPONENT_TYPE_DOWN, + }, + command_argument_type: super::ids::Remap::Runs { + up: COMMAND_ARGUMENT_TYPE_UP, + down: COMMAND_ARGUMENT_TYPE_DOWN, + }, + custom_stat: super::ids::Remap::Runs { + up: CUSTOM_STAT_UP, + down: CUSTOM_STAT_DOWN, + }, + map_decoration_type: super::ids::Remap::Runs { + up: MAP_DECORATION_TYPE_UP, + down: MAP_DECORATION_TYPE_DOWN, + }, + block: super::ids::Remap::Runs { + up: BLOCK_UP, + down: BLOCK_DOWN, + }, + block_entity_type: super::ids::Remap::Same, + menu: super::ids::Remap::Same, + mob_effect: super::ids::Remap::Same, + attribute: super::ids::Remap::Same, + stat_type: super::ids::Remap::Same, + fluid: super::ids::Remap::Same, + game_event: super::ids::Remap::Same, + recipe_serializer: super::ids::Remap::Runs { + up: RECIPE_SERIALIZER_UP, + down: RECIPE_SERIALIZER_DOWN, + }, + slot_display: super::ids::Remap::Same, + recipe_display: super::ids::Remap::Same, + recipe_book_category: super::ids::Remap::Same, + villager_type: super::ids::Remap::Same, + villager_profession: super::ids::Remap::Same, + position_source_type: super::ids::Remap::Same, + consume_effect_type: super::ids::Remap::Same, + enchantment_effect_component_type: super::ids::Remap::Same, + // Not a registry: set by hand where a version numbers EntityDataSerializers differently. + entity_data_serializer: super::ids::Remap::Same, +}; diff --git a/crates/horizon-protocol/src/entity_data.rs b/crates/horizon-protocol/src/entity_data.rs index 7cf2fce2ba..ea62bee3b9 100644 --- a/crates/horizon-protocol/src/entity_data.rs +++ b/crates/horizon-protocol/src/entity_data.rs @@ -4,6 +4,7 @@ //! them are kept. use crate::{ Result, + adapter::ids, buf::{Codec, Reader, Writer}, item::{Slot, Stack, Template}, }; @@ -179,6 +180,11 @@ pub enum PositionSource { impl Particle { pub fn read(r: &mut Reader) -> Result { let id = r.varint()?; + let id = if id == MOD_PARTICLE_ID { + id + } else { + ids::particle(id) + }; // A native mod's type: the namespaced name follows the sentinel id. if id == MOD_PARTICLE_ID { return Ok(Particle { @@ -188,7 +194,7 @@ impl Particle { } // `minecraft:particle_type` registry order as of 26.3. let data = match id { - 1 | 2 | 36 | 121 | 125 => Some(ParticleData::State(r.varint()?)), + 1 | 2 | 36 | 121 | 125 => Some(ParticleData::State(ids::block_state(r.varint()?))), 28 | 46 | 52 => Some(ParticleData::Color(r.i32()?)), 21 => { let color = r.i32()?; @@ -355,9 +361,9 @@ fn read_value(kind: i32, r: &mut Reader) -> Result> { // VarInt-encoded: direction, block states, optional ints, pose, the // holder-registry variants (cat to zombie nautilus, painting) and the // enum states (sniffer to weathering copper), arm, dye. + // Block states as an older server numbers them (the optional one sends air, 0, for none). + BLOCK_STATE | OPTIONAL_BLOCK_STATE => Value::Id(ids::block_state(r.varint()?)), DIRECTION - | BLOCK_STATE - | OPTIONAL_BLOCK_STATE | OPTIONAL_UNSIGNED_INT | POSE | CAT_VARIANT..=ZOMBIE_NAUTILUS_VARIANT @@ -428,7 +434,7 @@ impl Codec> for EntityData { if index == 0xFF { return Ok(out); } - let kind = r.varint()?; + let kind = ids::entity_data_serializer(r.varint()?); match read_value(kind, r)? { Some(v) => out.push((index, kind, v)), None => { diff --git a/crates/horizon-protocol/src/item.rs b/crates/horizon-protocol/src/item.rs index 2e4a8c048c..5ff148cb08 100644 --- a/crates/horizon-protocol/src/item.rs +++ b/crates/horizon-protocol/src/item.rs @@ -3,6 +3,7 @@ //! client sends back in container clicks (HashedStack). use crate::{ Result, + adapter::ids, buf::{Codec, Reader, Writer, pack_block_pos, unpack_block_pos}, }; use horizon_world::nbt::Tag; @@ -702,14 +703,16 @@ impl WirePatch { let negative = r.count(65536)?; let mut added = Vec::with_capacity(positive.min(64)); for _ in 0..positive { - let kind = r.varint()?; + let kind = ids::component(r.varint()?); let len = r.count(r.remaining())?; added.push(( kind, Component::read(kind, &mut Reader::new(r.bytes(len)?))?, )); } - let removed = (0..negative).map(|_| r.varint()).collect::>()?; + let removed = (0..negative) + .map(|_| r.varint().map(ids::component)) + .collect::>()?; Ok((added, removed)) } } @@ -762,15 +765,15 @@ impl Codec> for Slot { return; }; w.varint(s.count); - w.varint(s.item); + w.varint(ids::item(s.item)); w.varint(s.added.len() as i32); w.varint(s.removed.len() as i32); for (kind, c) in &s.added { - w.varint(*kind); + w.varint(ids::component(*kind)); c.write(*kind, w); } for kind in &s.removed { - w.varint(*kind); + w.varint(ids::component(*kind)); } } } @@ -783,7 +786,7 @@ fn read_stack(r: &mut Reader, delimited: bool) -> Result> { if count <= 0 { return Ok(None); } - let item = r.varint()?; + let item = ids::item(r.varint()?); let positive = r.count(65536)?; let negative = r.count(65536)?; let mut added = Vec::with_capacity(positive.min(64)); @@ -791,7 +794,7 @@ fn read_stack(r: &mut Reader, delimited: bool) -> Result> { patch.varint(positive as i32); patch.varint(negative as i32); for _ in 0..positive { - let kind = r.varint()?; + let kind = ids::component(r.varint()?); let (component, value) = if delimited { let len = r.count(r.remaining())?; let value = r.bytes(len)?; @@ -809,7 +812,9 @@ fn read_stack(r: &mut Reader, delimited: bool) -> Result> { patch.byte_array(value); added.push((kind, component)); } - let removed: Vec = (0..negative).map(|_| r.varint()).collect::>()?; + let removed: Vec = (0..negative) + .map(|_| r.varint().map(ids::component)) + .collect::>()?; removed.iter().for_each(|kind| patch.varint(*kind)); Ok(Some(Stack { item, @@ -834,21 +839,25 @@ impl Codec> for DelimitedSlot { return; }; w.varint(s.count); - w.varint(s.item); - if let Some(patch) = &s.wire.0 { + w.varint(ids::item(s.item)); + // The patch as read holds the pinned version's component ids: written again from the values when + // they are being renumbered. + if let Some(patch) = &s.wire.0 + && ids::active().is_none() + { w.bytes(patch); return; } w.varint(s.added.len() as i32); w.varint(s.removed.len() as i32); for (kind, c) in &s.added { - w.varint(*kind); + w.varint(ids::component(*kind)); let mut value = Writer::new(); c.write(*kind, &mut value); w.byte_array(&value.data); } for kind in &s.removed { - w.varint(*kind); + w.varint(ids::component(*kind)); } } } @@ -857,16 +866,18 @@ impl Codec> for DelimitedSlot { pub struct Template; impl Codec for Template { fn read(r: &mut Reader) -> Result { - let item = r.varint()?; + let item = ids::item(r.varint()?); let count = r.varint()?; let positive = r.count(65536)?; let negative = r.count(65536)?; let mut added = Vec::with_capacity(positive.min(64)); for _ in 0..positive { - let kind = r.varint()?; + let kind = ids::component(r.varint()?); added.push((kind, Component::read(kind, r)?)); } - let removed = (0..negative).map(|_| r.varint()).collect::>()?; + let removed = (0..negative) + .map(|_| r.varint().map(ids::component)) + .collect::>()?; Ok(Stack { item, count, @@ -876,16 +887,16 @@ impl Codec for Template { }) } fn write(s: &Stack, w: &mut Writer) { - w.varint(s.item); + w.varint(ids::item(s.item)); w.varint(s.count); w.varint(s.added.len() as i32); w.varint(s.removed.len() as i32); for (kind, c) in &s.added { - w.varint(*kind); + w.varint(ids::component(*kind)); c.write(*kind, w); } for kind in &s.removed { - w.varint(*kind); + w.varint(ids::component(*kind)); } } } @@ -930,16 +941,16 @@ impl Codec> for HashedSlot { fn write(v: &Option, w: &mut Writer) { w.bool(v.is_some()); if let Some(h) = v { - w.varint(h.item); + w.varint(ids::item(h.item)); w.varint(h.count); w.varint(h.added.len() as i32); for (k, hash) in &h.added { - w.varint(*k); + w.varint(ids::component(*k)); w.i32(*hash); } w.varint(h.removed.len() as i32); for k in &h.removed { - w.varint(*k); + w.varint(ids::component(*k)); } } } diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index aafa86ebc7..34f49b732f 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -893,7 +893,7 @@ impl Codec<(i32, Option<(String, Option)>)> for SoundHolder { fn read(r: &mut Reader) -> Result<(i32, Option<(String, Option)>)> { let id = r.varint()?; if id != 0 { - return Ok((id - 1, None)); + return Ok((crate::adapter::ids::sound_event(id - 1), None)); } let location = r.string(32767)?; let range = if r.bool()? { Some(r.f32()?) } else { None }; diff --git a/crates/horizon-protocol/src/packets/recipes.rs b/crates/horizon-protocol/src/packets/recipes.rs index e357058435..bf01418c94 100644 --- a/crates/horizon-protocol/src/packets/recipes.rs +++ b/crates/horizon-protocol/src/packets/recipes.rs @@ -25,7 +25,9 @@ impl HolderSet { return Err(crate::Error::new("holder set larger than the packet")); } Ok(Self::Items( - (0..n).map(|_| r.varint()).collect::>()?, + (0..n) + .map(|_| r.varint().map(crate::adapter::ids::item)) + .collect::>()?, )) } pub fn write(&self, w: &mut Writer) { @@ -93,16 +95,19 @@ impl SlotDisplay { return Err(crate::Error::new("slot displays nest too deep")); } let inner = |r: &mut Reader| Self::read_at(r, depth + 1).map(Box::new); - Ok(match r.varint()? { + Ok(match crate::adapter::ids::slot_display(r.varint()?) { 0 => Self::Empty, 1 => Self::AnyFuel, 2 => Self::WithAnyPotion(inner(r)?), 3 => { let source = inner(r)?; - Self::OnlyWithComponent(source, r.varint()?) + Self::OnlyWithComponent(source, crate::adapter::ids::component(r.varint()?)) } - 4 => Self::Item(r.varint()?), + 4 => Self::Item(crate::adapter::ids::item(r.varint()?)), 5 => Self::ItemStack(Template::read(r)?), + 6 if crate::adapter::ids::quirks().tag_slot_display_is_id => { + Self::Tag(HolderSet::Tag(r.string(32767)?)) + } 6 => Self::Tag(HolderSet::read(r)?), 7 => Self::Dyed { dye: inner(r)?, diff --git a/crates/horizon-shaders/src/translate/mod.rs b/crates/horizon-shaders/src/translate/mod.rs index 355fedcc24..415acaf851 100644 --- a/crates/horizon-shaders/src/translate/mod.rs +++ b/crates/horizon-shaders/src/translate/mod.rs @@ -289,6 +289,14 @@ fn translate( samplers.insert(name.clone(), ty.clone()); } } + if gbuffers.is_some() { + // The references use the geometry transform state instead of a second frame slot. + for name in ["projectionMatrix", "modelViewMatrix"] { + if uniforms.get(name).is_some_and(|ty| ty == "mat4") { + uniforms.remove(name); + } + } + } // `alphaTestRef` is the program's alpha test reference: a constant of the program. let alpha_test_ref = gbuffers .filter(|_| uniforms.get("alphaTestRef").is_some_and(|t| t == "float")) @@ -1444,6 +1452,110 @@ mod tests { } } + /// Core matrix state and compatibility matrix state describe the same geometry transform. + #[test] + fn gbuffers_core_matrices_use_frame_state() { + for declaration in ["", "uniform mat4 projectionMatrix, modelViewMatrix;"] { + for profile in ["330 compatibility", "330 core"] { + let source = format!( + "#version {profile}\n{declaration}\nin vec3 vaPosition;\nvoid main() {{ gl_Position = projectionMatrix * modelViewMatrix * vec4(vaPosition, 1.0); }}\n" + ); + let p = core_matrix_program(&source, false).unwrap(); + for name in ["projectionMatrix", "modelViewMatrix"] { + assert!(!p.interface.uniforms.iter().any(|u| u.name == name)); + } + for name in ["gbufferProjection", "gbufferModelView"] { + assert!(p.interface.uniforms.iter().any(|u| u.name == name)); + } + } + } + } + + #[test] + fn core_matrix_aliases_preserve_authored_names_and_errors() { + for declarations in [ + "mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);", + "mat4 projectionMatrix; mat4 modelViewMatrix;", + "const mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);", + "uniform mat4 projectionMatrix, modelViewMatrix;", + ] { + let source = format!( + "#version 330\n{declarations}\n\ + struct S {{ mat4 projectionMatrix; mat4 modelViewMatrix; }};\n\ + vec4 local(mat4 projectionMatrix, mat4 modelViewMatrix) {{\n\ + return projectionMatrix * modelViewMatrix * vec4(1.0); }}\n\ + void main() {{\n\ + S s; s.projectionMatrix = mat4(1.0); s.modelViewMatrix = mat4(1.0);\n\ + vec4 p = projectionMatrix * modelViewMatrix * vec4(1.0);\n\ + {{ mat4 projectionMatrix = modelViewMatrix; p += projectionMatrix * vec4(1.0); }}\n\ + for (int modelViewMatrix = 0; modelViewMatrix < 2; modelViewMatrix++) p.x += float(modelViewMatrix);\n\ + gl_Position = p + local(s.projectionMatrix, s.modelViewMatrix); }}" + ); + core_matrix_program(&source, false).unwrap_or_else(|e| panic!("{declarations}: {e:?}")); + } + core_matrix_program( + "#version 330\nmat4 projectionMatrix() { return mat4(2.0); }\nvoid main() { gl_Position = projectionMatrix() * modelViewMatrix * vec4(1.0); }", false, + ).unwrap(); + let unknown = core_matrix_program( + "#version 330\nvoid main() { gl_Position = unknownMatrix * vec4(1.0); }", + false, + ) + .err() + .unwrap(); + assert!(unknown.iter().any(|e| e.message.contains("unknownMatrix"))); + let shadow = core_matrix_program( + "#version 330\nvoid main() { gl_Position = projectionMatrix * modelViewMatrix * vec4(1.0); }", true, + ).unwrap(); + for name in ["shadowProjection", "shadowModelView"] { + assert!(shadow.interface.uniforms.iter().any(|u| u.name == name)); + } + // Composite shaders have their own transform; undeclared core names stay errors. + let composite = program( + &[ + ( + "composite.vsh", + "#version 330\nvoid main() { gl_Position = projectionMatrix * vec4(1.0); }", + ), + ( + "composite.fsh", + "#version 330\nout vec4 c; void main() { c = vec4(1.0); }", + ), + ], + Target::Buffers, + ) + .err() + .unwrap(); + assert!( + composite + .iter() + .any(|e| e.message.contains("projectionMatrix")) + ); + } + + fn core_matrix_program(source: &str, shadow: bool) -> Result> { + let files = [ + ("shaders/gbuffers_line.vsh", source), + ( + "shaders/gbuffers_line.fsh", + "#version 330\nout vec4 color; void main() { color = vec4(1.0); }", + ), + ]; + let pack = Pack::from_zip_bytes("core-matrices.zip", zip(&files)).unwrap(); + let macros = Macros::standard(); + let vertex = preprocess(&pack, "gbuffers_line.vsh", ¯os); + let fragment = preprocess(&pack, "gbuffers_line.fsh", ¯os); + let inputs = GbuffersInputs { + body: "hz_Vertex = vec4(0.25, 0.5, -2.0, 1.0);".into(), + attributes: vec![("vaPosition".into(), "hz_Vertex.xyz".into())], + ..Default::default() + }; + if shadow { + translate_shadow(&vertex, &fragment, &inputs) + } else { + translate_gbuffers(&vertex, &fragment, Target::Buffers, &inputs) + } + } + #[test] fn compute_dispatch_does_not_guess_unsupported_scalar_expressions() { for declaration in [ diff --git a/crates/horizon-shaders/src/translate/rewrite.rs b/crates/horizon-shaders/src/translate/rewrite.rs index 4df8d8ae91..b9bee750e3 100644 --- a/crates/horizon-shaders/src/translate/rewrite.rs +++ b/crates/horizon-shaders/src/translate/rewrite.rs @@ -368,6 +368,7 @@ impl Stage<'_> { if sampler_ty.contains_key(t) || params.contains_key(t) || uniforms.get(t).is_some_and(|ty| ty == "bool") + || self.core_matrix(t).is_some() || ((depth > 0 || !calls.is_empty()) && self.runtime_array_lengths.contains_key(t)) { let before = prev(i).map(|j| self.tokens[j]); @@ -396,6 +397,10 @@ impl Stage<'_> { out.push_str(t); continue; } + if let Some(matrix) = self.core_matrix(t) { + out.push_str(matrix); + continue; + } // A sampler named like a function (`texture(texture, uv)`) is the function when called. let sampler = params .get(t) diff --git a/crates/horizon-shaders/src/translate/scan.rs b/crates/horizon-shaders/src/translate/scan.rs index 3afd6e890c..9319ccb32e 100644 --- a/crates/horizon-shaders/src/translate/scan.rs +++ b/crates/horizon-shaders/src/translate/scan.rs @@ -75,6 +75,8 @@ pub(super) struct Stage<'p> { pub(super) replaced: HashMap, /// Globals whose initializer runs in our `main` (`g = hz_init_g();`), in order. pub(super) inits: Vec, + /// Authored global names; automatic core matrix inputs must not hide them. + pub(super) globals: BTreeSet, /// Their captured array size constants and declarator ends: `.length()` stays type-only. pub(super) runtime_array_lengths: HashMap, pub(super) errors: Vec, @@ -315,6 +317,7 @@ impl<'p> Stage<'p> { gbuffers: false, replaced: HashMap::new(), inits: Vec::new(), + globals: BTreeSet::new(), runtime_array_lengths: HashMap::new(), errors: Vec::new(), }; @@ -476,6 +479,51 @@ impl<'p> Stage<'p> { } } + /// Core names share the compatibility state for geometry, including shadow transforms. + /// Authored non-uniform globals (or uniforms of another type) keep their own meaning. + pub(super) fn core_matrix(&self, name: &str) -> Option<&'static str> { + let matrix = match name { + "projectionMatrix" => "hz_ProjectionMatrix", + "modelViewMatrix" => "hz_ModelViewMatrix", + _ => return None, + }; + if !self.gbuffers + || (self.globals.contains(name) + && !self + .uniforms + .iter() + .any(|(n, ty, _)| n == name && ty == "mat4")) + { + return None; + } + Some(matrix) + } + + /// Remember names without interpreting their values. Commas inside constructors and array + /// suffixes do not introduce another declarator, nor do references in an initializer. + fn global_names(&mut self, words: &[usize], mut type_at: usize) { + while words + .get(type_at) + .is_some_and(|&i| self.tokens[i] == "const" || QUALIFIERS.contains(&self.tokens[i])) + { + type_at += 1; + } + let (mut depth, mut name) = (0i32, true); + for &i in words.iter().skip(type_at + 1) { + let token = self.tokens[i]; + if depth == 0 && name && is_ident(token) { + self.globals.insert(token.to_owned()); + name = false; + } + match token { + "(" | "[" | "{" => depth += 1, + ")" | "]" | "}" => depth -= 1, + "," if depth == 0 => name = true, + _ => {} + } + } + } + /// A statement at global scope, tokens `from..to` (without its `;`, or before a `{`). pub(super) fn global(&mut self, from: usize, to: usize, body: bool) { let words: Vec = (from..to) @@ -538,6 +586,7 @@ impl<'p> Stage<'p> { } k += 1; } + self.global_names(&words, k); let Some(storage) = storage else { return self.initializer(&words, to); }; @@ -916,6 +965,7 @@ impl<'p> Stage<'p> { let Some(&name) = open.checked_sub(1).and_then(|n| words.get(n)) else { return; }; + self.globals.insert(self.tokens[name].to_owned()); let mut params = Vec::new(); let mut is_sampler = false; for &i in &words[open + 1..] { diff --git a/crates/horizon-world/src/region.rs b/crates/horizon-world/src/region.rs index 2e6e7f9a7f..11a19f2a8f 100644 --- a/crates/horizon-world/src/region.rs +++ b/crates/horizon-world/src/region.rs @@ -234,7 +234,11 @@ impl RegionFile { if external { let temporary = directory.join(format!(".c.{}.{}.mcc.tmp", chunk.x, chunk.z)); let written = File::create(&temporary) - .and_then(|mut f| f.write_all(&record.compressed)) + .and_then(|mut f| { + f.write_all(&record.compressed)?; + // The region's sync only covers the stub, not this external payload. + f.sync_all() + }) .and_then(|_| fs::rename(&temporary, &external_file)); if let Err(e) = written { let _ = fs::remove_file(&temporary); diff --git a/docs/cosmetics.md b/docs/cosmetics.md index 75ce20eb0c..97f5100b9d 100644 --- a/docs/cosmetics.md +++ b/docs/cosmetics.md @@ -159,6 +159,9 @@ account (signed out, it asks you to sign in): - **States**: offline, the page keeps working from what's kept on disk and says how many changes wait to sync; a service failure says what you wear still shows in the game, with *Try again* and *Copy details* (host, status, time). +- **A launcher theme** worn (First Light) replaces the sky's six stops and the accent's hue + (`Saved::theme`, `Theme::with_sky`), from the outfit kept on disk, so it holds offline too. Taking + it off brings the player's own look back. - `HORIZON_LAUNCHER_PAGE=wardrobe` in a development build shows the page for a made-up account wearing `HORIZON_COSMETICS_LOCAL`'s items; `HORIZON_WARDROBE_STATE=offline|failed|closed|empty` - shows that state. + shows that state, `theme` wears First Light. diff --git a/launcher/Cargo.lock b/launcher/Cargo.lock index 9aa062ad0b..28118b9c3c 100644 --- a/launcher/Cargo.lock +++ b/launcher/Cargo.lock @@ -2807,6 +2807,7 @@ dependencies = [ "horizon-core", "horizon-world", "ipconfig", + "log", "serde_json", "sha1", "sha2", diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 0337502090..af85229b7f 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -836,6 +836,11 @@ pub(crate) fn install_as( pub fn remove(tag: &str) -> Result<(), String> { let dir = version_dir(tag); + remove_in(&dir) +} + +fn remove_in(dir: &Path) -> Result<(), String> { + let installed = dir.join(COMPLETE).is_file(); // The marker goes first: a version counted as installed until its folder is gone comes back // into `installed` when an overlapping removal re-reads the list mid-delete. let _ = fs::remove_file(dir.join(COMPLETE)); @@ -843,10 +848,18 @@ pub fn remove(tag: &str) -> Result<(), String> { // go of it, and remove_dir_all stops at the first one. Retry for a second; a folder that is // already gone is removed. for attempt in 1.. { - match fs::remove_dir_all(&dir) { + match fs::remove_dir_all(dir) { Ok(()) => break, Err(e) if e.kind() == std::io::ErrorKind::NotFound => break, - Err(e) if attempt == 5 => return Err(e.to_string()), + Err(e) if attempt == 5 => { + // A failed removal must remain discoverable and retryable. Files may + // already have gone, so this is recovery metadata, not a rollback. + if installed && dir.is_dir() { + fs::write(dir.join(COMPLETE), "") + .map_err(|restore| format!("{e}; {restore}"))?; + } + return Err(e.to_string()); + } Err(_) => std::thread::sleep(std::time::Duration::from_millis(250)), } } @@ -3435,6 +3448,31 @@ mod tests { use super::*; use std::cmp::Ordering::*; + #[test] + #[cfg(windows)] + fn failed_removal_stays_listed_and_can_be_retried() { + use std::os::windows::fs::OpenOptionsExt; + let versions = + std::env::temp_dir().join(format!("horizon-remove-locked-{}", std::process::id())); + let dir = versions.join("v0.2.0"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join(COMPLETE), "").unwrap(); + fs::write(dir.join(CLIENT), "locked executable").unwrap(); + // Refuse delete sharing, as a running Windows executable does. + let running = fs::File::options() + .read(true) + .share_mode(1) + .open(dir.join(CLIENT)) + .unwrap(); + assert!(remove_in(&dir).is_err()); + assert_eq!(installed_in(&versions), ["v0.2.0"]); + drop(running); + remove_in(&dir).unwrap(); + assert!(installed_in(&versions).is_empty()); + assert!(!dir.exists()); + fs::remove_dir_all(versions).unwrap(); + } + /// A canonical path loses Windows' verbatim prefix on a drive (horizon-mixins' output /// check stats `\\?\C:`, the volume itself, which Windows refuses, os error 1), and keeps /// it where there is no drive form. diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 37e16c59b2..9fdcdbbf99 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -273,6 +273,8 @@ struct Launcher { wardrobe: Option, /// Ask the service again on the next draw of the Wardrobe (set by each visit). wardrobe_reload: bool, + /// Work the look out again on the next frame (a launcher theme put on or taken off). + restyle: bool, /// Why the last picture for Home's background was refused. background_error: Option, settings: Settings, @@ -557,6 +559,7 @@ impl Launcher { skin: None, wardrobe: None, wardrobe_reload: false, + restyle: horizon_cosmetics::enabled(), background_error: None, args_input: line_edit::LineEdit::new(settings.game_args.clone(), 1024, 38), args_focused: false, @@ -4780,6 +4783,9 @@ impl Render for Launcher { .children(titlebar::render(&t, window)) .child(specimen::render(&t)); } + if std::mem::take(&mut self.restyle) { + self.theme = self.settings_theme(window); + } self.scale = window.scale_factor(); self.perf.frame(); if self.bench.as_ref().is_some_and(perf::Bench::scrolling) { diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs index 093df3331d..4c7e5569a6 100644 --- a/launcher/src/theme.rs +++ b/launcher/src/theme.rs @@ -97,6 +97,12 @@ pub fn pinned_dark(choice: Appearance) -> Option { impl Theme { /// The pinned look (`pinned_dark`: dark unless the player picked another), the system's when /// they asked to follow it, in the player's accent. + /// This look under another sky (a launcher theme from the Wardrobe), top to bottom. + pub fn with_sky(mut self, sky: [u32; 6]) -> Self { + self.night.sky = sky; + self + } + pub fn for_appearance(choice: Appearance, accent: Accent, system: WindowAppearance) -> Self { let system_dark = !matches!( system, diff --git a/launcher/src/themes_page.rs b/launcher/src/themes_page.rs index 6bd99a13cb..afa0233e4a 100644 --- a/launcher/src/themes_page.rs +++ b/launcher/src/themes_page.rs @@ -14,12 +14,15 @@ const STRIP: f32 = 120.; impl Launcher { /// The theme Settings ask for, under the window's own light or dark. + /// The player's look; a launcher theme worn in the Wardrobe brings its own sky and accent. pub(super) fn settings_theme(&self, window: &Window) -> Theme { - Theme::for_appearance( - self.settings.appearance, - self.settings.accent, - window.appearance(), - ) + let worn = self.worn_theme(); + let accent = worn.map_or(self.settings.accent, |(_, hue)| Accent::custom(hue)); + let theme = Theme::for_appearance(self.settings.appearance, accent, window.appearance()); + match worn { + Some((sky, _)) => theme.with_sky(sky), + None => theme, + } } /// The picture behind Home, if the player keeps one. diff --git a/launcher/src/wardrobe.rs b/launcher/src/wardrobe.rs index 040408b0d5..0e27cf7487 100644 --- a/launcher/src/wardrobe.rs +++ b/launcher/src/wardrobe.rs @@ -204,6 +204,19 @@ impl Saved { std::fs::rename(tmp, path) } + /// The launcher theme worn: its sky (`0xrrggbb`, top to bottom) and accent hue. + pub fn theme(&self) -> Option<([u32; 6], f32)> { + let id = self.me.as_ref()?.equipped.get("theme")?; + let entry = self + .catalogue + .as_ref()? + .items + .iter() + .find(|e| &e.id == id)?; + let theme = entry.theme.as_ref()?; + Some((theme.sky()?, theme.accent_hue)) + } + /// Make `change` here: the outfit shows it at once and it waits to be sent. A newer change of /// the same slot (or of visibility) replaces an older one still waiting. pub fn change(&mut self, change: Change) { diff --git a/launcher/src/wardrobe/tests.rs b/launcher/src/wardrobe/tests.rs index ab20ba878b..b6628b91d1 100644 --- a/launcher/src/wardrobe/tests.rs +++ b/launcher/src/wardrobe/tests.rs @@ -332,3 +332,25 @@ fn scratch(name: &str) -> PathBuf { std::fs::create_dir_all(&root).unwrap(); root } + +#[test] +fn the_theme_worn_is_the_one_equipped_in_the_theme_slot() { + let (m, service) = serve(|_| {}); + let session = sign_in(&m, &service); + let mut saved = Saved { + catalogue: Some(service.catalogue().unwrap()), + me: Some(service.me(&session).unwrap()), + pending: Vec::new(), + }; + assert_eq!(saved.theme(), None); + let wear = |item: &str| Change::Equip { + slot: "theme".into(), + item: Some(item.into()), + }; + saved.change(wear("dawn")); + let (sky, hue) = saved.theme().unwrap(); + assert_eq!((sky[4], hue), (0xe2a0b0, 344.)); + // A theme the catalogue doesn't hold (any more) leaves the player's own look. + saved.change(wear("dusk")); + assert_eq!(saved.theme(), None); +} diff --git a/launcher/src/wardrobe_page.rs b/launcher/src/wardrobe_page.rs index c775509c45..1abed97d03 100644 --- a/launcher/src/wardrobe_page.rs +++ b/launcher/src/wardrobe_page.rs @@ -238,6 +238,23 @@ impl Launcher { .detach(); } + /// The launcher theme the selected account wears (its sky and accent hue), from the outfit + /// kept on disk; None with cosmetics off. + pub(super) fn worn_theme(&self) -> Option<([u32; 6], f32)> { + if !horizon_cosmetics::enabled() { + return None; + } + let kept; + let saved = match &self.wardrobe { + Some(w) => &w.saved, + None => { + kept = Saved::load(&dir(), &self.wardrobe_account()?.uuid); + &kept + } + }; + saved.theme() + } + /// The account the page is for: the selected one, if it signed in with Microsoft. fn wardrobe_account(&self) -> Option { match self.accounts.player() { @@ -282,6 +299,7 @@ impl Launcher { (_, None) => None, }; w.toast = said.map(|s| (s, Instant::now())); + self.restyle = true; } /// Wear `item` in `slot` (None: nothing there), or turn Show my cosmetics on or off: at once @@ -311,6 +329,10 @@ impl Launcher { w.saved.change(change); let _ = w.saved.save(&dir(), &w.uuid); w.seen = None; + self.restyle = true; + let Some(w) = self.wardrobe.as_mut() else { + return; + }; let Some(session) = w.session.clone().filter(|s| s.live()) else { // Offline, or the session ended: it waits, and the next visit sends it. w.toast = Some(( @@ -380,7 +402,8 @@ impl Launcher { /// `HORIZON_LAUNCHER_PAGE=wardrobe` in a development build: the page for a made-up account /// that owns the items of `HORIZON_COSMETICS_LOCAL` and wears the first of each slot, for /// screenshots. Nothing is asked of Mojang or the service, and nothing is saved. - /// `HORIZON_WARDROBE_STATE=offline|failed|closed|empty` shows that state instead. + /// `HORIZON_WARDROBE_STATE=offline|failed|closed|empty` shows that state instead, `theme` wears + /// First Light. pub(super) fn wardrobe_preview(&mut self) { use horizon_launcher::wardrobe::{Claim, Entry, Me}; let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") else { @@ -406,7 +429,25 @@ impl Launcher { .entry(slot(i).to_owned()) .or_insert_with(|| i.id.clone()); } - let ids: Vec = items.iter().map(|i| i.id.clone()).collect(); + let mut ids: Vec = items.iter().map(|i| i.id.clone()).collect(); + // A launcher theme to wear: the design brief's First Light. + let dawn = Entry { + id: "first_light".into(), + hash: String::new(), + slot: "theme".into(), + names: [("en_us".to_owned(), "First Light".to_owned())].into(), + theme: Some(horizon_launcher::wardrobe::Theme { + sky: [ + "#0b1030", "#1d2a63", "#3d4c94", "#7f78c0", "#e2a0b0", "#ffd6a0", + ] + .map(String::from), + accent_hue: 246., + }), + }; + ids.push(dawn.id.clone()); + if state == "theme" { + equipped.insert("theme".to_owned(), dawn.id.clone()); + } let claim = Claim { state: if state == "closed" { ClaimState::Closed @@ -429,6 +470,7 @@ impl Launcher { names: [("en_us".to_owned(), title_case(&i.id))].into(), theme: None, }) + .chain([dawn]) .collect(), claim: claim.clone(), }; @@ -483,6 +525,7 @@ impl Launcher { toast: None, }); self.wardrobe_reload = false; + self.restyle = true; } /// The account's skin wearing what it wears now, baked once per change. diff --git a/launcher/src/worlds.rs b/launcher/src/worlds.rs index 3317ab1b82..641262f3ff 100644 --- a/launcher/src/worlds.rs +++ b/launcher/src/worlds.rs @@ -11,6 +11,18 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// The lock a running game holds on a world; vanilla's backups leave it out. const LOCK: &str = "session.lock"; +/// Hold the same exclusive lock as the game until a filesystem operation finishes. +fn lock_world(world: &World) -> Result { + let lock = fs::File::options() + .write(true) + .create(true) + .truncate(false) + .open(world.path.join(LOCK)) + .map_err(|e| e.to_string())?; + lock.try_lock().map_err(|e| e.to_string())?; + Ok(lock) +} + #[derive(Clone, Debug, PartialEq)] pub struct World { /// Its folder's name under `saves/`, the id commands take. @@ -158,6 +170,7 @@ fn backup_at( world: &World, now: chrono::DateTime, ) -> Result { + let _lock = lock_world(world)?; let dir = home.join("backups"); fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; let prefix = format!("{}_{}", now.format("%Y-%m-%d_%H-%M-%S"), world.folder); @@ -201,6 +214,7 @@ fn backup_at( /// Copies the world into a new folder of the same `saves/`, named " (1)", " (2)"... /// and gives the copy that name in its level.dat so the game's list tells them apart. pub fn copy(world: &World) -> Result { + let _lock = lock_world(world)?; let saves = world.path.parent().ok_or("A world folder has a parent")?; let folder = free_name(saves, &world.name, "", 1); let to = saves.join(&folder); @@ -251,6 +265,13 @@ fn rename_level(world: &Path, name: &str) -> std::io::Result<()> { /// Moves the world to the system's trash, where the player can still get it back. pub fn delete(world: &World) -> Result<(), String> { + let lock = lock_world(world)?; + // Match server deletion: Windows cannot move the folder with this handle open. + // If the game opens it after the probe, its handle prevents the move instead. + #[cfg(windows)] + drop(lock); + #[cfg(not(windows))] + let _lock = lock; crate::desktop_seams::to_trash(&world.path) .map_err(|e| format!("Could not move {} to the trash: {e}", world.name)) } diff --git a/launcher/src/worlds/tests.rs b/launcher/src/worlds/tests.rs index 7949b8c83c..f663eaa253 100644 --- a/launcher/src/worlds/tests.rs +++ b/launcher/src/worlds/tests.rs @@ -36,6 +36,30 @@ fn name(n: &str) -> Tag { Tag::String(n.into()) } +#[test] +fn active_worlds_cannot_be_backed_up_copied_or_deleted() { + let home = scratch("active-lock"); + let dir = world(&home, "Active", vec![("LevelName", name("Active"))]); + let w = find(&home, "Active").unwrap(); + let game = fs::File::options() + .write(true) + .open(dir.join(LOCK)) + .unwrap(); + game.try_lock().unwrap(); + + assert!(backup(&home, &w).is_err()); + assert!(copy(&w).is_err()); + assert!(delete(&w).is_err()); + assert!(!home.join("backups").exists()); + assert!(!home.join("saves/Active (1)").exists()); + assert_eq!(fs::read(dir.join("region/r.0.0.mca")).unwrap(), [7u8; 1000]); + + drop(game); + assert!(backup(&home, &w).unwrap().is_file()); + assert!(copy(&w).unwrap().path.is_dir()); + fs::remove_dir_all(home).unwrap(); +} + #[test] fn lists_what_each_world_is() { let home = scratch("list"); diff --git a/tools/command_exec_corpus.py b/tools/command_exec_corpus.py index 6032caf4b5..06d70c1a9e 100644 --- a/tools/command_exec_corpus.py +++ b/tools/command_exec_corpus.py @@ -918,6 +918,119 @@ def items_and_loot(): ] +def aquatic_from_bucket(): + """Saved bucket flags default false, except the always-true tadpole (#7763).""" + ops = [] + case = 0 + for kind in ('cod', 'salmon', 'pufferfish', 'tropical_fish', 'axolotl', 'tadpole'): + for value in (None, '0b', '1b'): + case += 1 + tag = f'h108_{case}' + target = f'@e[type=minecraft:{kind},tag={tag},limit=1]' + bucket = '' if value is None else ',FromBucket:' + value + ops += setup(f'/summon minecraft:{kind} 10.5 220 10.5 ' + f'{{Tags:["{tag}"],NoAI:1b,NoGravity:1b,Silent:1b,' + f'PersistenceRequired:1b{bucket}}}') + # Advance an entity tick before reading the saved field and + # checking that this isolated subject still exists. + ops += [{'op': 'tick', 'n': 1}, + cmd(f'/execute if entity {target}'), + cmd(f'/data get entity {target} FromBucket')] + ops += setup(f'/kill {target}') + return ops + + + + +def summoned_fangs_lifetime(): + ops = setup('/fill 0 199 0 15 199 15 stone', + '/fill 0 200 0 15 206 15 air') + target = '@e[type=minecraft:evoker_fangs,limit=1]' + for i, warmup in enumerate((None, 0, 5, -2)): + extra = '' if warmup is None else f',Warmup:{warmup}' + ops += [cmd('/summon evoker_fangs 10.5 200 10.5 ' + f'{{UUID:[I;0,0,1,{32+i}],NoGravity:1b,Silent:1b{extra}}}'), + cmd(f'/data get entity {target} Pos')] + previous = 0 + for tick in (0, 1, 7, 22, 23, 27, 28): + if tick > previous: + ops += [{'op': 'tick', 'n': tick - previous}] + # Record both: missing entity and missing Warmup are different + # command failures and must never be collapsed by the verifier. + ops += [cmd(f'/execute if entity {target}'), + cmd(f'/data get entity {target} Warmup')] + previous = tick + ops += setup('/kill @e[type=minecraft:evoker_fangs]') + ops += [{'op': 'tick', 'n': 1}] + return ops + + +def summoned_fangs_edits(): + target = '@e[type=evoker_fangs,tag=fang_control,limit=1]' + return [ + cmd('/summon evoker_fangs 10.5 200 10.5 ' + '{UUID:[I;0,0,1,99],Tags:["fang_control"],NoGravity:1b,Silent:1b,Warmup:50}'), + cmd('/execute if entity 00000000-0000-0000-0000-000100000063'), + cmd(f'/data get entity {target} UUID'), + cmd(f'/tag {target} add fang_edited'), + cmd('/execute if entity @e[type=evoker_fangs,tag=fang_edited]'), + cmd(f'/data modify entity {target} Warmup set value 0'), + {'op': 'tick', 'n': 1}, + cmd(f'/data get entity {target} Warmup'), + cmd(f'/data remove entity {target} Warmup'), + {'op': 'tick', 'n': 1}, + cmd(f'/data get entity {target} Warmup'), + # Java int predecrement wraps; loading this value must not panic. + cmd(f'/data modify entity {target} Warmup set value -2147483648'), + {'op': 'tick', 'n': 1}, + cmd(f'/data get entity {target} Warmup'), + cmd(f'/kill {target}'), + cmd('/execute if entity @e[type=evoker_fangs]'), + ] + + +def summoned_fangs_levels(): + # Distance limits these selectors to the source level. An already + # loaded fang must stay in the Overworld across a command-level swap. + box = 'x=10,y=200,z=10,distance=..3' + return [ + cmd('/summon evoker_fangs 10.5 200 10.5 {Tags:["fang_overworld"],Warmup:1000,Silent:1b}'), + cmd(f'/execute in minecraft:the_nether if entity @e[tag=fang_overworld,{box}]'), + cmd(f'/execute in minecraft:overworld if entity @e[tag=fang_overworld,{box}]'), + *setup('/kill @e[type=evoker_fangs]'), + ] + + +def bird_flap_sensors(): + # Real mob movement -> Game event dispatch -> sculk sensor. Silent birds + # still emit vibrations; NoAI controls must keep the same positions too. + ops = setup('/gamerule random_tick_speed 0', '/tp @s 1.5 200 1.5') + target = '@e[tag=flap_bird,limit=1]' + for species in ('chicken', 'parrot'): + for no_ai in (0, 1): + ops += setup('/kill @e[tag=flap_bird]', + '/fill 0 200 0 15 206 15 air', + '/fill 0 199 0 15 199 15 stone') + ops += [{'op': 'tick', 'n': 45}] + ops += setup('/setblock 9 200 10 sculk_sensor strict') + ops += [{'op': 'tick', 'n': 2}] + ops += [cmd('/execute if block 9 200 10 sculk_sensor[sculk_sensor_phase=inactive,power=0]')] + ops += setup(f'/summon {species} 6.5 205 10.5 ' + f'{{Tags:["flap_bird"],NoAI:{no_ai}b,Silent:1b,NoGravity:1b,' + 'Motion:[0d,-1d,0d],attributes:[' + '{id:"minecraft:movement_speed",base:0d},' + '{id:"minecraft:flying_speed",base:0d}]}') + ops += [cmd(f'/execute if entity {target}'), + cmd(f'/data get entity {target} Pos')] + for _ in range(30): + ops += [{'op': 'tick', 'n': 1}, + cmd(f'/data get entity {target} Pos'), + cmd('/execute if block 9 200 10 sculk_sensor[sculk_sensor_phase=active,power=8]')] + ops += [cmd('/data get block 9 200 10 last_vibration_frequency')] + return ops + setup('/kill @e[tag=flap_bird]', '/setblock 9 200 10 air strict', + '/gamerule random_tick_speed 3') + + def item_nbt_count_bounds(): """ItemStack's NBT count codec defaults invalid counts to one (#7724).""" stand = '@e[type=minecraft:armor_stand,tag=h085,limit=1]' @@ -2238,6 +2351,7 @@ def groups(): ('setblock destroy air', setblock_destroy_air()), ('forceload query order', forceload_query_order()), ('entities', entities()), + ('aquatic FromBucket', aquatic_from_bucket()), ('villager command names', villager_command_names()), ('item lifetime counters', item_lifetime_counters()), @@ -2267,6 +2381,10 @@ def groups(): ('levels', levels()), ('end portal entities', end_portal_entities()), ('items and loot', items_and_loot()), + ('summoned fangs lifetime', summoned_fangs_lifetime()), + ('summoned fangs edits', summoned_fangs_edits()), + ('summoned fangs levels', summoned_fangs_levels()), + ('bird flap sensors', bird_flap_sensors()), ('loot set damage', loot_set_damage()), diff --git a/tools/generate_registry_id_adapter.py b/tools/generate_registry_id_adapter.py new file mode 100644 index 0000000000..2bd8d734b1 --- /dev/null +++ b/tools/generate_registry_id_adapter.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""Generate crates/horizon-protocol/src/adapter/v_ids.rs: how an older release numbers the registry entries +the protocol sends by id (block states, items, entity types, ...) against the pinned one, from both versions' +data generator reports (registries.json, blocks.json). + + generate_registry_id_adapter.py --old ~/code/horizon-parity/versions/26.2 + +`--old` (and `--new`, the pinned version in .local by default) is a directory `tools/version_port.py prepare +VERSION --download` made. Entries are matched by name; block states by block name and properties. Each +registry gets two tables of runs `(from, to, length)`: `_UP` from the old server's ids to the pinned ones, +`_DOWN` back. An id in no run has no entry in the other version. Only registries whose ids differ are written; the rest map to themselves. +""" + +import argparse +import json +from pathlib import Path + +try: + from . import lab +except ImportError: + import lab + +ROOT = Path(__file__).resolve().parents[1] +PER_LINE = 16 +NONE = 65535 +# Registries the protocol sends by numeric id (ByteBufCodecs.registry / holderRegistry / idMapper over a +# registry), as the adapter reads them. +REGISTRIES = [ + "minecraft:item", + "minecraft:entity_type", + "minecraft:particle_type", + "minecraft:sound_event", + "minecraft:data_component_type", + "minecraft:command_argument_type", + "minecraft:custom_stat", + "minecraft:map_decoration_type", + "minecraft:block", + "minecraft:block_entity_type", + "minecraft:menu", + "minecraft:mob_effect", + "minecraft:attribute", + "minecraft:stat_type", + "minecraft:fluid", + "minecraft:game_event", + "minecraft:recipe_serializer", + "minecraft:slot_display", + "minecraft:recipe_display", + "minecraft:recipe_book_category", + "minecraft:villager_type", + "minecraft:villager_profession", + "minecraft:position_source_type", + "minecraft:consume_effect_type", + "minecraft:enchantment_effect_component_type", +] + + +def entries(report, registry): + """{name: id} of one registry, checked to number its entries 0..n.""" + found = {name: e["protocol_id"] for name, e in report.get(registry, {}).get("entries", {}).items()} + assert sorted(found.values()) == list(range(len(found))), registry + return found + + +def states(blocks): + """{(block, properties): state id} over every state of every block.""" + found = {} + for name, block in blocks.items(): + for state in block["states"]: + key = (name, tuple(sorted(state.get("properties", {}).items()))) + found[key] = state["id"] + assert sorted(found.values()) == list(range(len(found))) + return found + + +def table(source, target, renames=None): + """For each id of `source` (in order), the id `target` gives the same key (or the key it was renamed + to), or NONE.""" + renames = renames or {} + by_id = sorted(source.items(), key=lambda kv: kv[1]) + return [target.get(renames.get(key, key), NONE) for key, _ in by_id] + + +def runs(values): + """The mapping as runs (from, to, length): ids from..from+length map to to..to+length. Ids in no run + have no counterpart.""" + found = [] + for i, v in enumerate(values): + if v == NONE: + continue + if found and found[-1][0] + found[-1][2] == i and found[-1][1] + found[-1][2] == v: + found[-1][2] += 1 + else: + found.append([i, v, 1]) + return found + + +def rust_table(name, values): + assert all(0 <= v <= NONE for v in values) + cells = [f"({a}, {b}, {n})" for a, b, n in runs(values)] + lines = ["#[rustfmt::skip]", f"pub const {name}: &[(u16, u16, u16)] = &["] + for i in range(0, len(cells), PER_LINE // 2): + lines.append(" " + ", ".join(cells[i:i + PER_LINE // 2]) + ",") + lines.append("];") + return lines + + +def const_name(registry): + return registry.split(":", 1)[1].upper() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--old", type=Path, required=True, help="the older version's prepared directory") + parser.add_argument("--new", type=Path, help="the pinned version's prepared directory (default: .local)") + parser.add_argument("--out", type=Path) + parser.add_argument("--rename", action="append", default=[], metavar="REGISTRY:OLD=NEW", + help="an entry the pinned version knows by another name, e.g. " + "minecraft:command_argument_type:minecraft:color=minecraft:team_color") + args = parser.parse_args() + renames = {} + for spec in args.rename: + head, new = spec.split("=", 1) + registry, old_path = head.rsplit(":", 1) + registry, old_ns = registry.rsplit(":", 1) + renames.setdefault(registry, {})[f"{old_ns}:{old_path}"] = new + old_reports = args.old / "datagen/reports" + new_reports = args.new / "datagen/reports" if args.new else ROOT / ".local/datagen/out/reports" + old_info = lab.version_info(args.old / "jars/client.jar") + info = lab.version_info(args.new / "jars/client.jar") if args.new else lab.version_info() + old_reg = json.loads((old_reports / "registries.json").read_text()) + new_reg = json.loads((new_reports / "registries.json").read_text()) + module = "v" + old_info["id"].replace(".", "_") + "_ids" + out = args.out or ROOT / f"crates/horizon-protocol/src/adapter/{module}.rs" + lines = [ + f"//! Registry ids of {old_info['id']} against the pinned {info['name']}: block states and the registries", + "//! the protocol sends by id. Generated by tools/generate_registry_id_adapter.py from both versions'", + "//! registries and blocks reports; do not edit by hand.", + "", + ] + old_states = states(json.loads((old_reports / "blocks.json").read_text())) + new_states = states(json.loads((new_reports / "blocks.json").read_text())) + lines.append(f"/// Block states: {len(old_states)} in {old_info['id']}, {len(new_states)} pinned.") + lines.append(f"pub const OLD_STATES: u32 = {len(old_states)};") + lines.append(f"pub const PINNED_STATES: u32 = {len(new_states)};") + lines.extend(rust_table("BLOCK_STATE_UP", table(old_states, new_states))) + lines.extend(rust_table("BLOCK_STATE_DOWN", table(new_states, old_states))) + same = [] + for registry in REGISTRIES: + o, n = entries(old_reg, registry), entries(new_reg, registry) + if o == n: + same.append(registry) + continue + name = const_name(registry) + lines.append(f"/// `{registry}`: {len(o)} in {old_info['id']}, {len(n)} pinned.") + forward = renames.get(registry, {}) + backward = {new: old for old, new in forward.items()} + lines.extend(rust_table(f"{name}_UP", table(o, n, forward))) + lines.extend(rust_table(f"{name}_DOWN", table(n, o, backward))) + lines.append(f"/// Every table above, as the adapter reads them.") + lines.append("pub const IDS: super::ids::Ids = super::ids::Ids {") + lines.append(" quirks: super::ids::Quirks::NONE,") + lines.append(" states: (OLD_STATES, PINNED_STATES),") + lines.append(" block_state: super::ids::Remap::Runs { up: BLOCK_STATE_UP, down: BLOCK_STATE_DOWN },") + for registry in REGISTRIES: + field = registry.split(":", 1)[1] + if registry in same: + lines.append(f" {field}: super::ids::Remap::Same,") + else: + name = const_name(registry) + lines.append(f" {field}: super::ids::Remap::Runs {{ up: {name}_UP, down: {name}_DOWN }},") + lines.append(" // Not a registry: set by hand where a version numbers EntityDataSerializers differently.") + lines.append(" entity_data_serializer: super::ids::Remap::Same,") + lines.append("};") + out.write_text("\n".join(lines) + "\n") + print(out, "same:", ", ".join(same)) + + +if __name__ == "__main__": + main() diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 04e076db34..1e85621c21 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -6932,6 +6932,128 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'tag-query': check_tag_query} + +def check_raid_captain_fields(host, guest, out): + """Real first-wave leader initialization, observed through saved fields (#7849).""" + def command(text): + return server_reply(host, text) + + def game_time(): + text = server_reply(host, 'time query gametime', 'time is') + return int(re.search(r'time is (\d+)', text).group(1)) + + def poll(observe, predicate, label, seconds=90): + deadline = time.monotonic() + seconds + while True: + value = observe() + if predicate(value): + return value + assert time.monotonic() < deadline, (label, value) + time.sleep(0.05) + + def test(text): + line = server_reply(host, text, 'Test ') + assert 'Test passed' in line or 'Test failed' in line, line + return 'Test passed' in line + + def saved(target, path): + line = command(f'data get entity {target} {path}') + assert 'No entity was found' not in line, (target, path, line) + if 'Found no elements matching' in line: + return None + prefix = 'has the following entity data: ' + assert prefix in line, (target, path, line) + return line.split(prefix, 1)[1] + + positions = [(g, pos_of(g)) for g in (host, guest)] + rule_names = ['spawn_mobs', 'spawn_patrols', 'advance_time', 'mob_griefing', 'mob_drops'] + rules = {} + for name in rule_names: + line = command(f'gamerule {name}') + value = re.search(r'\b(true|false)\b', line) + assert value, line + rules[name] = value.group(1) + try: + for g in (host, guest): + g('command', command='gamemode creative') + g('command', command='effect clear @s') + for text in ['difficulty normal', 'gamerule spawn_mobs false', + 'gamerule spawn_patrols false', 'gamerule advance_time false', + 'gamerule mob_griefing false', 'gamerule mob_drops false', + 'time set noon', 'forceload add -128 -128 127 127']: + command(text) + for x, z in [(-128, -128), (-128, 127), (127, -128), (127, 127)]: + poll(lambda: test(f'execute if loaded {x} 199 {z}'), bool, 'loaded raid floor') + for x0, x1 in [(-128, -1), (0, 127)]: + for z0, z1 in [(-128, -1), (0, 127)]: + line = command(f'fill {x0} 199 {z0} {x1} 199 {z1} stone') + assert 'filled' in line.lower(), line + for text in ['fill -3 199 -3 5 203 5 glass hollow', + 'setblock 2 200 2 red_bed[facing=south,part=head]', + 'setblock 2 200 1 red_bed[facing=south,part=foot]', + 'summon villager 0.5 200 0.5 {PersistenceRequired:1b,NoAI:0b,Tags:["h163_village"]}', + 'tick unfreeze', 'tick rate 200']: + command(text) + start = game_time() + prepared = poll(game_time, lambda t: t >= start + 300, 'bed-claim ticks') + command('tick rate 20') + command('tick freeze') + frozen = game_time() + for x in (4.5, 3.5): + guest('command', command=f'tp @s {x} 200 3.5') + guest.wait(5) + line = server_reply(guest, 'data get entity @s Pos', f'[{x}d, 200.0d, 3.5d]') + assert f'[{x}d, 200.0d, 3.5d]' in line, line + command('tick rate 1000') + omen = f'execute if entity @a[name={GUEST_NAME},nbt={{active_effects:[{{id:"minecraft:raid_omen"}}]}}]' + guest('command', command='effect give @s minecraft:bad_omen 100 0') + poll(lambda: test(omen), bool, 'Raid Omen appeared', seconds=60) + poll(lambda: test(omen), lambda present: not present, 'Raid Omen expired', seconds=60) + assert game_time() == frozen, 'omen processing must leave world ticks frozen' + elapsed = 0 + for ticks in (1, 149, 150): + command(f'tick step {ticks}') + elapsed += ticks + actual = poll(game_time, lambda t: t >= frozen + elapsed, 'completed raid step') + assert actual == frozen + elapsed, (ticks, actual, frozen + elapsed) + captain = '@e[nbt={PatrolLeader:1b},limit=1]' + line = server_reply(host, 'execute if entity @e[nbt={PatrolLeader:1b}]', 'Test ') + assert 'Count: 1' in line, line + for tag, extra in [('h163_ordinary', ''), ('h163_explicit', ',drop_chances:{head:2.0f}')]: + command('summon pillager 4.5 200 4.5 ' + f'{{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Tags:["{tag}"]{extra}}}') + rows = {} + for name, target in [('captain', captain), ('ordinary', '@e[tag=h163_ordinary,limit=1]'), + ('explicit', '@e[tag=h163_explicit,limit=1]')]: + exists = test(f'execute if entity {target}') + assert exists, name + head, chance = saved(target, 'equipment.head'), saved(target, 'drop_chances') + present = test(f'execute as {target} if data entity @s drop_chances.head') + has_chance = name != 'ordinary' + checks = {'exists': exists, + 'head': (head is not None and 'white_banner' in head and 'banner_patterns' in head) + if name == 'captain' else head is None, + 'chance': chance == '{head: 2.0f}' if has_chance else chance is None, + 'presence': present == has_chance} + rows[name] = {'head': head, 'drop_chances': chance, 'presence': present, 'checks': checks} + failures = [f'{name}/{key}' for name, row in rows.items() + for key, passed in row['checks'].items() if not passed] + detail = {'passed': 12 - len(failures), 'observations': 12, 'failures': failures, + 'bed_claim_ticks': prepared - start, 'raid_ticks': elapsed, 'subjects': rows} + (out / 'raid-captain-fields.json').write_text(json.dumps(detail, indent=2) + '\n') + assert not failures, detail + return detail + finally: + for text in ['effect clear @a', 'tick rate 20', 'tick unfreeze', + 'kill @e[type=pillager]', 'kill @e[tag=h163_village]', 'forceload remove all']: + host('command', command=text) + for name, value in rules.items(): + host('command', command=f'gamerule {name} {value}') + for game, at in positions: + game('command', command='tp @s ' + ' '.join(str(v) for v in at)) + game('command', command='gamemode survival') + + def run(ctl, out, resources, checks): host, guest = Game('host', ctl, resources), Game('guest', ctl, resources) # A headless run (HORIZON_E2E_HEADLESS, or a Linux without vscreen) has no focused window to draw @@ -12315,6 +12437,10 @@ CHECKS += ['datapack-recipe'] CHECK_FNS['datapack-recipe'] = check_datapack_recipe +CHECKS += ['raid-captain-fields'] +CHECK_FNS['raid-captain-fields'] = check_raid_captain_fields + + def check_damage_attacker(host, guest, out): """Connected causing players survive /damage and /execute on attacker. diff --git a/tools/port_map/animal-1.tsv b/tools/port_map/animal-1.tsv index e428ac8819..f818d35cff 100644 --- a/tools/port_map/animal-1.tsv +++ b/tools/port_map/animal-1.tsv @@ -1,3 +1,5 @@ +AbstractFish.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt FromBucket always written; tadpole getter is always true +AbstractFish.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt FromBucket via read_variant defaults false; tadpole ignores loaded input AgeableWaterCreature.handleAirSupply inline crates/horizon-client/src/mobs/ai.rs::base_tick the needs_water branch of base_tick; dolphins override it to nothing and refill air in aquatic.rs AgeableWaterCreature.checkSurfaceAgeableWaterCreatureSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::surface_water Animal.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt InLove is read; LoveCause is not kept @@ -39,8 +41,8 @@ ArmadilloAi.initIdleActivity inline crates/horizon-client/src/mobs/armadillo.rs: ArmadilloAi.initScaredActivity inline crates/horizon-client/src/mobs/armadillo.rs::server_ai_step the PANIC activity and ArmadilloBallUp Axolotl. port crates/horizon-client/src/mobs/aquatic.rs::register the Axolotl arm: smooth swimming move and look control, amphibious navigation, water malus 0 Axolotl.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta variant and playing_dead; from_bucket is not synced -Axolotl.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Variant is written; FromBucket is not -Axolotl.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant Variant is read; FromBucket is not +Axolotl.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Variant and FromBucket are written +Axolotl.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant Variant and FromBucket are read Axolotl.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize group variants and baby age Axolotl.tickBabyAnimations inline crates/horizon-client/src/mobs/axolotl.rs::Animation::tick the baby branch Axolotl.soloAnimation inline crates/horizon-client/src/mobs/axolotl.rs::Animation::tick one state started, the rest stopped @@ -109,6 +111,8 @@ CamelAi.initCoreActivity inline crates/horizon-client/src/mobs/camel.rs::registe CamelAi.initIdleActivity inline crates/horizon-client/src/mobs/camel.rs::register breed, tempt, follow parent, stroll, random sitting, look CamelAi.CamelPanic.checkExtraStartConditions port crates/horizon-client/src/mobs/camel.rs::CamelPanic::can_use CamelAi.RandomSitting.checkExtraStartConditions port crates/horizon-client/src/mobs/camel.rs::RandomSitting::can_use gap: no leash or suffocation (canCamelChangePose) check +Chicken.isFlapping port crates/horizon-client/src/mobs/farm.rs::chicken_process_flapping strict flyDist > nextFlap, used by air movement and live Game flap dispatch +Chicken.onFlap port crates/horizon-client/src/mobs/farm.rs::chicken_process_flapping nextFlap = flyDist + flapSpeed / 2, no sound Chicken.registerGoals port crates/horizon-client/src/mobs/farm.rs::register Chicken.getSoundSet inline crates/horizon-client/src/mobs/ai.rs::voice_sounds gap: baby sets only; the picky sound variant is stored but never used Chicken.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::Game::breed gap: baby copies the first parent's variant, no random parent @@ -141,3 +145,4 @@ MushroomCow.get port crates/horizon-client/src/mobs/finalize.rs::species_variant MushroomCow.applyImplicitComponent port crates/horizon-client/src/entity_nbt.rs::apply_item_components an egg's mooshroom/variant sets the colour MushroomCow.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::Game::breed see getOffspringVariant MushroomCow.getOffspringVariant port crates/horizon-client/src/game/creatures.rs::breed ticked in #3512; `MushroomCow.getOffspringVariant` is cited at crates/horizon-client/src/game/creatures.rs:2566 (in a comment, so `where` names the enclosing fn) +Tadpole.fromBucket inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt saved FromBucket is always true diff --git a/tools/port_map/client-004.tsv b/tools/port_map/client-004.tsv index 96c290aa89..1a5a74bebe 100644 --- a/tools/port_map/client-004.tsv +++ b/tools/port_map/client-004.tsv @@ -9,7 +9,7 @@ EnderDragonRenderer.submit port crates/horizon-client/src/game/dragon.rs::Game:: EnderDragonRenderer.submitRays port crates/horizon-client/src/game/dragon.rs::Game::dragon_rays EnderDragonRenderer.submitCrystalBeams port crates/horizon-client/src/game/dragon.rs::Game::crystal_beam EnderDragonRenderer.extractRenderState inline crates/horizon-client/src/game/dragon.rs::Game::dragon_geometry flap, death fade, hurt overlay, nearest-crystal beam and egg distance read there -EndermanRenderer.getRenderOffset port crates/horizon-client/src/mobs/render.rs::enderman_render_offset EndermanRenderer.getRenderOffset's random side offset for a creepy enderman +EndermanRenderer.getRenderOffset port crates/horizon-client/src/mobs/render.rs::enderman_render_offset EndermanRenderer.getRenderOffset's random side offset for a creepy enderman, 0.02 * minecraft:scale per axis EndermanRenderer.extractRenderState inline crates/horizon-client/src/mobs/enderman.rs::anim gap: minor: carried block is drawn by game/mob_blocks.rs, not from this pose EntityRenderDispatcher.getRenderer skip:other renderer lookup; Horizon dispatches by mob kind in game.rs::Game::entity_geometry and game/creatures.rs::Game::mob_geometry EntityRenderDispatcher.getAvatarRenderer port crates/horizon-client/src/mobs/render.rs::body slim or wide arm model by skin diff --git a/tools/port_map/game-006.tsv b/tools/port_map/game-006.tsv index d30b257634..db6b81af1b 100644 --- a/tools/port_map/game-006.tsv +++ b/tools/port_map/game-006.tsv @@ -32,7 +32,9 @@ LinearInterpolationHandler.doInterpolate inline crates/horizon-client/src/game/r LivingEntity. port crates/horizon-client/src/mobs.rs::Mob::new box, health, random yaw LivingEntity.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: player: arrow count (12) and stinger count (13) are never kept or sent, so arrows and bee stingers do not stay stuck in a body LivingEntity.hasLandedInLiquid port crates/horizon-world/src/living_fall.rs::ImpulseContext::landed_in_liquid water or lava with getDeltaMovement().y below 9.9999997E-6, from the guest move loop; the grace time still defers it -LivingEntity.getScale port crates/horizon-client/src/mobs/riders.rs::scale the minecraft:scale a client has for any living entity; the server's player reads its own in PlayerMotion::refresh_dimensions (PlayerMotion.scale keeps it for Guest::dimensions) +LivingEntity.getDimensions port crates/horizon-client/src/mobs/living.rs::dims getDefaultDimensions(pose).scale(getScale()) for every living entity: the type's box (a class's baby, size or dig dims) with width, height and eye height all times minecraft:scale; a sleeper's 0.2 SLEEPING_DIMENSIONS comes before the scale +LivingEntity.getScale port crates/horizon-client/src/mobs/living.rs::model_scale the minecraft:scale a client has for any living entity, through sanitizeScale; the server's player reads its own in PlayerMotion::refresh_dimensions (PlayerMotion.scale keeps it for Guest::dimensions) +LivingEntity.sanitizeScale inline crates/horizon-client/src/mobs/living.rs::model_scale identity for every species Horizon runs as a Mob; Shulker caps the scale at 3 and HappyGhast at 1 (the EnderDragon, always 1, has no Mob) LivingEntity.shouldDropLoot inline crates/horizon-client/src/mobs/drops.rs::die mob_drops rule and not a baby (monsters excepted) LivingEntity.decreaseAirSupply port crates/horizon-client/src/motion/environment.rs::MotionWorld::tick_environment_damage gap: minor: only the player reads oxygen_bonus (Respiration); a mob loses one air per tick in ai.rs::base_tick LivingEntity.setLastHurtByPlayer inline crates/horizon-client/src/mobs.rs::Mob::damage_with ai.last_hurt_by_player = 100 on a player's hit; Game::last_hurt_by_player records which guest @@ -103,7 +105,7 @@ LivingEntity.createEquipmentSlotAccess inline crates/horizon-client/src/game/com LivingEntity.getEquipmentSlot inline crates/horizon-client/src/game/commands/items.rs::Game::slots_named slot names instead of the numeric ids 98, 99 and 100 and up LivingEntity.getSlot port crates/horizon-client/src/game/commands/items.rs::Game::slots_named for the player and mobs through /item LivingEntity.recreateFromPacket port crates/horizon-client/src/game/remote.rs::Game::spawn_entity position, head yaw and movement of a server's mob -LivingEntity.getPassengerRidingPosition port crates/horizon-client/src/mobs/riders.rs::seat gap: minor: attachments are a table by species, not scaled by getScale and the age scale +LivingEntity.getPassengerRidingPosition port crates/horizon-client/src/mobs/riders.rs::seat gap: minor: attachments are a table by species, not scaled by the age scale LivingEntity.lerpHeadRotationStep inline crates/horizon-client/src/game/remote.rs::Game::tick_remote_entities head_lerps: 1/steps of the way to the target yaw LivingEntity.isInvulnerableTo inline crates/horizon-client/src/mobs.rs::Mob::damage_with gap: minor: the invulnerable flag only; no enchantment damage immunity Mob.getMoveControl port crates/horizon-client/src/mobs/control.rs::Mob::move_control_tick a mount is steered through riders.rs::drive diff --git a/tools/port_map/game-013.tsv b/tools/port_map/game-013.tsv index d4ce2c36d0..c0bca31267 100644 --- a/tools/port_map/game-013.tsv +++ b/tools/port_map/game-013.tsv @@ -58,6 +58,7 @@ Parrot.createAttributes skip:data attributes (health 6, flying speed 0.4) come Parrot.createNavigation port crates/horizon-client/src/mobs/parrot.rs::register flying navigation and a flying move control, no doors, floats Parrot.calculateFlapping port crates/horizon-client/src/mobs/parrot.rs::calculate_flapping Parrot.checkParrotSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules S::Parrot arm: parrots_spawnable_on below and bright enough +Parrot.isFlapping port crates/horizon-client/src/mobs/parrot.rs::process_flapping strict flyDist > nextFlap, used by air movement and live Game flap dispatch Parrot.onFlap port crates/horizon-client/src/mobs/parrot.rs::process_flapping the wing-beat sound at 0.15 and nextFlap Parrot.hurtServer port crates/horizon-client/src/mobs/tame.rs::stand_when_hurt Parrot.get skip:other reads the variant as a data component for predicates; Horizon has no entity component view diff --git a/tools/port_map/game2-002.tsv b/tools/port_map/game2-002.tsv index c199ddf57a..856280a862 100644 --- a/tools/port_map/game2-002.tsv +++ b/tools/port_map/game2-002.tsv @@ -120,7 +120,7 @@ Shulker.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::m Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb -Shulker.getDefaultDimensions inline crates/horizon-client/src/mobs/shulker.rs::progress_aabb gap: minor: the down-face height scale is not seen here +Shulker.getDefaultDimensions port crates/horizon-client/src/mobs/shulker.rs::peek_height_scale a shell on Direction.DOWN grows `(1, 1 + currentPeekAmount)` tall as its lid opens, applied last in Mob::awake_dims_in Shulker.startRiding missing no attach-face reset on boarding: `mobs/shulker.rs` sets `extra.variant3` from `Attachable` and the save only, so a shulker on a vehicle keeps the face it had Shulker.stopRiding inline crates/horizon-client/src/mobs/shulker.rs::tick the body yaw is pinned to 0 on every tick (`shulker::tick`, and `client_tick` for a remote one), so a dismounted shulker shows no leftover body yaw without a reset of its own Shulker.move missing the `SHULKER_BOX` mover type has no counterpart: `shulker::teleport` runs from the tick and the bullet hit only, so a shulker box pushed by a piston never takes its shulker along diff --git a/tools/port_map/game2-003.tsv b/tools/port_map/game2-003.tsv index 98b491aa33..4d9aab5e4f 100644 --- a/tools/port_map/game2-003.tsv +++ b/tools/port_map/game2-003.tsv @@ -74,6 +74,7 @@ Creaking.checkEyeBlink inline crates/horizon-client/src/mobs/creaking.rs::animat Creaking.knockback port crates/horizon-client/src/mobs.rs::Mob::knockback Creaking.CreakingPathNavigation.createPathFinder missing no home-distance node evaluator: `m.extra.home` is read for damage, tearing down and the save, never by the pathfinder Creaking.HomeNodeEvaluator.getPathType missing no node is marked blocked for being more than 32 blocks from the heart +Creaking.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds the sway while it is bound to a heart, `Monster.getHurtSound` (the hostile hurt) otherwise CreakingAi.initCoreActivity port crates/horizon-client/src/mobs/monster_brain.rs::creaking_packages core swim, look-at-target and move sink CreakingAi.initIdleActivity port crates/horizon-client/src/mobs/monster_brain.rs::creaking_packages idle: attack active creakings, look sometimes, stroll, look-walk or do nothing CreakingAi.initFightActivity port crates/horizon-client/src/mobs/monster_brain.rs::creaking_packages fight: walk to target, melee on a 40 tick cooldown, stop when unreachable diff --git a/tools/port_map/game2-005.tsv b/tools/port_map/game2-005.tsv index a82300cbaa..3a4bed942c 100644 --- a/tools/port_map/game2-005.tsv +++ b/tools/port_map/game2-005.tsv @@ -92,6 +92,7 @@ Player.increaseScore skip:other the score is experience.total in Horizon (death Player.getWeaponItem missing the riptide spin attack keeps only `auto_spin_attack_ticks` and `auto_spin_attack_dmg`, not the stack it started with (`autoSpinAttackItemStack`), so there is no item to answer with mid-spin Player.destroyVanishingCursedItems port crates/horizon-client/src/game.rs::Game::drop_inventory vanishing-curse stacks are removed from the death drops Player.hasCorrectToolForDrops port crates/horizon-client/src/game.rs::Game::tick_mining the correct-tool test for harvest drops +Player.canDropItems inline crates/horizon-client/src/lan/menu.rs::click THROW is not acted on while the client has dropped items too fast Player.readAdditionalSaveData port crates/horizon-client/src/save.rs::Level::read_player Player.addAdditionalSaveData port crates/horizon-client/src/save.rs::Level::player_tag Player.hurtHelmet inline crates/horizon-client/src/game.rs::Game::hurt_armor gap: minor: hurt_armor hits all four armour slots; the head-only hit from LivingEntity.hurt is not separated @@ -133,8 +134,8 @@ StackedContents.RecipePicker.getVisitedIngredientIndex skip:java bitset index a StackedContents.RecipePicker.getVisitiedItemIndex skip:java bitset index arithmetic; plain bool vectors in Horizon StackedContents.RecipePicker.clearAllVisited port crates/horizon-client/src/menu/place.rs::Picker::clear_visited StackedItemContents.getBiggestCraftableStack port crates/horizon-client/src/menu/place.rs::Stacked::try_pick_all the largest craftable count, found by a bounded search -EvokerFangs.readAdditionalSaveData missing fangs are not saved: `entity_nbt.rs` has no evoker fangs writer, so a save in mid-bite drops them -EvokerFangs.addAdditionalSaveData missing no writer for the warmup delay or the owner either +EvokerFangs.readAdditionalSaveData port crates/horizon-client/src/game/persist.rs::Game::spawn_loaded_entities Warmup defaults to 0 and enters active fangs; gap: Owner resolution only finds already-loaded mobs +EvokerFangs.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::evoker_fangs_to_nbt live Warmup, known owner UUID, unresolved Owner retained from raw NBT EvokerFangs.getAnimationProgress port crates/horizon-client/src/game/fangs.rs::Game::fangs_geometry the bite progress that positions the fang model EyeOfEnder.setItem skip:ui the item sprite a flying eye shows; Horizon draws the eye itself EyeOfEnder.shouldRenderAtSqrDistance skip:ui render distance culling only diff --git a/tools/port_map/game2-007.tsv b/tools/port_map/game2-007.tsv index 349faf45be..a4f2f29f4e 100644 --- a/tools/port_map/game2-007.tsv +++ b/tools/port_map/game2-007.tsv @@ -91,6 +91,7 @@ AbstractContainerMenu.synchronizeSlotToRemote port crates/horizon-client/src/lan AbstractContainerMenu.synchronizeDataSlotToRemote port crates/horizon-client/src/lan/containers.rs::Hub::sync_container_data sends each changed furnace, brewing or crafter value AbstractContainerMenu.synchronizeCarriedToRemote inline crates/horizon-client/src/lan/containers.rs::Hub::answer_click the cursor item is sent after a click when it differs; gap: minor: no sync on other carried changes AbstractContainerMenu.setRemoteSlotUnsafe port crates/horizon-client/src/game/remote.rs::Game::set_menu_slot the server's slot update is applied; gap: minor: no hashed copy of the remote slot is kept +AbstractContainerMenu.doClick inline crates/horizon-client/src/menu.rs::Menu::click the click dispatch for the player's own and a host's station menus; its QUICK_MOVE arm repeats quickMoveStack while the slot keeps the item, and the guest copy in lan/menu.rs::click does it in one pass whose move_to fills every empty slot AbstractContainerMenu.getClickAction inline crates/horizon-client/src/menu.rs::Menu::click button 0 is the primary action, 1 the secondary AbstractContainerMenu.tryItemClickBehaviourOverride port crates/horizon-client/src/menu/bundle.rs::Menu::bundle_click a bundle takes or puts items on a click on another stack; the NeoForge event is not modelled AbstractContainerMenu.initializeContents port crates/horizon-client/src/game/remote.rs::Game::set_menu_slot the server's full content is applied to the open menu diff --git a/tools/port_map/game2-008.tsv b/tools/port_map/game2-008.tsv index ba84e22934..8e732ad50a 100644 --- a/tools/port_map/game2-008.tsv +++ b/tools/port_map/game2-008.tsv @@ -16,6 +16,7 @@ ResultSlot.checkTakeAchievements port crates/horizon-client/src/game.rs::Game::a ResultSlot.copyAllInputItems inline crates/horizon-client/src/crafting.rs::Recipes::remainders gap: minor: vanilla copies every input when no recipe matches on the server; remainders returns none for an unmatched recipe ResultSlot.getRemainingItems port crates/horizon-client/src/crafting.rs::Recipes::remainders container remainders and the special banner and book copies Slot.safeTake port crates/horizon-client/src/menu.rs::Menu::safe_take +Slot.mayPickup inline crates/horizon-client/src/lan/menu.rs::View::may_pickup a binding curse keeps a survival player's armour on (ArmorSlot.mayPickup); the menu's own rules answer for every other slot SlotRanges.create inline crates/horizon-command/src/args.rs::slot_ranges a range is a name and size pair built in slot_ranges SlotRanges.addSlotRange inline crates/horizon-command/src/args.rs::slot_ranges the range closure adds the numbered names and the wildcard SlotRanges.tryRead inline crates/horizon-command/src/args.rs::ArgType::parse the Slots and Slot arms read a name and check the range exists diff --git a/tools/test_save_roundtrip_parity.py b/tools/test_save_roundtrip_parity.py index 02e37d0d2f..b808bbe386 100644 --- a/tools/test_save_roundtrip_parity.py +++ b/tools/test_save_roundtrip_parity.py @@ -86,7 +86,7 @@ IGNORED = { r'server_data\.state_updating_resumes_at)$': 'block entity timers advance', r'^entity\.(Passengers\[\]\.)*(Age|Motion\[\]|Rotation\[\]|Pos\[\]|OnGround|HurtTime|Health|TimeInOverworld|fall_distance|' - r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time)$': 'entity simulation advances', + r'wasOnGround|Fire|Air|DeathTime|Fuse|life|inGround|ticks_in_ground|Time|EggLayTime)$': 'entity simulation advances', r'^level\.dat\.Data\.(Time|LastPlayed)$': 'clock and wall time of the save', r'^level\.dat\.Data\.(ServerBrands.*|singleplayer_uuid)$': 'a client world names its brand and its player', r'^entity\.listener\.selector\.(event|tick)$': 'the warden listener holds the last vibration it heard; timing',