Merge upstream main into build-time native API generation

This commit is contained in:
arismoko committed 2026-11-03 04:48:17 +00:00
commit 5e6d499849
96 files changed
+5390 -207

No files matched your search

Generated
+40
View File
@@ -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",
]
+4
View File
@@ -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"] }
+23 -3
View File
@@ -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<Result<Item, &'static str>> {
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 => {}
@@ -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", &macros),
&preprocess(&pack, "gbuffers_line.fsh", &macros),
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,
+149
View File
@@ -145,6 +145,118 @@ pub const TRIM_SLOTS: usize = 32;
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
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<Self> {
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<u8>)> {
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<u8>)> {
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<u8>)> {
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<u8>)> {
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"<html>").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();
+1
View File
@@ -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;
@@ -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<String>,
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<crate::pack_screen::PackInfo>,
pub pack_selection: Vec<String>,
@@ -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);
}
@@ -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<Hit>),
/// A project's icon, as the PNG bytes Modrinth served.
Icon(String, Vec<u8>),
/// 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<usize>) -> 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<String>)> = 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<String> = 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<Vec<Hit>, 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>), 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<PathBuf> {
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<Hit>,
pub selected: Option<usize>,
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<String>,
/// The hit an install is running for.
pub installing: Option<usize>,
/// Hits already installed this visit, by index.
pub installed: std::collections::BTreeSet<usize>,
/// The sidebar filters that are on, as indices into [`FILTERS`].
pub filters: std::collections::BTreeSet<usize>,
sender: Sender<Message>,
receiver: Receiver<Message>,
}
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<Message> {
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<String, String> {
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<Vec<u8>, 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<usize> = 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<usize> = 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<usize> = 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);
}
}
+575 -5
View File
@@ -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<Arc<[u8]>> {
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<Arc<[u8]>> {
scale_icon(crate::hud::decode_image_bytes(bytes).ok()?)
}
fn scale_icon((w, h, rgba): (u32, u32, Vec<u8>)) -> Option<Arc<[u8]>> {
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<Confirm>,
/// The selection changed since the screen opened.
pub changed: bool,
/// The packs' icons by their [`icon_texture`].
icons: HashMap<String, Arc<[u8]>>,
/// The packs' icons by their [`icon_texture`], and the browser's.
pub icons: HashMap<String, Arc<[u8]>>,
/// 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<crate::pack_browser::Browser>,
/// 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<String>,
/// 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<Action> {
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<Draw>, 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 =
@@ -87,6 +87,30 @@ pub fn fill(out: &mut Vec<Draw>, 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<Draw>, sprite: &str, x: i32, y: i32, w: i32, h: i32) {
out.push(Draw::Sprite {
sprite: sprite.to_owned(),
@@ -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.",
+2 -1
View File
@@ -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,
+2 -1
View File
@@ -198,7 +198,7 @@ pub fn scaled_value(tag: &Tag, path: &NbtPath, scale: f64) -> Result<i32, Comman
}
/// The text `data get ... <scale>` 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");
}
}
+23 -5
View File
@@ -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)"
+102 -3
View File
@@ -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<ItemEntity> {
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"))
+119 -7
View File
@@ -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<GameplayTables, String> {
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<String> {
))
}
/// `Minecraft.<init>`: `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.<init>: 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.<init>`: 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
@@ -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<i32> {
| 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),
@@ -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,
}
@@ -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()
@@ -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<i32> {
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:?}");
}
}
+13 -2
View File
@@ -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");
@@ -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)
)],
);
}
}
+13 -7
View File
@@ -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<Instant>,
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<Mutex<Lists>>, minutes: i32) {
lists.lock().unwrap().player_idle_timeout = minutes;
self.store_property("player-idle-timeout", minutes.to_string());
}
@@ -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 });
+14 -1
View File
@@ -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)
}
+1
View File
@@ -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))
+21 -3
View File
@@ -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;
}
+3
View File
@@ -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 ... <angle>`).
playerdata::face_world_spawn(g, known, self.level.world_spawn_angle);
if !known || force {
g.game_mode = default_mode;
}
+3
View File
@@ -53,6 +53,7 @@ pub(super) struct Parked {
armor_stands: Vec<horizon_client::entities::ArmorStand>,
cushions: Vec<horizon_client::entities::Cushion>,
others: Vec<commands::OtherEntity>,
fangs: super::fangs::FangState,
pub(super) boats: Vec<boats::Boat>,
pub(super) carts: Vec<minecarts::Minecart>,
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);
@@ -112,6 +112,7 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id)
| Target::Fangs(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
+10
View File
@@ -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 {
+38 -1
View File
@@ -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.
+37
View File
@@ -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 {
+4 -3
View File
@@ -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| {
@@ -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.
+2 -3
View File
@@ -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),
@@ -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).
@@ -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 {
@@ -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);
}
}
+292 -3
View File
@@ -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<usize>, std::ops::Range<usize>) {
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<usize> = 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<usize> {
(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<usize> {
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
@@ -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;
+21 -8
View File
@@ -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<Polygon> = v["polygons"]
.as_array()
.into_iter()
.flatten()
@@ -430,6 +431,12 @@ pub(crate) fn exact_attributes(bytes: &[u8]) -> HashMap<String, HashMap<String,
})
.collect()
}
/// The models that draw with `RenderTypes::entityCutoutCull`.
fn culls_back_faces(key: &str) -> bool {
matches!(key, "minecraft:bat#main" | "minecraft:turtle_baby#main")
}
pub struct MobTypes {
pub types: HashMap<String, MobType>,
}
@@ -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<Box<horizon_world::attributes::entity::EntityAttributes>>,
/// `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,
+21 -2
View File
@@ -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],
@@ -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");
}
}
+27
View File
@@ -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();
+140 -12
View File
@@ -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);
}
}
+20 -5
View File
@@ -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<MobEvent>) {
/// `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<MobEvent>) -> 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
+16 -2
View File
@@ -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));
+152 -21
View File
@@ -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"
);
}
}
}
+8 -13
View File
@@ -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;
+63
View File
@@ -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<f32> {
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}");
}
}
+40 -14
View File
@@ -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<Option<([f32; 3], [f32; 3])>>,
/// 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<crate::mobs::Part>, 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 {
+6 -2
View File
@@ -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.
+1 -6
View File
@@ -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;
}
+52 -1
View File
@@ -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| {
+14 -1
View File
@@ -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: <hostname> [<port> [<players>]], the port 1 to 65535.
let transfer = d.literal(root, "transfer");
d.requires(transfer, ADMINS);
@@ -94,6 +94,7 @@ pub const ORDER: &[(&str, &[&str])] = &[
"save-all",
"save-off",
"save-on",
"setidletimeout",
"stop",
"transfer",
"whitelist",
+66
View File
@@ -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<u32>,
/// `villager_food.nutrition`: what a villager gets from eating it.
pub villager_food: Option<i32>,
/// `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<String, HashSet<String>>, spec: &str) -> HashSet<S
None => 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<Option<HashMap<Arc<str>, i32>>> =
std::sync::RwLock::new(None);
fn set_villager_food(items: &HashMap<Arc<str>, 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<i32> {
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<i32>)> = [
"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));
}
}
+1
View File
@@ -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"
+21 -1
View File
@@ -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<i32> {
let table = match (self, direction) {
(Self::Current, _) => return Some(id),
+252
View File
@@ -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<i32> {
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<i32> {
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<i32> {
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<Option<(&'static Ids, Way)>> = 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);
}
}
+27 -7
View File
@@ -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<u8>) -> Result<Option<(i32, Vec<u8>)>> {
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<Option<Vec<u8>>> {
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.
@@ -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<Option<(i32, Option<Vec<u8>>)>> {
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<Option<(i32, Vec<u8>)>> {
let renumbered = |id: i32, payload: Vec<u8>| {
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<play::Clientbound> {
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<Vec<u8>> {
let p = decode_play(ids, id, payload)?;
Ok(strip_id(p.encode()))
}
fn strip_id(body: Vec<u8>) -> Vec<u8> {
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(&registry.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<play::SpawnInfo> {
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 = <crate::buf::Opt<play::GlobalPos>>::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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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 = <crate::buf::List<crate::chunk::BlockEntityInfo>>::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<crate::chunk::LightData> {
let mut mask = || -> Result<Vec<u8>> {
let longs = r.long_array()?;
let mut bytes: Vec<u8> = 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<Vec<u8>> {
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<i32> = (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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
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<u8>)> {
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<i32> = (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
}
}
@@ -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,
};
+10 -4
View File
@@ -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<Particle> {
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<Option<Value>> {
// 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<Vec<(u8, i32, Value)>> 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 => {
+32 -21
View File
@@ -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::<Result<_>>()?;
let removed = (0..negative)
.map(|_| r.varint().map(ids::component))
.collect::<Result<_>>()?;
Ok((added, removed))
}
}
@@ -762,15 +765,15 @@ impl Codec<Option<Stack>> 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<Option<Stack>> {
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<Option<Stack>> {
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<Option<Stack>> {
patch.byte_array(value);
added.push((kind, component));
}
let removed: Vec<i32> = (0..negative).map(|_| r.varint()).collect::<Result<_>>()?;
let removed: Vec<i32> = (0..negative)
.map(|_| r.varint().map(ids::component))
.collect::<Result<_>>()?;
removed.iter().for_each(|kind| patch.varint(*kind));
Ok(Some(Stack {
item,
@@ -834,21 +839,25 @@ impl Codec<Option<Stack>> 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<Option<Stack>> for DelimitedSlot {
pub struct Template;
impl Codec<Stack> for Template {
fn read(r: &mut Reader) -> Result<Stack> {
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::<Result<_>>()?;
let removed = (0..negative)
.map(|_| r.varint().map(ids::component))
.collect::<Result<_>>()?;
Ok(Stack {
item,
count,
@@ -876,16 +887,16 @@ impl Codec<Stack> 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<Option<Hashed>> for HashedSlot {
fn write(v: &Option<Hashed>, 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));
}
}
}
+1 -1
View File
@@ -893,7 +893,7 @@ impl Codec<(i32, Option<(String, Option<f32>)>)> for SoundHolder {
fn read(r: &mut Reader) -> Result<(i32, Option<(String, Option<f32>)>)> {
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 };
@@ -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::<Result<_>>()?,
(0..n)
.map(|_| r.varint().map(crate::adapter::ids::item))
.collect::<Result<_>>()?,
))
}
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)?,
+112
View File
@@ -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<Program, Vec<Error>> {
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", &macros);
let fragment = preprocess(&pack, "gbuffers_line.fsh", &macros);
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 [
@@ -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)
@@ -75,6 +75,8 @@ pub(super) struct Stage<'p> {
pub(super) replaced: HashMap<usize, String>,
/// Globals whose initializer runs in our `main` (`g = hz_init_g();`), in order.
pub(super) inits: Vec<String>,
/// Authored global names; automatic core matrix inputs must not hide them.
pub(super) globals: BTreeSet<String>,
/// Their captured array size constants and declarator ends: `.length()` stays type-only.
pub(super) runtime_array_lengths: HashMap<String, (String, usize)>,
pub(super) errors: Vec<Error>,
@@ -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<usize> = (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..] {
+5 -1
View File
@@ -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);
+4 -1
View File
@@ -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.
+1
View File
@@ -2807,6 +2807,7 @@ dependencies = [
"horizon-core",
"horizon-world",
"ipconfig",
"log",
"serde_json",
"sha1",
"sha2",
+40 -2
View File
@@ -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.
+6
View File
@@ -273,6 +273,8 @@ struct Launcher {
wardrobe: Option<wardrobe_page::Wardrobe>,
/// 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<String>,
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) {
+6
View File
@@ -97,6 +97,12 @@ pub fn pinned_dark(choice: Appearance) -> Option<bool> {
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,
+8 -5
View File
@@ -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.
+13
View File
@@ -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) {
+22
View File
@@ -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);
}
+45 -2
View File
@@ -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<account::Account> {
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<String> = items.iter().map(|i| i.id.clone()).collect();
let mut ids: Vec<String> = 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.
+21
View File
@@ -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<fs::File, String> {
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<chrono::Local>,
) -> Result<PathBuf, String> {
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 "<name> (1)", "<name> (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<World, String> {
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))
}
+24
View File
@@ -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");
+118
View File
@@ -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()),
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env python3
"""Generate crates/horizon-protocol/src/adapter/v<OLD>_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)`: `<NAME>_UP` from the old server's ids to the pinned ones,
`<NAME>_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()
+126
View File
@@ -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.
+7 -2
View File
@@ -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.<init> 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
1 AgeableWaterCreature.handleAirSupply AbstractFish.addAdditionalSaveData inline crates/horizon-client/src/mobs/ai.rs::base_tick crates/horizon-client/src/entity_nbt.rs::mob_to_nbt the needs_water branch of base_tick; dolphins override it to nothing and refill air in aquatic.rs FromBucket always written; tadpole getter is always true
1 AbstractFish.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt FromBucket always written; tadpole getter is always true
2 AbstractFish.readAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_from_nbt FromBucket via read_variant defaults false; tadpole ignores loaded input
3 AgeableWaterCreature.handleAirSupply AgeableWaterCreature.handleAirSupply inline crates/horizon-client/src/mobs/ai.rs::base_tick 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 the needs_water branch of base_tick; dolphins override it to nothing and refill air in aquatic.rs
4 AgeableWaterCreature.checkSurfaceAgeableWaterCreatureSpawnRules AgeableWaterCreature.checkSurfaceAgeableWaterCreatureSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::surface_water crates/horizon-client/src/mobs/spawn_rules.rs::surface_water
5 Animal.readAdditionalSaveData Animal.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt crates/horizon-client/src/entity_nbt.rs::mob_from_nbt InLove is read; LoveCause is not kept InLove is read; LoveCause is not kept
41 ArmadilloAi.initScaredActivity ArmadilloAi.initScaredActivity inline crates/horizon-client/src/mobs/armadillo.rs::server_ai_step crates/horizon-client/src/mobs/armadillo.rs::server_ai_step the PANIC activity and ArmadilloBallUp the PANIC activity and ArmadilloBallUp
42 Axolotl.<init> Axolotl.<init> port crates/horizon-client/src/mobs/aquatic.rs::register crates/horizon-client/src/mobs/aquatic.rs::register the Axolotl arm: smooth swimming move and look control, amphibious navigation, water malus 0 the Axolotl arm: smooth swimming move and look control, amphibious navigation, water malus 0
43 Axolotl.defineSynchedData Axolotl.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta crates/horizon-client/src/game/lan.rs::mob_meta variant and playing_dead; from_bucket is not synced variant and playing_dead; from_bucket is not synced
44 Axolotl.addAdditionalSaveData Axolotl.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt crates/horizon-client/src/entity_nbt.rs::mob_to_nbt Variant is written; FromBucket is not Variant and FromBucket are written
45 Axolotl.readAdditionalSaveData Axolotl.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::read_variant crates/horizon-client/src/entity_nbt.rs::read_variant Variant is read; FromBucket is not Variant and FromBucket are read
46 Axolotl.finalizeSpawn Axolotl.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize group variants and baby age group variants and baby age
47 Axolotl.tickBabyAnimations Axolotl.tickBabyAnimations inline crates/horizon-client/src/mobs/axolotl.rs::Animation::tick crates/horizon-client/src/mobs/axolotl.rs::Animation::tick the baby branch the baby branch
48 Axolotl.soloAnimation Axolotl.soloAnimation inline crates/horizon-client/src/mobs/axolotl.rs::Animation::tick crates/horizon-client/src/mobs/axolotl.rs::Animation::tick one state started, the rest stopped one state started, the rest stopped
111 CamelAi.initIdleActivity CamelAi.initIdleActivity inline crates/horizon-client/src/mobs/camel.rs::register crates/horizon-client/src/mobs/camel.rs::register breed, tempt, follow parent, stroll, random sitting, look breed, tempt, follow parent, stroll, random sitting, look
112 CamelAi.CamelPanic.checkExtraStartConditions CamelAi.CamelPanic.checkExtraStartConditions port crates/horizon-client/src/mobs/camel.rs::CamelPanic::can_use crates/horizon-client/src/mobs/camel.rs::CamelPanic::can_use
113 CamelAi.RandomSitting.checkExtraStartConditions CamelAi.RandomSitting.checkExtraStartConditions port crates/horizon-client/src/mobs/camel.rs::RandomSitting::can_use crates/horizon-client/src/mobs/camel.rs::RandomSitting::can_use gap: no leash or suffocation (canCamelChangePose) check gap: no leash or suffocation (canCamelChangePose) check
114 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
115 Chicken.onFlap port crates/horizon-client/src/mobs/farm.rs::chicken_process_flapping nextFlap = flyDist + flapSpeed / 2, no sound
116 Chicken.registerGoals Chicken.registerGoals port crates/horizon-client/src/mobs/farm.rs::register crates/horizon-client/src/mobs/farm.rs::register
117 Chicken.getSoundSet Chicken.getSoundSet inline crates/horizon-client/src/mobs/ai.rs::voice_sounds crates/horizon-client/src/mobs/ai.rs::voice_sounds gap: baby sets only; the picky sound variant is stored but never used gap: baby sets only; the picky sound variant is stored but never used
118 Chicken.getBreedOffspring Chicken.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::Game::breed crates/horizon-client/src/game/creatures.rs::Game::breed gap: baby copies the first parent's variant, no random parent gap: baby copies the first parent's variant, no random parent
145 MushroomCow.applyImplicitComponent MushroomCow.applyImplicitComponent port crates/horizon-client/src/entity_nbt.rs::apply_item_components crates/horizon-client/src/entity_nbt.rs::apply_item_components an egg's mooshroom/variant sets the colour an egg's mooshroom/variant sets the colour
146 MushroomCow.getBreedOffspring MushroomCow.getBreedOffspring port crates/horizon-client/src/game/creatures.rs::Game::breed crates/horizon-client/src/game/creatures.rs::Game::breed see getOffspringVariant see getOffspringVariant
147 MushroomCow.getOffspringVariant MushroomCow.getOffspringVariant port crates/horizon-client/src/game/creatures.rs::breed 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) 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)
148 Tadpole.fromBucket inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt saved FromBucket is always true
+1 -1
View File
@@ -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
1 DolphinRenderer.extractRenderState port crates/horizon-client/src/game/mob_blocks.rs::Game::held_item_geometry gap: minor: isMoving (dolphin motion flag) is not read
9 EnderDragonRenderer.submitRays port crates/horizon-client/src/game/dragon.rs::Game::dragon_rays
10 EnderDragonRenderer.submitCrystalBeams port crates/horizon-client/src/game/dragon.rs::Game::crystal_beam
11 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
12 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's random side offset for a creepy enderman, 0.02 * minecraft:scale per axis
13 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
14 EntityRenderDispatcher.getRenderer skip:other renderer lookup; Horizon dispatches by mob kind in game.rs::Game::entity_geometry and game/creatures.rs::Game::mob_geometry
15 EntityRenderDispatcher.getAvatarRenderer port crates/horizon-client/src/mobs/render.rs::body slim or wide arm model by skin
+4 -2
View File
@@ -32,7 +32,9 @@ LinearInterpolationHandler.doInterpolate inline crates/horizon-client/src/game/r
LivingEntity.<init> 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
1 Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false
32 LivingEntity.<init> port crates/horizon-client/src/mobs.rs::Mob::new box, health, random yaw
33 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
34 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
35 LivingEntity.getScale LivingEntity.getDimensions port crates/horizon-client/src/mobs/riders.rs::scale crates/horizon-client/src/mobs/living.rs::dims 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) 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
36 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)
37 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)
38 LivingEntity.shouldDropLoot inline crates/horizon-client/src/mobs/drops.rs::die mob_drops rule and not a baby (monsters excepted)
39 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
40 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
105 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
106 LivingEntity.getSlot port crates/horizon-client/src/game/commands/items.rs::Game::slots_named for the player and mobs through /item
107 LivingEntity.recreateFromPacket port crates/horizon-client/src/game/remote.rs::Game::spawn_entity position, head yaw and movement of a server's mob
108 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 gap: minor: attachments are a table by species, not scaled by the age scale
109 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
110 LivingEntity.isInvulnerableTo inline crates/horizon-client/src/mobs.rs::Mob::damage_with gap: minor: the invulnerable flag only; no enchantment damage immunity
111 Mob.getMoveControl port crates/horizon-client/src/mobs/control.rs::Mob::move_control_tick a mount is steered through riders.rs::drive
+1
View File
@@ -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
1 AbstractNautilus.createAttributes skip:data attributes come from the entity type table (MobType in mobs.rs)
58 Parrot.createNavigation port crates/horizon-client/src/mobs/parrot.rs::register flying navigation and a flying move control, no doors, floats
59 Parrot.calculateFlapping port crates/horizon-client/src/mobs/parrot.rs::calculate_flapping
60 Parrot.checkParrotSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules S::Parrot arm: parrots_spawnable_on below and bright enough
61 Parrot.isFlapping port crates/horizon-client/src/mobs/parrot.rs::process_flapping strict flyDist > nextFlap, used by air movement and live Game flap dispatch
62 Parrot.onFlap port crates/horizon-client/src/mobs/parrot.rs::process_flapping the wing-beat sound at 0.15 and nextFlap
63 Parrot.hurtServer port crates/horizon-client/src/mobs/tame.rs::stand_when_hurt
64 Parrot.get skip:other reads the variant as a data component for predicates; Horizon has no entity component view
+1 -1
View File
@@ -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
1 ItemFrame.defineSynchedData skip:other entity data defaults; ItemFrame starts with item None and rotation 0 in place_item_frame
120 Shulker.addAdditionalSaveData inline crates/horizon-client/src/entity_nbt.rs::mob_to_nbt AttachFace, Peek and Color are written there
121 Shulker.findNewAttachment inline crates/horizon-client/src/mobs/shulker.rs::tick keeps its face or teleports in tick, through attachable and teleport
122 Shulker.makeBoundingBox port crates/horizon-client/src/mobs/shulker.rs::progress_aabb
123 Shulker.getDefaultDimensions inline port crates/horizon-client/src/mobs/shulker.rs::progress_aabb crates/horizon-client/src/mobs/shulker.rs::peek_height_scale gap: minor: the down-face height scale is not seen here a shell on Direction.DOWN grows `(1, 1 + currentPeekAmount)` tall as its lid opens, applied last in Mob::awake_dims_in
124 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
125 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
126 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
+1
View File
@@ -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
1 Strider.checkStriderSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules strider arm: climbs lava upward, then needs air above
74 Creaking.knockback port crates/horizon-client/src/mobs.rs::Mob::knockback
75 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
76 Creaking.HomeNodeEvaluator.getPathType missing no node is marked blocked for being more than 32 blocks from the heart
77 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
78 CreakingAi.initCoreActivity port crates/horizon-client/src/mobs/monster_brain.rs::creaking_packages core swim, look-at-target and move sink
79 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
80 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
+3 -2
View File
@@ -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
1 ZombieVillager.defineSynchedData skip:java SynchedEntityData definitions; Horizon keeps the fields on Mob and sets them at spawn
92 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
93 Player.destroyVanishingCursedItems port crates/horizon-client/src/game.rs::Game::drop_inventory vanishing-curse stacks are removed from the death drops
94 Player.hasCorrectToolForDrops port crates/horizon-client/src/game.rs::Game::tick_mining the correct-tool test for harvest drops
95 Player.canDropItems inline crates/horizon-client/src/lan/menu.rs::click THROW is not acted on while the client has dropped items too fast
96 Player.readAdditionalSaveData port crates/horizon-client/src/save.rs::Level::read_player
97 Player.addAdditionalSaveData port crates/horizon-client/src/save.rs::Level::player_tag
98 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
134 StackedContents.RecipePicker.getVisitiedItemIndex skip:java bitset index arithmetic; plain bool vectors in Horizon
135 StackedContents.RecipePicker.clearAllVisited port crates/horizon-client/src/menu/place.rs::Picker::clear_visited
136 StackedItemContents.getBiggestCraftableStack port crates/horizon-client/src/menu/place.rs::Stacked::try_pick_all the largest craftable count, found by a bounded search
137 EvokerFangs.readAdditionalSaveData missing port crates/horizon-client/src/game/persist.rs::Game::spawn_loaded_entities fangs are not saved: `entity_nbt.rs` has no evoker fangs writer, so a save in mid-bite drops them Warmup defaults to 0 and enters active fangs; gap: Owner resolution only finds already-loaded mobs
138 EvokerFangs.addAdditionalSaveData missing port crates/horizon-client/src/entity_nbt.rs::evoker_fangs_to_nbt no writer for the warmup delay or the owner either live Warmup, known owner UUID, unresolved Owner retained from raw NBT
139 EvokerFangs.getAnimationProgress port crates/horizon-client/src/game/fangs.rs::Game::fangs_geometry the bite progress that positions the fang model
140 EyeOfEnder.setItem skip:ui the item sprite a flying eye shows; Horizon draws the eye itself
141 EyeOfEnder.shouldRenderAtSqrDistance skip:ui render distance culling only
+1
View File
@@ -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
1 AbstractBoat.setInitialPos port crates/horizon-client/src/game/boats.rs::Boat::new spawn sets the position and its previous copy; velocity and yaw start at zero
91 AbstractContainerMenu.synchronizeDataSlotToRemote port crates/horizon-client/src/lan/containers.rs::Hub::sync_container_data sends each changed furnace, brewing or crafter value
92 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
93 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
94 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
95 AbstractContainerMenu.getClickAction inline crates/horizon-client/src/menu.rs::Menu::click button 0 is the primary action, 1 the secondary
96 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
97 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
+1
View File
@@ -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
1 LoomMenu.<init> port crates/horizon-client/src/menu.rs::Menu::new the loom's banner, dye, pattern and result slots are laid out for Kind::Loom in the constructor
16 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
17 ResultSlot.getRemainingItems port crates/horizon-client/src/crafting.rs::Recipes::remainders container remainders and the special banner and book copies
18 Slot.safeTake port crates/horizon-client/src/menu.rs::Menu::safe_take
19 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
20 SlotRanges.create inline crates/horizon-command/src/args.rs::slot_ranges a range is a name and size pair built in slot_ranges
21 SlotRanges.addSlotRange inline crates/horizon-command/src/args.rs::slot_ranges the range closure adds the numbered names and the wildcard
22 SlotRanges.tryRead inline crates/horizon-command/src/args.rs::ArgType::parse the Slots and Slot arms read a name and check the range exists
+1 -1
View File
@@ -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',