Merge remote-tracking branch 'origin/main' into fix/projectile-summon-gravity

This commit is contained in:
HrafnaValkyrja committed 2026-11-02 09:29:51 -05:00
commit bf3a0e467e
290 files changed
+5888 -3912

No files matched your search

@@ -573,6 +573,7 @@ mod tests {
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
let p = (1, 64, 2);
let mut entities = level();
+7 -1
View File
@@ -154,10 +154,16 @@ pub(crate) struct Uniform {
colors: [[f32; 4]; 4],
}
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let offset = drift(self.time, self.partial_tick, *w);
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
+10 -1
View File
@@ -5332,6 +5332,14 @@ impl Renderer {
&& self.native.is_none()
}
/// Whether the eyes render type's layers may be posed on the GPU too
/// (`mobs::skin::SkinModels::eyes`): only with improved transparency off,
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
pub fn skins_eyes(&self) -> bool {
self.skins_entities() && !self.improved_transparency
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -5351,7 +5359,8 @@ impl Renderer {
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
/// (its block's light), three values a layer.
/// Returns the programs that run and the notes of loading them.
pub fn set_shader_pack(
&mut self,
@@ -66,10 +66,9 @@ pub struct SkinJob {
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
/// 1 for an item model's quads (`item_render::WorldSink`): the colour by the
/// face's axis rides in the part's first 6 normal words ([`SkinPart::item`]),
/// shaded on the CPU, and the normal is `mesher::CULL_BACK`.
pub item: u32,
/// How the vertices are finished: [`SkinJob::MODEL`], [`SkinJob::ITEM`] or
/// [`SkinJob::FLAT`].
pub mode: u32,
/// 1 for a model whose render type culls back faces (`mobs::Polygon::cull_back`):
/// the normal's w is `mesher::SHADED_CULL_W` instead of -127.
pub cull: u32,
@@ -77,6 +76,18 @@ pub struct SkinJob {
pub spare: u32,
}
impl SkinJob {
/// A model's polygons: the face normal posed, the job's colour.
pub const MODEL: u32 = 0;
/// An item model's quads (`item_render::WorldSink`): the colour by the face's
/// axis rides in the part's first 6 normal words ([`SkinPart::item`]), shaded on
/// the CPU, and the normal is `mesher::CULL_BACK`.
pub const ITEM: u32 = 1;
/// A model's polygons with no normal at all (all four bytes 0): the eyes render
/// type's layers, which entity.wgsl leaves unshaded.
pub const FLAT: u32 = 2;
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
@@ -375,9 +386,11 @@ pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
let (color, normal) = if job.item != 0 {
let (color, normal) = if job.mode == SkinJob::ITEM {
let color = part.normal[v.axis as usize].to_bits();
(color, crate::mesher::CULL_BACK)
} else if job.mode == SkinJob::FLAT {
(job.color, [0; 4])
} else {
let w = if job.cull != 0 {
crate::mesher::SHADED_CULL_W
@@ -524,11 +537,26 @@ mod tests {
rect: [0.5, 0.25, 0.5625, 0.3125],
light: 0xa0_30,
overlay: 2 << 16,
item: 1,
mode: SkinJob::ITEM,
..Default::default()
},
// An eyes layer: no normal, even over a culled model's encoding; the
// tier and OIT tag ride in the overlay word.
SkinJob {
first: 24,
count: 24,
part: 1,
dst: 96,
rect: [0.125, 0.75, 0.25, 0.875],
color: 0x80ff_ffff,
light: 0xf0f0,
overlay: crate::gfx::OIT_ENTITY | 3 << 16 | 7,
mode: SkinJob::FLAT,
cull: 1,
..Default::default()
},
],
vertices: 96,
vertices: 120,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
@@ -15,9 +15,9 @@ const MODEL_WORDS: u32 = 7u;
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), whether
// it draws an item's quads (`SkinJob::item`), whether its quads cull back faces
// (`SkinJob::cull`), 1 spare (0: `keep`'s).
// whether the overlay is the armour glint (its model UV in `glint_uv`), the mode
// (`SkinJob::mode`: 0 a model, 1 an item's quads, 2 a model with no normal),
// whether its quads cull back faces (`SkinJob::cull`), 1 spare (0: `keep`'s).
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
@@ -88,7 +88,7 @@ fn skin(
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
let item = jobs[j + 13u] != 0u;
let mode = jobs[j + 13u];
let cull = jobs[j + 14u] != 0u;
keep = jobs[j + 15u];
for (var k = lane; k < count; k += 64u) {
@@ -115,9 +115,12 @@ fn skin(
// `mesher::SHADED_CULL_W` (-126) in the normal's w byte.
normal = (normal & 0x00ffffffu) | (u32(-126 & 0xff) << 24u);
}
if item {
if mode == 1u {
vertex_color = parts[part * PART_WORDS + 16u + axis];
normal = 1u << 24u;
} else if mode == 2u {
// The eyes render type's layers: no normal (`SkinJob::FLAT`).
normal = 0u;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
@@ -164,8 +164,9 @@ impl PackAtlas {
"layout(set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };\n",
);
// `mc_Entity` of each sprite layer: id, then 1 for fluids or -1 (the game's table),
// and the arena page's vertices (`TerrainVertex`, six words each).
// Of each sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and
// `at_midBlock.w` (its block's light), the game's table; and the arena page's
// vertices (`TerrainVertex`, six words each).
declarations.push_str(
"layout(set = 3, binding = 0) readonly buffer HzBlockIds { int hz_block_ids[]; };\n\
layout(set = 3, binding = 1) readonly buffer HzVertices { uint hz_vertices[]; };\n",
@@ -208,13 +209,15 @@ impl PackAtlas {
}
declarations.push_str("vec2 hz_cell; vec2 hz_scale; vec4 hz_tangent;\n");
let entity = if flat {
"vec4(float(hz_block_ids[2u * hz_layer]), float(hz_block_ids[2u * hz_layer + 1u]), 0.0, 0.0)"
"vec4(float(hz_block_ids[3u * hz_layer]), float(hz_block_ids[3u * hz_layer + 1u]), 0.0, 0.0)"
} else {
"vec4(-1.0, -1.0, 0.0, 0.0)"
};
// The block a face belongs to: half a block behind its centre (the quad's diagonal).
// The block a face belongs to: half a block behind its centre (the quad's diagonal);
// w, the light it gives (Iris's `BLOCK_EMISSION_ATTRIBUTE`, 0 to 15).
let mid_block = if flat {
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, 0.0)"
"vec4((floor((hz_p0 + hz_p2) * 0.5 - 0.5 * hz_Normal) + 0.5 - hz_v_pos) * 64.0, \
float(hz_block_ids[3u * hz_layer + 2u]))"
} else {
"vec4(0.0)"
};
@@ -269,6 +269,103 @@ pub(super) fn clear(
/// The most bytes of zeros kept to clear images without `CLEAR_TEXTURE`.
const ZEROS: u64 = 4 << 20;
/// What an image no `image.*` gives binds. Iris 1.11.7 runs the program, and its loads read
/// zero, also after it stored into the image (observed with a probe pack). Here it is one texel
/// of the declared format, cleared before every pass that binds it; loads and stores outside
/// that texel are out of bounds (with the same probe pack on Vulkan, the loads read zero).
pub(super) struct StandIn {
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
pub(super) view: wgpu::TextureView,
zeros: wgpu::Buffer,
}
impl StandIn {
/// Zero again, before a pass that binds it.
pub(super) fn clear(&self, encoder: &mut wgpu::CommandEncoder) {
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &self.zeros,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: None,
rows_per_image: None,
},
},
self.view.texture().as_image_copy(),
self.view.texture().size(),
);
}
}
/// One stand-in for each format and dimension of `wanted`.
pub(super) fn stand_ins(
device: &wgpu::Device,
wanted: Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>,
) -> Vec<StandIn> {
wanted
.into_iter()
.map(|(format, dimension)| {
let atomic = device.features().contains(wgpu::Features::TEXTURE_ATOMIC)
&& matches!(
format,
wgpu::TextureFormat::R32Sint | wgpu::TextureFormat::R32Uint
);
let view = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack image stand-in"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: if dimension == wgpu::TextureViewDimension::D3 {
wgpu::TextureDimension::D3
} else {
wgpu::TextureDimension::D2
},
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| if atomic {
wgpu::TextureUsages::STORAGE_ATOMIC
} else {
wgpu::TextureUsages::empty()
},
view_formats: &[],
})
.create_view(&Default::default());
// New buffers start zeroed; one texel is at most 16 bytes.
let zeros = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack image stand-in zeros"),
size: 16,
usage: wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
StandIn {
format,
dimension,
view,
zeros,
}
})
.collect()
}
/// The stand-in of this format and dimension.
pub(super) fn stand_in(
stand_ins: &[StandIn],
format: wgpu::TextureFormat,
dimension: wgpu::TextureViewDimension,
) -> Option<&StandIn> {
stand_ins
.iter()
.find(|s| s.format == format && s.dimension == dimension)
}
/// The pack's storage buffers, bound in set 4.
pub(super) struct Buffers {
pub(super) layout: wgpu::BindGroupLayout,
+159 -21
View File
@@ -207,6 +207,9 @@ enum Image {
/// The copy of a buffer or a pack image taken before a dispatch, which its loads read.
ColorBefore(usize),
CustomBefore(usize),
/// An image no `image.*` gives: its stand-in of the declared format and dimension
/// (`pack_images::StandIn`).
StandIn(wgpu::TextureFormat, wgpu::TextureViewDimension),
}
impl Image {
@@ -423,7 +426,8 @@ pub(super) struct World<'a> {
pub lightmap: wgpu::TextureView,
/// Terrain's geometry, when the pack draws it.
pub terrain: Option<Geometry<'a>>,
/// `mc_Entity` of each block sprite layer: (id, 1 for fluids or -1) pairs.
/// Of each block sprite layer, `mc_Entity` (id, then 1 for fluids or -1) and `at_midBlock.w`
/// (its block's light): three values a layer.
pub block_ids: &'a [i32],
/// The atlas of the sun and moon's sprites.
pub celestial: Option<wgpu::TextureView>,
@@ -569,6 +573,8 @@ pub struct PackPost {
gpu: Gpu,
/// Zeros to clear images by copying, where the device has no `clear_texture`.
zeros: Option<wgpu::Buffer>,
/// What the images no `image.*` gives bind.
stand_ins: Vec<pack_images::StandIn>,
dump: Option<pack_images::Dump>,
buffers: pack_images::Buffers,
textures: Vec<pack_textures::PackTexture>,
@@ -937,10 +943,11 @@ pub(super) struct ShadowView {
pub unculled: Option<f32>,
}
/// Set 3 of flat terrain, read by the vertex stage: `mc_Entity`'s table (-1 pairs when empty)
/// and, per arena page, the page's vertices (a vertex reads its quad's corners for its normal).
/// Set 3 of flat terrain, read by the vertex stage: the sprite layers' table (`mc_Entity`'s id
/// and fluid, `at_midBlock.w`; -1, -1, 0 when empty) and, per arena page, the page's vertices
/// (a vertex reads its quad's corners for its normal).
fn block_ids(device: &wgpu::Device, table: &[i32]) -> (wgpu::BindGroupLayout, wgpu::Buffer) {
let empty = [-1, -1];
let empty = [-1, -1, 0];
let table = if table.is_empty() { &empty[..] } else { table };
let buffer = wgpu::util::DeviceExt::create_buffer_init(
device,
@@ -994,6 +1001,8 @@ struct Resources<'a> {
gpu: Gpu,
/// The buffers whose format can be bound as an image.
storable: u16,
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
}
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
@@ -1189,6 +1198,27 @@ fn bindings(
.and_then(|n| n.parse::<usize>().ok())
.filter(|&n| n < BUFFERS && !group.shadow());
let own = pack.images.iter().position(|c| c.def.name == i.name);
let Some(format) = image_format(&i.format) else {
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
return None;
};
let dimension = if i.ty.ends_with("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
// An image no `image.*` gives reads zero, also after the program stored into it
// (Iris 1.11.7, observed with a probe pack).
let stand_in = |of: &str, notes: &mut Vec<String>| {
notes.push(format!(
"{name}: image `{of}` has no `image.{of}`; it reads zero"
));
let mut stand_ins = pack.stand_ins.borrow_mut();
if !stand_ins.contains(&(format, dimension)) {
stand_ins.push((format, dimension));
}
(Image::StandIn(format, dimension), format)
};
let (target, actual) = match (buffer, own) {
(_, Some(k)) => (Image::Custom(k), pack.images[k].format),
(Some(b), None) => (Image::Color(b), formats[b]),
@@ -1202,20 +1232,10 @@ fn bindings(
match (buffer, pack.images.iter().position(|c| c.def.name == of)) {
(_, Some(k)) => (Image::CustomBefore(k), pack.images[k].format),
(Some(b), None) => (Image::ColorBefore(b), formats[b]),
(None, None) => {
notes.push(format!("{name}: image `{of}` is not provided yet"));
return None;
}
(None, None) => stand_in(of, notes),
}
}
(None, None) => {
notes.push(format!("{name}: image `{}` is not provided yet", i.name));
return None;
}
};
let Some(format) = image_format(&i.format) else {
notes.push(format!("{name}: `{}` has format {}", i.name, i.format));
return None;
(None, None) => stand_in(&i.name, notes),
};
if actual != format {
notes.push(format!(
@@ -1230,11 +1250,6 @@ fn bindings(
"atomic" => wgpu::StorageTextureAccess::Atomic,
_ => wgpu::StorageTextureAccess::ReadWrite,
};
let dimension = if i.ty.ends_with("3D") {
wgpu::TextureViewDimension::D3
} else {
wgpu::TextureViewDimension::D2
};
if let Image::Color(b) | Image::ColorBefore(b) = target
&& pack.storable & 1 << b == 0
{
@@ -1790,6 +1805,7 @@ impl PackPost {
}],
})
});
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &textures,
images: &images,
@@ -1798,6 +1814,7 @@ impl PackPost {
indices: indices.as_ref(),
gpu,
storable,
stand_ins: &stand_ins,
};
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
let in_shadow = p.group.shadow();
@@ -2045,6 +2062,7 @@ impl PackPost {
.filter(|p| !skip.split(',').any(|s| s == p.name))
.filter_map(|p| build(p, None))
.collect();
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
if let Some(t) = &terrain {
notes.push(format!("terrain draws with {}", t.name));
}
@@ -2362,6 +2380,7 @@ impl PackPost {
shadow_culling: post.shadow_culling,
gpu,
zeros: None,
stand_ins,
dump: pack_images::Dump::new(),
dummy_3d: device
.create_texture(&wgpu::TextureDescriptor {
@@ -2457,6 +2476,13 @@ impl PackPost {
out: [u32; 2],
) -> wgpu::BindGroup {
pack_images::copy(encoder, &self.images, pass.image_copies);
for &(_, image) in &pass.images {
if let Image::StandIn(format, dimension) = image
&& let Some(s) = pack_images::stand_in(&self.stand_ins, format, dimension)
{
s.clear(encoder);
}
}
if let Some(targets) = &self.targets {
for i in (0..BUFFERS).filter(|i| pass.copies & 1 << i != 0) {
if let (Some(t), Some(copy)) = (&targets.color[i], &targets.scratch[i]) {
@@ -2629,6 +2655,10 @@ impl PackPost {
Image::Custom(k) => self.images[k].view.as_ref().unwrap_or(&self.dummy),
Image::ColorBefore(i) => targets.scratch[i].as_ref().unwrap_or(&self.dummy),
Image::CustomBefore(k) => self.images[k].before.as_ref().unwrap_or(&self.dummy),
Image::StandIn(format, dimension) => {
pack_images::stand_in(&self.stand_ins, format, dimension)
.map_or(&self.dummy, |s| &s.view)
}
};
entries.push(wgpu::BindGroupEntry {
binding,
@@ -4135,6 +4165,114 @@ mod tests {
assert!(buffer(wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::MAP_WRITE).is_err());
}
/// Iris 1.11.7 runs a program whose image no `image.*` gives, and its loads read zero
/// (observed with a probe pack). Its pass is kept, the image bound to a stand-in of its
/// declared format, zero again before each pass even after one stored into it.
#[test]
fn an_image_without_an_entry_reads_zero() {
use wgpu::TextureViewDimension as D;
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let image = |name: &str, ty: &str, format: &str, binding| horizon_shaders::Image {
name: name.into(),
ty: ty.into(),
format: format.into(),
access: String::new(),
binding,
before: None,
};
let interface = horizon_shaders::Interface {
uniform_size: 48,
uniforms: Vec::new(),
samplers: Vec::new(),
outputs: vec![0],
draw: false,
images: vec![
image("mask", "uimage3D", "r32ui", 1),
image("other", "uimage3D", "r32ui", 2),
image("flat", "image2D", "rgba8", 3),
],
buffers: Vec::new(),
};
let buffers = pack_images::Buffers::new(
&device,
Default::default(),
Default::default(),
wgpu::ShaderStages::FRAGMENT,
);
let stand_ins = std::cell::RefCell::new(Vec::new());
let pack = Resources {
textures: &[],
images: &[],
left_out: &[],
buffers: &buffers,
indices: None,
gpu: Gpu::of(&adapter, device.features()),
storable: 0,
stand_ins: &stand_ins,
};
let mut notes = Vec::new();
let (.., images) = bindings(
device.features(),
&interface,
"composite",
Group::Composite,
&[0],
None,
&[T::Rgba8Unorm; BUFFERS],
None,
&mut notes,
&pack,
)
.expect("the pass is kept");
assert!(
images
== [
(1, Image::StandIn(T::R32Uint, D::D3)),
(2, Image::StandIn(T::R32Uint, D::D3)),
(3, Image::StandIn(T::Rgba8Unorm, D::D2)),
]
);
assert_eq!(notes.len(), 3, "{notes:?}");
// One stand-in for each format and dimension.
let made = pack_images::stand_ins(&device, stand_ins.take());
assert_eq!(made.len(), 2);
let mask = pack_images::stand_in(&made, T::R32Uint, D::D3).expect("made");
let texture = mask.view.texture();
queue.write_texture(
texture.as_image_copy(),
&7u32.to_le_bytes(),
wgpu::TexelCopyBufferLayout::default(),
texture.size(),
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 4,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
mask.clear(&mut encoder);
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read,
layout: wgpu::TexelCopyBufferLayout::default(),
},
texture.size(),
);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
assert_eq!(&read.slice(..).get_mapped_range().unwrap()[..], &[0; 4]);
}
#[test]
fn pack_formats_map_to_renderable_ones() {
let f = |name| texture_format(Format::parse(name).unwrap(), wgpu::Features::empty());
@@ -49,6 +49,10 @@ pub struct PackInputs {
pub elytra_flying: bool,
/// `cloudHeight`, see [`cloud_height`].
pub cloud_height: f32,
/// `cloudTime`: how far the game's clouds have moved along X, in blocks (`clouds::drift`
/// times vanilla's 0.03 blocks a tick). Iris 1.11.7 gives it, though ShaderDoc doesn't list
/// it: observed with a probe pack, three game times 0.03 apart per tick.
pub cloud_time: f32,
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
/// The player's state, for Iris's status uniforms.
@@ -260,6 +264,7 @@ pub const NAMES: &[&str] = &[
"logicalHeightLimit",
"hasSkylight",
"hasCeiling",
"cloudTime",
"isElytraFlying",
"cloudHeight",
];
@@ -557,6 +562,7 @@ impl FrameUniforms {
"heldBlockLightValue2" => Value::i(&[inputs.held_light[1]]),
"isElytraFlying" => Value::i(&[inputs.elytra_flying as i32]),
"cloudHeight" => Value::f(&[inputs.cloud_height]),
"cloudTime" => Value::f(&[inputs.cloud_time]),
// renderStage is NONE outside gbuffers draws.
_ => status_value(name, inputs).unwrap_or(Value::i(&[0])),
}
@@ -900,6 +906,25 @@ mod initial_history_tests {
float(&u, "cameraPosition")
);
}
/// Iris 1.11.7 (probe pack, a world saved at game time 5073 and loaded about ten seconds
/// before): `cloudTime` read 157.98, 160.56 and 163.11, four seconds apart. That is the
/// clouds' drift at 0.03 blocks a tick, 5266 ticks in.
#[test]
fn cloud_time_is_how_far_the_clouds_drifted() {
let hud = HudBatch::new([800, 450]);
let f = frame(&hud);
let mut u = FrameUniforms::default();
let drift = crate::clouds::drift(5266, 0.0, 256);
let input = PackInputs {
cloud_time: drift as f32 * 0.030_000_001,
..Default::default()
};
u.update(&f, &input, [800, 450]);
assert!((float(&u, "cloudTime")[0] - 157.98).abs() < 1e-3);
// A trip across the 256-cell texture takes 256 * 400 ticks, then starts again.
assert_eq!(crate::clouds::drift(256 * 400 + 3, 0.25, 256), 3.25);
}
#[test]
fn environmental_smoothing_starts_at_the_first_inputs_without_resetting_each_frame() {
let hud = HudBatch::new([800, 450]);
@@ -54,6 +54,9 @@ impl DirtySections {
pub struct World {
/// Server-owned mod data attached to this dimension.
pub attachments: horizon_world::attachments::Attachments,
/// The holders lent to mods since the last flush, whose synced changes
/// `flush_attachment_sync` sends.
pub attachment_syncs: FxHashSet<horizon_world::attachments::Holder>,
/// The level's build range and skylight (its dimension type); its
/// chunks hold `shape.column()`.
pub shape: LevelShape,
@@ -203,6 +206,7 @@ impl World {
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
attachments: Default::default(),
attachment_syncs: FxHashSet::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
+54
View File
@@ -56,6 +56,60 @@ pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
}
}
/// An entity that can't breathe and has no air (`LivingDrownEvent`, posted
/// from `LivingEntity.baseTick`), with vanilla's answers as the defaults: it
/// drowns at -20 air, for 2 damage and 8 bubbles. Bubble count is read-only;
/// changes to the event field do not affect the vanilla entity event. Asked on
/// servers, for the entities `native_living_breathe` is. API 356.
#[inline(never)]
pub fn native_living_drown(
entity: u32,
kind: &str,
is_drowning: &mut bool,
damage_amount: &mut f32,
bubble_count: i32,
cancel: &mut bool,
) {
crate::native_client_hooks::living_drown(
entity,
kind,
is_drowning,
damage_amount,
bubble_count,
cancel,
);
}
/// A drowning tick as the mods left it.
pub struct Drowning {
pub damage: Option<f32>,
}
/// `breath` applied to `air` (`apply`), then `LivingEntity.baseTick`'s
/// drowning, which the mods may change when `server` (`native_living_drown`).
/// An entity that drowns is set to 0 air.
pub fn drown(
entity: u32,
kind: &str,
air: &mut i32,
breath: Breath,
server: bool,
) -> Option<Drowning> {
let (next, drowned) = apply(*air, 300, breath);
*air = if drowned { *air - breath.consume } else { next };
let (mut drowning, mut damage, mut cancel) = (drowned, 2.0, false);
if server && !breath.can_breathe && *air <= 0 {
native_living_drown(entity, kind, &mut drowning, &mut damage, 8, &mut cancel);
}
if cancel || !drowning {
return None;
}
*air = 0;
Some(Drowning {
damage: (damage > 0.0).then_some(damage),
})
}
/// The air supply after `breath`: raised up to `max` when it can breathe,
/// lowered otherwise. The second is whether the entity now drowns
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
+1 -1
View File
@@ -26,7 +26,7 @@ use std::{
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
/// API 166.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
+27
View File
@@ -109,6 +109,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
max_stack,
);
},
living_heal: |entity, amount, cancel| {
crate::mod_api::dispatch_living_heal(crate::mod_api::mods(), entity, amount, cancel);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
@@ -119,6 +122,30 @@ pub fn main_with(mods: crate::mod_api::Mods) {
refill,
);
},
living_drown: |entity, kind, is_drowning, damage_amount, bubble_count, cancel| {
crate::mod_api::dispatch_living_drown(
crate::mod_api::mods(),
entity,
kind,
is_drowning,
damage_amount,
bubble_count,
cancel,
);
},
living_fall: |entity, kind, distance, multiplier, cancel| {
crate::mod_api::dispatch_living_fall(
crate::mod_api::mods(),
entity,
kind,
distance,
multiplier,
cancel,
);
},
living_jump: |entity, kind| {
crate::mod_api::dispatch_living_jump(crate::mod_api::mods(), entity, kind);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
+1
View File
@@ -56,6 +56,7 @@ mod agent_settings;
mod allays;
mod armor_stands;
mod atlas_slots;
mod attachment_sync;
mod banners;
mod beacon;
mod beds;
@@ -0,0 +1,148 @@
//! Attachment sync (docs/native-mods.md, "Server attachments"): the server
//! sends the synced values of the holders a level pass lent to mods, and whole
//! snapshots to the players who started seeing a holder; a client keeps them.
use super::{Game, lan::LanHost};
use crate::lan::{
guest::Guest,
hub::{AttachmentSnapshot, Hub},
};
use horizon_protocol::attachment_sync::{self, Message};
use horizon_world::attachments::{Attachments, Holder};
/// The guests `holder`'s changes go to: those in the level in place who see it,
/// and a player's own wherever they are.
fn recipients(hub: &Hub, holder: Holder) -> impl Iterator<Item = &Guest> {
let owner = match holder {
Holder::Player(uuid) => hub.guests.iter().find(|g| g.uuid == uuid),
_ => None,
};
let sees = move |g: &Guest| match holder {
Holder::Level => true,
Holder::Chunk([x, z]) => g.sent_chunks.contains(&(x, z)),
Holder::BlockEntity([x, _, z]) => g.sent_chunks.contains(&(x >> 4, z >> 4)),
Holder::Entity(id) => g.tracker.is_tracking(id as i32),
Holder::Player(_) => owner.is_some_and(|o| g.tracker.is_tracking(o.entity_id)),
_ => false,
};
hub.guests.iter().filter(move |g| {
matches!(holder, Holder::Player(uuid) if g.uuid == uuid) || (hub.here(g) && sees(g))
})
}
impl Game<'_> {
/// The messages that give a player `holder`'s synced attachments whole: its
/// own, and for a chunk those of its block entities. A player holder with
/// none still gives one, to drop what the client holds.
fn attachment_snapshot(&mut self, holder: Holder) -> Vec<Message> {
let changes = match holder {
// Read in place: a chunk lent to a mod is copied and saved.
Holder::Chunk([x, z]) => self
.world
.chunks
.get(&(x, z))
.and_then(|chunk| chunk.saved.as_ref())
.map(|extras| extras.attachments.synced()),
// Read in place: lending the map would give the entity one.
Holder::Entity(id) => self
.persist
.uuids
.get(&id)
.and_then(|uuid| self.entity_attachments.get(uuid))
.map(|map| map.synced()),
_ => self
.attachment_map_quiet(holder)
.ok()
.map(|map| map.synced()),
}
.unwrap_or_default();
let mut holders = vec![(holder, changes)];
if let Holder::Chunk([x, z]) = holder {
holders.extend(
self.world
.block_entities
.chunk_attachments(x, z)
.into_iter()
.map(|((x, y, z), map)| (Holder::BlockEntity([x, y, z]), map.synced())),
);
}
holders
.into_iter()
.filter(|(holder, changes)| !changes.is_empty() || matches!(holder, Holder::Player(_)))
.map(|(holder, changes)| Message {
holder,
replace: true,
changes,
})
.collect()
}
/// The holders players started seeing are sent whole to them, in the pass
/// of the level they are in (another's stay queued), then the synced
/// changes of the holders lent to mods since the last pass to the players
/// who see them.
pub(super) fn flush_attachment_sync(&mut self, host: &mut LanHost) {
let hub = &mut host.hub;
for snapshot in std::mem::take(&mut hub.attachment_snapshots) {
let AttachmentSnapshot { player, holder } = snapshot;
let Some(guest) = hub.guests.iter().find(|g| g.uuid == player) else {
continue;
};
if !guest.reads_attachment_sync() {
continue;
}
if guest.dimension != hub.config.dimension {
hub.attachment_snapshots.push(snapshot);
continue;
}
// A guest is a player holder, whose entity another player tracks.
let holder = match holder {
Holder::Entity(id) => hub
.guests
.iter()
.find(|g| g.entity_id == id as i32)
.map_or(holder, |g| Holder::Player(g.uuid)),
_ => holder,
};
for message in self.attachment_snapshot(holder) {
guest.send_attachment_sync(&message);
}
}
for holder in std::mem::take(&mut self.world.attachment_syncs) {
let Ok(map) = self.attachment_map_quiet(holder) else {
continue;
};
let changes = map.take_sync_changes();
if changes.is_empty() {
continue;
}
let message = Message {
holder,
replace: false,
changes,
};
for guest in recipients(hub, holder) {
guest.send_attachment_sync(&message);
}
}
}
}
impl Game<'_> {
/// A `horizon:attachment_sync` payload the server sent the window.
pub(super) fn remote_attachment_sync(&mut self, data: &[u8]) {
let message = match attachment_sync::decode(data) {
Ok(message) => message,
Err(e) => return log::debug!("Horizon: a bad attachment sync: {e}"),
};
let Some(remote) = self.remote.as_mut() else {
return;
};
let map = remote.attachments.entry(message.holder).or_default();
if message.replace {
*map = Attachments::default();
}
for change in message.changes {
map.apply_sync(change);
}
}
}
+10 -13
View File
@@ -5379,7 +5379,7 @@ impl Game<'_> {
.map_or(2.0, |f| f.0 as f32 * 2.0);
// TamableAnimal.feed(.., 2.0F, 2.0F): heals twice the nutrition,
// then playEatingSound, which wolves leave empty.
m.health = (m.health + heal).min(m.attrs.max_health);
m.heal(heal);
consume = true;
}
// Wolf.mobInteract: the owner puts wolf armor on a tamed adult.
@@ -5517,7 +5517,7 @@ impl Game<'_> {
.and_then(|i| i.food.as_ref())
.map_or(1.0, |f| f.0 as f32);
// TamableAnimal.feed.
m.health = (m.health + heal).min(m.attrs.max_health);
m.heal(heal);
consume = true;
eat = eating_sound(m, &mut self.world.random);
}
@@ -5573,7 +5573,7 @@ impl Game<'_> {
Species::IronGolem
if item == "minecraft:iron_ingot" && m.health < m.attrs.max_health =>
{
m.health = (m.health + 25.0).min(m.attrs.max_health);
m.heal(25.0);
consume = true;
sound = Some("entity.iron_golem.repair");
}
@@ -6308,16 +6308,13 @@ impl Game<'_> {
/// poses entities: a new frame of draws, the store restarted when the
/// renderer's no longer matches. Once per frame, before any entity draws.
pub(super) fn begin_skinning(&self) {
let stored = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match stored {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
let renderer = self.renderer.as_ref().filter(|r| r.skins_entities());
match renderer.and_then(|r| r.skinned_models()) {
Some(stored) => {
let mut models = self.skin_models.borrow_mut();
models.begin(&mut self.skin_frame.borrow_mut(), stored);
models.eyes = renderer.is_some_and(|r| r.skins_eyes());
}
None => self.skin_frame.borrow_mut().clear_draws(),
}
}
@@ -68,37 +68,51 @@ impl Game<'_> {
.collect();
return json!({ "value": all });
}
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
// `GameRulesService.update` through `MinecraftGameRuleServiceImpl.updateGameRule`: the rule is set,
// logged and told on every update, even to the value it has (as vanilla's sends its notification
// then too, which the parity script checks). What doesn't fit answers with the texts of vanilla's
// codecs, as the parity script saw them.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
let Some(key) = update["key"].as_str() else {
return json!({ "invalid": format!("No key key in MapLike[{update}]") });
};
let rule = gamerules::rule(key).filter(|r| self.ui.rules.get(r.name).is_some());
let Some(rule) = rule else {
let key = if key.contains(':') {
key.to_owned()
} else {
format!("minecraft:{key}")
};
return json!({ "invalid": format!(
"Unknown registry key in ResourceKey[minecraft:root / minecraft:game_rule]: {key}"
) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
(GameRuleValue::Bool(_), Value::Bool(b)) => Ok(GameRuleValue::Bool(*b)),
(GameRuleValue::Bool(_), other) => Err(format!("Not a boolean: {other}")),
(GameRuleValue::Int(_), other) => match other.as_i64() {
Some(n) if (i64::from(rule.min)..=i64::from(rule.max)).contains(&n) => {
Ok(GameRuleValue::Int(n as i32))
}
Some(n) => Err(format!(
"Value {n} outside of range [{}:{}]",
rule.min, rule.max
)),
None => Err(format!("Not a number: {other}")),
},
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
let value = match value {
Ok(value) => value,
Err(invalid) => return json!({ "invalid": invalid }),
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
}
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
return json!({ "value": typed_rule(rule, value) });
}
"serversettings/autosave" => {
+2
View File
@@ -1301,6 +1301,7 @@ impl Game<'_> {
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
let lan = Lan::start_with(sync.clone(), net)?;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.sync_attachments = !crate::mod_api::mods().is_empty();
hub.flat_world = self.world_flat;
hub.debug_world = self.world_debug;
let port = hub.lan.port;
@@ -2335,6 +2336,7 @@ impl Game<'_> {
self.lan_sync_border(&mut host);
host.hub.anticheat_ping();
drop(holding);
self.flush_attachment_sync(&mut host);
self.flush_native_net(&mut host);
self.flush_far_terrain(&mut host);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
+1 -1
View File
@@ -34,7 +34,7 @@ impl Game<'_> {
loved = true;
}
if m.health < m.attrs.max_health {
m.health = (m.health + heal).min(m.attrs.max_health);
m.heal(heal);
used = true;
}
let mut happy = false;
+20 -1
View File
@@ -97,11 +97,29 @@ impl Game<'_> {
self.serving.is_some() || self.options.dedicated.is_some()
}
/// Access a server-owned holder's attachment map.
/// Access a holder's attachment map, to lend to a mod: a server's, whose
/// synced changes are sent at the pass's end, or a client's copy of what
/// its server synced.
pub(super) fn attachment_map(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
if self.native_authoritative() {
self.world.attachment_syncs.insert(holder);
return self.attachment_map_quiet(holder);
}
match self.remote.as_mut() {
Some(remote) => Ok(remote.attachments.entry(holder).or_default()),
None => Err(horizon_client::mod_api::AttachmentError::MissingHolder),
}
}
/// `attachment_map` without recording the holder for the sync.
pub(super) fn attachment_map_quiet(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
use horizon_client::mod_api::AttachmentError;
if !self.native_authoritative() {
@@ -162,6 +180,7 @@ impl horizon_client::mod_api::LevelRandomHost for super::GameLevel<'_> {
if !self.native_authoritative {
return Err(horizon_client::mod_api::AttachmentError::MissingHolder);
}
self.world.attachment_syncs.insert(holder);
world_attachments(self.world, holder)
}
fn random(&mut self) -> Option<&mut horizon_client::mod_api::LegacyRandom> {
+1 -1
View File
@@ -320,7 +320,7 @@ impl Game<'_> {
loved = true;
}
if m.health < m.attrs.max_health && heal > 0.0 {
m.health = (m.health + heal).min(m.attrs.max_health);
m.heal(heal);
used = true;
}
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
File diff suppressed because it is too large. Load diff
@@ -352,7 +352,7 @@ impl Game<'_> {
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
/// API 314.
pub(super) fn native_hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
let Some(host) = &mut self.lan else {
return false;
@@ -420,7 +420,7 @@ impl Game<'_> {
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
/// Only on a dedicated server; false elsewhere and for an empty message.
/// API @API:server_event@.
/// API 163.
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
if self.options.dedicated.is_none() || message.trim().is_empty() {
return false;
+1 -1
View File
@@ -101,7 +101,7 @@ impl Game<'_> {
// feed(player, hand, stack, 2, 1).
let sound = nautilus::eat_sound(m);
let m = &mut self.mobs[index];
m.health = (m.health + 2.0).min(m.attrs.max_health);
m.heal(2.0);
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
return true;
@@ -1786,6 +1786,7 @@ mod tests {
},
}),
copy_on_death: false,
sync: false,
};
let root_id = [1, 2, 3, 4];
let child_id = [5, 6, 7, 8];
+20 -5
View File
@@ -761,8 +761,7 @@ impl Game<'_> {
if (effect == "instant_damage") != inverted {
m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic));
} else {
m.health =
(m.health + (4 << amp.min(30)) as f32).min(m.attrs.max_health);
m.heal((4 << amp.min(30)) as f32);
}
}
_ => {
@@ -780,6 +779,23 @@ impl Game<'_> {
}
}
pub(super) fn apply_effect_action(&mut self, action: EffectAction) {
let action = match action {
EffectAction::Heal(mut amount) => {
if let Some(player) = self.player.as_ref() {
let mut cancel = false;
horizon_client::hunger::native_living_heal(
player.entity_id as u32,
&mut amount,
&mut cancel,
);
if cancel {
return;
}
}
EffectAction::Heal(amount)
}
action => action,
};
let creative = self.creative;
let rules = self.world.health_rules;
let Some(p) = self.player.as_mut() else {
@@ -1382,7 +1398,7 @@ impl Game<'_> {
let harm = m.potion_harm(amount(6));
m.hurt(harm, Some(at), &mut self.world.random);
} else {
m.health = (m.health + amount(4)).min(m.kind.max_health);
m.heal(amount(4));
}
}
_ => {
@@ -1725,8 +1741,7 @@ impl Game<'_> {
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
} else {
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
.min(m.kind.max_health);
m.heal((intensity * f64::from(4 << amp) + 0.5) as i32 as f32);
}
}
}
+42 -2
View File
@@ -42,6 +42,7 @@ use horizon_protocol::{
text::{Language, plain},
};
use horizon_world::{
attachments::{Attachments, Holder},
level::InteractionResult,
nbt::Tag,
registry::BlockStateId,
@@ -455,6 +456,8 @@ pub(super) struct Remote {
pub quiet_commands: bool,
/// The server announced it carries mod messages.
pub mod_net: bool,
/// The synced attachments the server sent, by holder.
pub attachments: HashMap<Holder, Attachments>,
/// The server samples far terrain for us, this far at most (`lod_net`).
pub far_terrain: Option<horizon_client::lod_net::Offer>,
pub(super) builtin: Arc<Builtin>,
@@ -981,6 +984,7 @@ impl Game<'_> {
features: vec!["minecraft:vanilla".into()],
quiet_commands: false,
mod_net: false,
attachments: HashMap::new(),
far_terrain: None,
builtin,
status: lang
@@ -1887,6 +1891,7 @@ impl Game<'_> {
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
@@ -2229,6 +2234,13 @@ impl Game<'_> {
self.world
.block_entities
.retain(|k, _| !gone.contains(&(k.0 >> 4, k.2 >> 4)));
if let Some(r) = self.remote.as_mut() {
r.attachments.retain(|holder, _| match *holder {
Holder::Chunk([x, z]) => !gone.contains(&(x, z)),
Holder::BlockEntity([x, _, z]) => !gone.contains(&(x >> 4, z >> 4)),
_ => true,
});
}
gone.clear();
}
/// `handleBlockEntityData`: the block entity at `pos`, when it is of the
@@ -2360,7 +2372,9 @@ impl Game<'_> {
}
}
/// Keep the renderable block entities (chests, furnaces) in step with blocks.
/// `previous`: the block's state before, when it changed.
/// `previous`: the block's state before, when it changed. A column sent again has
/// none, and keeps its block entities' synced attachments: the server does not send
/// them again (`remote_chunk` drops those of block entities the column lost).
pub(super) fn sync_block_entity(
&mut self,
pos: (i32, i32, i32),
@@ -2378,14 +2392,26 @@ impl Game<'_> {
e.id() == entity.id() && !(other_block && matches!(e, BlockEntity::Mod(_)))
});
if !same {
if previous.is_some() {
self.forget_block_entity_attachments(pos);
}
self.world.block_entities.insert(pos, entity);
}
}
None => {
if previous.is_some() {
self.forget_block_entity_attachments(pos);
}
self.world.block_entities.remove(&pos);
}
}
}
/// The server's synced attachments of a block entity that goes with it.
fn forget_block_entity_attachments(&mut self, (x, y, z): (i32, i32, i32)) {
if let Some(r) = self.remote.as_mut() {
r.attachments.remove(&Holder::BlockEntity([x, y, z]));
}
}
/// ClientLevel.setServerVerifiedBlockState: a block the client predicted keeps its
/// predicted state until the server acknowledges the action; the server's state is
/// remembered for then.
@@ -2984,6 +3010,10 @@ impl Game<'_> {
C::CustomPayload(p) if p.channel == horizon_protocol::mod_net::CHANNEL => {
self.remote_native_net(&p.data);
}
// The synced attachments of a holder the server shows the window.
C::CustomPayload(p) if p.channel == horizon_protocol::attachment_sync::CHANNEL => {
self.remote_attachment_sync(&p.data);
}
// The server's recipes its native mods asked to send.
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
self.remote_recipe_content(&p.data);
@@ -3001,6 +3031,7 @@ impl Game<'_> {
// replaces ServerReconfigScreen.
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = None;
r.attachments.clear();
}
// handleLogin makes a new MultiPlayerGameMode (carried index 0) and a
// new LocalPlayer (slot 0, no effects, riding nothing: the server
@@ -3042,6 +3073,11 @@ impl Game<'_> {
// A new player, without effects (the server sends them
// again) and with no item in use.
self.potions.effects.clear();
// The server sends the level, chunks and entities again.
if let Some(r) = self.remote.as_mut() {
r.attachments
.retain(|holder, _| matches!(holder, Holder::Player(_)));
}
self.using = None;
self.end_mod_use();
// After a death (no KEEP_ENTITY_DATA) the new LocalPlayer
@@ -3283,9 +3319,12 @@ impl Game<'_> {
C::RemoveEntities(p) => {
for id in p.ids {
if let Some(r) = self.remote.as_mut() {
r.level_players.remove(&id);
if let Some(uuid) = r.level_players.remove(&id) {
r.attachments.remove(&Holder::Player(uuid));
}
r.level_player_names.remove(&id);
r.level_player_textures.remove(&id);
r.attachments.remove(&Holder::Entity(id as u32));
}
self.remove_entity(id);
}
@@ -4635,6 +4674,7 @@ impl Game<'_> {
r.chat_validators.remove(&u);
r.skin_textures.remove(&u);
r.listed.remove(&u);
r.attachments.remove(&Holder::Player(u));
}
}
}
@@ -13,6 +13,7 @@ use horizon_protocol::{
packets::play::{self, Clientbound as C},
registry::DimensionType,
};
use horizon_world::attachments::Holder;
use std::sync::{
Arc,
mpsc::{self, TryRecvError},
@@ -307,6 +308,15 @@ impl Game<'_> {
// LevelChunk.replaceWithPacketData: the block entity loads its update tag.
self.remote_block_entity_data([pos.0, pos.1, pos.2], info.kind, &info.tag);
}
if let Some(r) = self.remote.as_mut() {
r.attachments.retain(|holder, _| match *holder {
Holder::BlockEntity([bx, by, bz]) => {
(bx >> 4, bz >> 4) != (x, z)
|| self.world.block_entities.contains_key(&(bx, by, bz))
}
_ => true,
});
}
}
/// `ClientPacketListener.handleChunksBiomes`: each loaded column takes
+139 -9
View File
@@ -200,13 +200,10 @@ impl Game<'_> {
Ok(pack) => {
let mut macros = Macros::standard();
renderer.define_pack_macros(&mut macros);
let text = pack.read_text("shaders.properties").unwrap_or_default();
// Its conditionals test the pack's options too.
let properties = Properties::parse(
&text,
&horizon_shaders::with_options(&pack, &macros),
);
for note in macros.define_features(&properties) {
let (properties, notes) =
horizon_shaders::read_properties(&pack, &mut macros);
for note in notes {
log::warn!("shader pack {name}: {note}");
}
for note in &properties.notes {
@@ -274,11 +271,14 @@ impl Game<'_> {
block_states: RefCell::default(),
});
let started_ids = std::time::Instant::now();
let table = block_id_table(&self.pack, &ids);
let table = terrain_table(
&block_id_table(&self.pack, &ids),
&block_emission_table(&self.pack),
);
log::info!(
"shader pack {name}: mc_Entity for {} of {} block sprites in {} ms",
table.chunks(2).filter(|p| p[0] != -1).count(),
table.len() / 2,
table.chunks(3).filter(|p| p[0] != -1).count(),
table.len() / 3,
started_ids.elapsed().as_millis()
);
if let Some((programs, notes)) =
@@ -354,6 +354,13 @@ impl Game<'_> {
sky.cloud_color[3],
sky.cloud_height,
),
// Vanilla's drift in float, times its 0.030000001F blocks a tick.
cloud_time: horizon_client::clouds::drift(
self.world.game_time,
f64::from(partial),
self.pack.clouds.0,
) as f32
* 0.030_000_001,
mod_params: self.native_shader_params_now(partial),
status,
end_flash,
@@ -663,6 +670,129 @@ pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec
table
}
/// `at_midBlock.w` of each block sprite layer: the light the block drawing it gives (Iris's
/// `BLOCK_EMISSION_ATTRIBUTE`; 0 to 15, as Iris 1.11.7 passes it). Terrain vertices carry their
/// sprite layer, not their block, so as for `mc_Entity` a layer takes the light of the block
/// named like its sprite, else the light most block states drawing it give (ties: the lowest).
pub(super) fn block_emission_table(pack: &super::PackResources) -> Vec<i32> {
use std::collections::BTreeMap;
let layers = pack
.render
.sprite_layers
.values()
.max()
.map_or(0, |l| l + 1) as usize;
let registry = pack.assets.registry;
let catalog = &pack.render.model_catalog;
let sprite = |name: &str| pack.render.sprite_layers.get(name).copied();
// (layer, light) -> (a block named like the sprite draws it, how many states do).
let mut votes: BTreeMap<(u32, u8), (bool, usize)> = BTreeMap::new();
for i in 0..registry.len() {
let state_id = horizon_world::registry::BlockStateId(i as u32);
let Some(state) = registry.state(state_id) else {
continue;
};
let light = pack
.context
.tables
.facts(state_id)
.map_or(0, |f| f.emission);
let block = state.name.strip_prefix("minecraft:").unwrap_or(&state.name);
let mut vote = |layer: u32| {
let named = pack
.layer_names
.get(&layer)
.and_then(|n| n.strip_prefix("block/"))
== Some(block);
let v = votes.entry((layer, light)).or_default();
v.0 |= named;
v.1 += 1;
};
let fluid = match block {
"water" => ["water_still", "water_flow"],
"lava" => ["lava_still", "lava_flow"],
_ => ["", ""],
};
for f in fluid.iter().filter(|f| !f.is_empty()) {
if let Some(l) = sprite(&format!("minecraft:block/{f}")) {
vote(l);
}
}
let origin = horizon_core::position::BlockPos::new(0, 0, 0);
for (_, model) in catalog.collect(state_id, origin).unwrap_or_default() {
for face in &model.faces {
vote(face.layer);
}
}
}
layer_lights(&votes, layers)
}
/// Each of `layers` sprite layers' light from `votes`, (layer, light) -> (a block named like the
/// sprite draws it, how many states do): the named block's, else the most states', ties the
/// lowest; 0 for a layer no block draws.
fn layer_lights(
votes: &std::collections::BTreeMap<(u32, u8), (bool, usize)>,
layers: usize,
) -> Vec<i32> {
let mut best: std::collections::BTreeMap<u32, (bool, usize, i32)> = Default::default();
for (&(layer, light), &(named, states)) in votes {
let candidate = (named, states, -i32::from(light));
let b = best.entry(layer).or_insert(candidate);
if candidate > *b {
*b = candidate;
}
}
let mut table = vec![0; layers];
for (layer, (_, _, light)) in best {
if let Some(slot) = table.get_mut(layer as usize) {
*slot = -light;
}
}
table
}
/// The table a `shaders/` pack's terrain reads per sprite layer: `mc_Entity`'s (id, fluid) pair
/// from `ids` (`block_id_table`), then `at_midBlock.w` from `emission`.
fn terrain_table(ids: &[i32], emission: &[i32]) -> Vec<i32> {
ids.chunks(2)
.enumerate()
.flat_map(|(layer, pair)| [pair[0], pair[1], emission.get(layer).copied().unwrap_or(0)])
.collect()
}
#[cfg(test)]
mod emission_tests {
use super::{layer_lights, terrain_table};
/// A sprite's light: the block named like it gives its own (a torch, 14, over a wall
/// torch's); a shared one takes what most states drawing it give (a furnace's side: as
/// many lit states, 13, as unlit, 0: the lowest), and a layer no block draws stays 0.
#[test]
fn a_sprite_takes_the_light_of_its_block() {
let votes = std::collections::BTreeMap::from([
((0, 14), (true, 1)),
((0, 0), (false, 5)),
((1, 13), (false, 4)),
((1, 0), (false, 4)),
((2, 15), (false, 4)),
((2, 0), (false, 5)),
((3, 7), (false, 2)),
]);
assert_eq!(layer_lights(&votes, 5), [14, 0, 0, 7, 0]);
}
/// The table `shaders/` packs' terrain reads: three values a layer, `mc_Entity`'s pair
/// then the light.
#[test]
fn the_terrain_table_holds_ids_then_light() {
assert_eq!(
terrain_table(&[10, -1, 20, 1], &[14, 0]),
[10, -1, 14, 20, 1, 0]
);
}
}
#[cfg(test)]
mod native_pack_tests {
use super::{active_pack, pack_draws_flat};
+1 -1
View File
@@ -249,7 +249,7 @@ impl Game<'_> {
match owner {
Some(Who::Mob(id)) => {
if let Some(w) = self.mobs.iter_mut().find(|m| m.id == id && m.alive()) {
w.health = (w.health + 5.0).min(w.attrs.max_health);
w.heal(5.0);
}
}
// The player (or the guest acting now) who owns the skull.
+28 -11
View File
@@ -13,6 +13,13 @@ use horizon_world::{
synced_floats::SyncedFloats,
};
/// A living entity is about to heal. Mods may change the amount or cancel.
/// `entity` identifies the affected entity. 358.
#[inline(never)]
pub fn native_living_heal(entity: u32, amount: &mut f32, cancel: &mut bool) {
crate::native_client_hooks::living_heal(entity, amount, cancel);
}
impl PlayerMotion {
/// Player.causeFoodExhaustion: creative and spectator abilities suppress
/// exhaustion from movement, attacks and effects.
@@ -33,16 +40,21 @@ impl PlayerMotion {
let health = self.health.health();
let maximum = self.attribute("minecraft:max_health")? as f32;
if health < maximum {
let h = &mut self.health;
let mut observer = Updates(&mut self.events);
PlayerAttributeHealth::new(
&mut self.attributes,
&mut h.data,
h.ids,
h.dead,
&mut observer,
)?
.heal(1.0)?;
let mut amount = 1.0;
let mut cancel = false;
native_living_heal(self.entity_id as u32, &mut amount, &mut cancel);
if !cancel {
let h = &mut self.health;
let mut observer = Updates(&mut self.events);
PlayerAttributeHealth::new(
&mut self.attributes,
&mut h.data,
h.ids,
h.dead,
&mut observer,
)?
.heal(amount)?;
}
}
let saturation = self.health.food.saturation;
if saturation < 20.0 {
@@ -117,7 +129,12 @@ impl FoodWorld for TickWorld<'_> {
)?
.is_hurt()?)
}
fn heal(&mut self, _: &mut FoodData, amount: f32) -> Result<()> {
fn heal(&mut self, _: &mut FoodData, mut amount: f32) -> Result<()> {
let mut cancel = false;
native_living_heal(self.entity as u32, &mut amount, &mut cancel);
if cancel {
return Ok(());
}
let mut observer = Updates(self.events);
Ok(PlayerAttributeHealth::new(
self.attributes,
+1 -1
View File
@@ -343,7 +343,7 @@ impl ItemRenderState {
rect: [r.u0, r.v0, r.u1, r.v1],
light,
overlay: u32::from(tier) << 16,
item: 1,
mode: SkinJob::ITEM,
..Default::default()
});
frame.parts.push(SkinPart::item(m, colors));
+27 -1
View File
@@ -9,8 +9,12 @@ use crate::{
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
attachment_sync,
item::Stack,
packets::{configuration::ClientInformation, play},
packets::{
configuration::{ClientInformation, CustomPayload},
play,
},
};
use std::{
collections::VecDeque,
@@ -200,6 +204,8 @@ pub struct Guest {
// Chunks.
pub center: Option<(i32, i32)>,
pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
/// Sent as far-ring columns and needing a near snapshot on promotion.
pub far_sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
pub chunk_queue: Vec<(i32, i32)>,
/// The far ring's chunks still to send, past the view distance (`lan::far`), closest first.
pub far_queue: Vec<(i32, i32)>,
@@ -416,6 +422,7 @@ impl Guest {
load_timeout: CLIENT_LOADED_TIMEOUT,
center: None,
sent_chunks: Default::default(),
far_sent_chunks: Default::default(),
chunk_queue: Vec::new(),
far_queue: Vec::new(),
unacknowledged: 0,
@@ -501,6 +508,25 @@ impl Guest {
}
self.sender.send(packet.encode());
}
/// Whether the guest is placed and on a Horizon client (the only one that
/// reads synced attachments).
pub fn reads_attachment_sync(&self) -> bool {
self.placed && self.brand == "horizon"
}
/// A holder's synced attachments, if the guest reads them; a message too big
/// for one payload is not sent.
pub fn send_attachment_sync(&self, message: &attachment_sync::Message) {
if !self.reads_attachment_sync() {
return;
}
match attachment_sync::encode(message) {
Ok(data) => self.send(&play::Clientbound::CustomPayload(CustomPayload {
channel: attachment_sync::CHANNEL.into(),
data,
})),
Err(e) => log::debug!("Horizon: attachment sync not sent: {e}"),
}
}
/// What `packet` changes about how the client moves, for the prediction's replay: kept until
/// the tick's ping places it (`Hub::anticheat_ping`).
fn note_change(&self, packet: &play::Clientbound) {
+94 -11
View File
@@ -29,6 +29,7 @@ use horizon_protocol::{
text::{literal, translatable},
};
use horizon_world::{
attachments::Holder,
block_collision::BlockCollisionRegistry,
block_collision_scan::BlockCollisionScan,
collision_context::CollisionContext,
@@ -216,10 +217,44 @@ pub struct TrackingBatch {
pub events: Vec<super::entity::TrackEvent>,
}
/// Moves a guest's tracking events into `out`, one batch.
fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
/// A holder whose synced attachments one player is sent whole, because the
/// player started seeing it.
pub struct AttachmentSnapshot {
pub player: u128,
pub holder: Holder,
}
/// Queues `holders` as snapshots for `player`.
fn queue_snapshots(
snapshots: &mut Vec<AttachmentSnapshot>,
player: u128,
holders: impl IntoIterator<Item = Holder>,
) {
snapshots.extend(
holders
.into_iter()
.map(|holder| AttachmentSnapshot { player, holder }),
);
}
/// Moves a guest's tracking events into `out`, one batch, and the entities it
/// started tracking into `snapshots`.
fn take_tracking(
g: &mut Guest,
out: &mut Vec<TrackingBatch>,
snapshots: &mut Vec<AttachmentSnapshot>,
sync_attachments: bool,
) {
let events = g.tracker.take_events();
if !events.is_empty() {
if sync_attachments {
let started = events.iter().filter(|e| e.start);
queue_snapshots(
snapshots,
g.uuid,
started.map(|e| Holder::Entity(e.entity as u32)),
);
}
out.push(TrackingBatch {
player: g.uuid,
name: g.name.clone(),
@@ -236,6 +271,11 @@ pub struct Hub {
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// The holders players started seeing, for the game to send their synced
/// attachments (`flush_attachment_sync`).
pub attachment_snapshots: Vec<AttachmentSnapshot>,
/// Whether native mods can produce synced attachments.
pub sync_attachments: bool,
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
/// tell the mods.
pub native_advancement_grants: Vec<TickGrants>,
@@ -602,6 +642,8 @@ impl Hub {
native_menu_events: Vec::new(),
pending_kicks: Vec::new(),
native_tracking: Vec::new(),
attachment_snapshots: Vec::new(),
sync_attachments: false,
native_advancement_grants: Vec::new(),
mods_loaded: false,
hidden: Default::default(),
@@ -1459,6 +1501,13 @@ impl Hub {
fn send_level_info(&mut self, i: usize, ctx: &TickCtx, load_start: bool) {
let spawn = ctx.spawn;
let g = &self.guests[i];
if self.sync_attachments {
queue_snapshots(
&mut self.attachment_snapshots,
g.uuid,
[Holder::Level, Holder::Player(g.uuid)],
);
}
g.send(&C::InitializeBorder(super::border::initialize(ctx.border)));
g.send(&self.time_packet(ctx.world, ctx.clock_rate));
g.send(&self.default_spawn(spawn));
@@ -1754,7 +1803,12 @@ impl Hub {
/// are kept in `native_tracking`.
pub fn collect_tracking(&mut self) {
for g in &mut self.guests {
take_tracking(g, &mut self.native_tracking);
take_tracking(
g,
&mut self.native_tracking,
&mut self.attachment_snapshots,
self.sync_attachments,
);
}
}
@@ -1782,7 +1836,12 @@ impl Hub {
}
// `PlayerList.remove`: the entities it tracked stop (`removePairing`).
g.tracker.clear();
take_tracking(&mut g, &mut self.native_tracking);
take_tracking(
&mut g,
&mut self.native_tracking,
&mut self.attachment_snapshots,
self.sync_attachments,
);
self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove {
uuids: vec![g.uuid],
}));
@@ -1971,17 +2030,27 @@ impl Hub {
}));
g.unacknowledged += 1;
}
for idx in taken.into_iter().rev() {
let key = g.chunk_queue.remove(idx);
g.sent_chunks.insert(key);
let mut seen: Vec<_> = taken
.into_iter()
.rev()
.map(|idx| g.chunk_queue.remove(idx))
.collect();
if self.sync_attachments {
queue_snapshots(
&mut self.attachment_snapshots,
g.uuid,
seen.iter().map(|&(x, z)| Holder::Chunk([x, z])),
);
}
// A far chunk the save doesn't have leaves the queue unsent.
for (idx, sent) in far.into_iter().rev() {
seen.extend(far.into_iter().rev().filter_map(|(idx, sent)| {
let key = g.far_queue.remove(idx);
if sent {
g.sent_chunks.insert(key);
if sent && self.sync_attachments && g.brand == "horizon" {
g.far_sent_chunks.insert(key);
}
}
sent.then_some(key)
}));
g.sent_chunks.extend(seen);
}
}
@@ -2012,8 +2081,19 @@ impl Hub {
.collect();
for (x, z) in forget {
g.sent_chunks.remove(&(x, z));
g.far_sent_chunks.remove(&(x, z));
g.send(&C::ForgetLevelChunk(play::ForgetLevelChunk { pos: [x, z] }));
}
// Far columns have no attachment snapshots. Resend them as near chunks
// after they enter the view, once the level has loaded them.
g.far_sent_chunks.retain(|&(x, z)| {
if tracked(cx, cz, r, x, z) {
g.sent_chunks.remove(&(x, z));
false
} else {
true
}
});
requeue(&mut g.chunk_queue, &g.sent_chunks, (cx, cz), r);
if reach > r {
requeue_ring(&mut g.far_queue, &g.sent_chunks, (cx, cz), r, reach);
@@ -3752,6 +3832,7 @@ impl Hub {
}
g.tracker.clear();
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
let id = g.entity_id;
@@ -3778,6 +3859,7 @@ impl Hub {
g.sent_effects.clear();
g.center = None;
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
g.tracker.clear();
@@ -3869,6 +3951,7 @@ impl Hub {
// respawn only, and covers a level change with its separate `isChangingDimension` term.
g.tracker.clear();
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
g.center = None;
@@ -429,6 +429,7 @@ mod tests {
},
}),
copy_on_death: true,
sync: false,
};
let dir = world("attachments");
let mut original = guest();
+33
View File
@@ -25,6 +25,20 @@ use horizon_world::{
};
use std::{collections::BTreeMap, fs, path::Path};
/// A living entity is about to handle a fall (`LivingFallEvent`). Mods may
/// change the distance and damage multiplier, or cancel fall handling.
/// 357.
#[inline(never)]
pub fn native_living_fall(
entity: u32,
kind: &str,
distance: &mut f64,
multiplier: &mut f32,
cancel: &mut bool,
) {
crate::native_client_hooks::living_fall(entity, kind, distance, multiplier, cancel);
}
pub fn load(root: &Path, properties: &MovementProperties<'_>) -> Result<BlockLandingRegistry> {
let raw: BTreeMap<String, serde_json::Value> =
serde_json::from_slice(&fs::read(root.join("landing-definitions.json"))?)?;
@@ -290,6 +304,25 @@ impl MotionWorld<'_, '_, '_> {
}) as i64;
self.fall_event(FallEvent::Statistic(rounded as i32));
}
let mut distance = distance;
let mut multiplier = multiplier;
let mut cancel = false;
if !self.player.replaying {
native_living_fall(
self.player.entity_id as u32,
"minecraft:player",
&mut distance,
&mut multiplier,
&mut cancel,
);
}
// A canceled fall still lands on the block (`Block.fallOn`).
if cancel {
if let Some((state, position, original_distance)) = contact {
self.mod_block_fall(state, position, original_distance, 0.0);
}
return Ok(false);
}
living_fall::cause_living_fall_damage(
&mut FallCall {
world: self,
+4
View File
@@ -3540,7 +3540,11 @@ pub(crate) mod tests {
*max_stack = 7;
}
},
living_heal: |_, _, _| {},
living_breathe: |_, _, _, _, _| {},
living_drown: |_, _, _, _, _, _| {},
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
ingredient: |_, _, _| {},
});
assert!(installed.is_ok());
+9 -1
View File
@@ -3341,6 +3341,14 @@ impl Mob {
});
true
}
/// `LivingEntity.heal`, with the native heal hook.
pub fn heal(&mut self, mut amount: f32) {
let mut cancel = false;
crate::hunger::native_living_heal(self.id, &mut amount, &mut cancel);
if !cancel && self.health > 0.0 {
self.health = (self.health + amount).clamp(0.0, self.attrs.max_health);
}
}
/// `HealOrHarmMobEffect.applyInstantenousEffect`: a harming effect hurts a
/// living mob (6 << amplifier, magic) and heals an undead one, a healing effect
/// the reverse (4 << amplifier), both scaled by `scale`.
@@ -3353,7 +3361,7 @@ impl Mob {
);
} else {
let heal = (scale * f64::from(4i32 << amp)) as i32 as f32;
self.health = (self.health + heal).min(self.attrs.max_health);
self.heal(heal);
}
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
+6 -6
View File
@@ -12,7 +12,7 @@ use super::{
undead,
};
use crate::blocks::Fluid;
use crate::breathing::{Breath, apply, breathe};
use crate::breathing::{Breath, breathe, drown};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
@@ -1040,14 +1040,14 @@ fn base_tick(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
refill: if drowns { 0 } else { 4 },
};
let breath = breathe(m.id, &m.kind.id, vanilla);
let (air, drowned) = apply(m.air, 300, breath);
m.air = air;
if drowned {
if let Some(drowning) = drown(m.id, &m.kind.id, &mut m.air, breath, true) {
events.push(MobEvent::Drown {
mob: m.id,
at: [m.position.x, m.position.y, m.position.z],
});
m.damage(2.0, Damage::of(DamageKind::Drown));
if let Some(damage) = drowning.damage {
m.damage(damage, Damage::of(DamageKind::Drown));
}
}
}
@@ -2597,7 +2597,7 @@ fn tick_effects(m: &mut Mob, mods: Option<&dyn super::ModEffectTicks>) {
"minecraft:regeneration"
if count % interval(50) == 0 && !m.kind.flags.undead && m.alive() =>
{
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
id if horizon_client_core::potions::is_mod_effect(id) => {
// tickServer: one with no ticks left goes unasked.
+1 -1
View File
@@ -365,7 +365,7 @@ pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>
/// `Allay.aiStep` / `tick`: healing, cooldowns, the hold and spin clocks.
pub fn ai_step(m: &mut Mob) {
if m.alive() && m.age.is_multiple_of(10) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
for i in [2, 3] {
if m.extra.ints[i] > 0 {
+1
View File
@@ -433,6 +433,7 @@ pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) -> bool {
m.velocity.y += 1.4285 * a * 0.42;
m.extra.anim_ticks = DASH_COOLDOWN;
m.extra.flag2 = true;
crate::motion::native_living_jump(m.id, &m.kind.id);
sound(m, voice(m, 3), events);
true
}
+2 -1
View File
@@ -718,6 +718,7 @@ pub fn ridden_step(m: &mut Mob) {
m.velocity.x += f64::from(-0.4 * s * r.jump);
m.velocity.z += f64::from(0.4 * c * r.jump);
}
crate::motion::native_living_jump(m.id, &m.kind.id);
if let Some(rider) = m.rider.as_mut() {
rider.jump = 0.0;
}
@@ -751,7 +752,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
return super::llama::ai_step(m, w);
}
if m.alive() && m.random.next_int_bound(900).unwrap_or(1) == 0 && m.death_time == 0 {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
// Grazing (canEatGrass: horses and donkeys alike).
if m.alive() && m.rider.is_none() {
@@ -574,7 +574,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
let wet = w.precipitation_at(x, y, z).is_some();
let interval = if wet { 20 } else { 600 };
if m.age.is_multiple_of(interval) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
}
still_tick(m, w);
+23 -2
View File
@@ -1029,10 +1029,28 @@ impl Mob {
// AbstractHorse.causeFallDamage hurts the horse first and passes the fall
// on to its riders only when it was hurt.
let horse = super::equine::is_equine(self.species());
// `Block.fallOn` is told the fall as it was before the mods' changes.
let landed = distance;
let mut distance = distance;
let mut modifier = modifier;
if !horse {
let mut cancel = false;
crate::landing::native_living_fall(
self.id,
&self.kind.id,
&mut distance,
&mut modifier,
&mut cancel,
);
if cancel {
self.mod_block_fall(contact, landed, 0.0);
return false;
}
}
// Entity.causeFallDamage: nothing happens to a fall immune entity, nor
// to those on it.
if self.kind.flags.fall_immune {
self.mod_block_fall(contact, distance, 0.0);
self.mod_block_fall(contact, landed, 0.0);
return false;
}
let power = distance + 1.0e-6 - self.kind.safe_fall_distance;
@@ -1044,7 +1062,7 @@ impl Mob {
super::Species::Goat => damage -= 10.0,
_ => {}
}
let damage = f64::from(self.mod_block_fall(contact, distance, damage as f32));
let damage = f64::from(self.mod_block_fall(contact, landed, damage as f32));
// LivingEntity.causeFallDamage lets the riders fall first.
if !horse {
self.fall_to_passengers(distance, modifier, events);
@@ -1425,6 +1443,7 @@ impl Mob {
self.needs_sync = true;
if self.species() == super::Species::Rabbit {
super::rabbit::jump(self, w);
crate::motion::native_living_jump(self.id, &self.kind.id);
return;
}
if matches!(
@@ -1432,6 +1451,7 @@ impl Mob {
super::Species::Slime | super::Species::MagmaCube | super::Species::SulfurCube
) {
super::cube::jump(self);
crate::motion::native_living_jump(self.id, &self.kind.id);
return;
}
let power = self.jump_power();
@@ -1444,6 +1464,7 @@ impl Mob {
self.velocity.z += f64::from(trig::cos(angle)) * 0.2;
}
}
crate::motion::native_living_jump(self.id, &self.kind.id);
}
pub fn jump_power(&self) -> f32 {
self.kind.jump_strength as f32
+1 -1
View File
@@ -245,7 +245,7 @@ fn spit(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
/// `AbstractHorse.aiStep` for llamas, which never graze: the slow healing.
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
if m.alive() && m.random.next_int_bound(900).unwrap_or(1) == 0 && m.death_time == 0 {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
if is_trader(m) {
super::caravan::trader_llama_tick(m, w);
+38 -2
View File
@@ -80,6 +80,25 @@ fn translucent_layer(m: &Mob, layer: &Layer) -> bool {
|| layer.texture.starts_with("entity/horse/horse_markings")
}
/// Whether a layer is the eyes render type's (`RenderTypes.eyes`: full bright, no
/// normal, at `EYES_TIER`) on a mob whose other draws are plain: spiders',
/// endermen's, phantoms', the creaking's and the copper golem's. The GPU path
/// poses them as [`super::skin::Skin`] `FLAT` draws when improved transparency
/// is off ([`super::skin::SkinModels::eyes`]).
fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
layer.full_bright
&& layer.scroll == 0.0
&& matches!(
m.species(),
Species::Spider
| Species::CaveSpider
| Species::Enderman
| Species::Phantom
| Species::Creaking
| Species::CopperGolem
)
}
/// One draw of a model with a texture.
pub struct Layer {
pub model: String,
@@ -1901,8 +1920,8 @@ fn render_visible(
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
(skin.models.eyes && eyes_layer(m, layer)
|| !layer.full_bright && !translucent_layer(m, layer))
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
@@ -2021,6 +2040,7 @@ fn render_visible(
None => overlay,
};
let start = out.len();
let first_job = skin.as_deref().map(|skin| skin.frame.jobs.len());
put(
&mut skin,
&part,
@@ -2050,6 +2070,22 @@ fn render_visible(
if translucent_layer(m, layer) {
mark_oit(&mut out[start..]);
}
// The same marks on what the GPU path posed of the layer (only eyes
// layers get there among the marked ones): the tags in the overlay word,
// no normal.
if let (Some(skin), Some(first)) = (skin.as_deref_mut(), first_job) {
let tagged = translucent_layer(m, layer);
let eyes = layer.full_bright && m.species() != Species::Warden;
for job in &mut skin.frame.jobs[first..] {
if tagged {
job.overlay |= crate::gfx::OIT_ENTITY;
}
if eyes {
job.overlay |= u32::from(EYES_TIER) << 16;
job.mode = crate::gfx::entity_skin::SkinJob::FLAT;
}
}
}
// The eyes render type's glowing eyes also shine in HDR output, at
// `EYES_TIER` in the overlay's high bits (entity.wgsl fs_hdr_mark).
if layer.full_bright && m.species() != Species::Warden {
+82 -56
View File
@@ -44,6 +44,10 @@ pub struct SkinModels {
/// Item models, the same way (`item_render::ItemRenderState::submit_skinned`).
items: FxHashMap<usize, (Arc<BakedItemModel>, Option<StoredItem>)>,
vertices: u32,
/// Whether the eyes render type's layers (spiders', endermen's...) are posed
/// here too, as [`SkinJob::FLAT`] draws (`gfx::Renderer::skins_eyes`); set
/// each frame.
pub eyes: bool,
}
/// A stored item model: its quads' vertices in order, by runs of quads that
@@ -422,7 +426,7 @@ impl Walk<'_> {
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
item: 0,
mode: SkinJob::MODEL,
cull: u32::from(self.stored.cull),
spare: 0,
});
@@ -440,7 +444,9 @@ mod tests {
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
/// leaves to the CPU draw exactly as before. Twice: with the eyes render
/// type's layers left to the CPU (improved transparency on), then posed
/// too (`SkinModels::eyes`). Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -481,65 +487,85 @@ mod tests {
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
// The mobs posed at least once, without and with the eyes layers.
let mut taken = [std::collections::BTreeSet::new(), Default::default()];
for eyes in [false, true] {
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
models.eyes = eyes;
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(
&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin,
);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
taken[usize::from(eyes)].insert(id.as_str());
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
assert!(posed > 2 * 3 * 40, "only {posed} draws took the GPU path");
let added: Vec<_> = taken[1].difference(&taken[0]).copied().collect();
eprintln!("posed only with the eyes layers: {added:?}");
for id in [
"minecraft:spider",
"minecraft:cave_spider",
"minecraft:enderman",
"minecraft:phantom",
"minecraft:copper_golem",
] {
assert!(added.contains(&id), "{id} not posed with its eyes");
}
assert!(taken[0].is_subset(&taken[1]));
}
/// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and
+1 -1
View File
@@ -287,7 +287,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
"minecraft:fire_resistance" => {
m.add_effect("minecraft:fire_resistance", 3600, 0)
}
"minecraft:healing" => m.health = (m.health + 4.0).min(m.attrs.max_health),
"minecraft:healing" => m.heal(4.0),
"minecraft:swiftness" => m.add_effect("minecraft:speed", 3600, 0),
_ => {}
}
+2 -2
View File
@@ -278,7 +278,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
}
m.extra.counter = left;
if m.age.is_multiple_of(10) {
m.health = (m.health + 10.0).min(m.attrs.max_health);
m.heal(10.0);
}
return;
}
@@ -404,7 +404,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
}
}
if m.age.is_multiple_of(20) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
}
/// `WitherBoss.hurtServer` filter: not while charging, no arrows once
+1 -1
View File
@@ -110,7 +110,7 @@ pub struct NativeScreenView<'a> {
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`. The title's own screens report theirs (API @API:native_instance_dir@): `title`,
/// `slots`. The title's own screens report theirs (API 326): `title`,
/// `world_select`, `world_create`, `world_create_flat`, `world_edit`,
/// `multiplayer`, `add_server`, `direct_connect`…, with no menu, no title
/// and no slots.
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -93,7 +93,8 @@ pub trait Host {
let _ = seed;
unimplemented!("Host::draw_item")
}
/// Access a server-owned holder's attachment map.
/// Access a server-owned holder's attachment map. On a client it is the
/// copy of what the server synced: `set` and `remove` change only the copy.
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> {
let _ = holder;
unimplemented!("Host::attachments")
@@ -1163,7 +1164,7 @@ pub trait Host {
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
/// API 314.
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
let _ = uuid;
let _ = level;
@@ -1172,7 +1173,7 @@ pub trait Host {
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False when nothing was drawn: an unknown item
/// or an empty stack (air, or a count of 0, as `ItemStack.isEmpty`).
/// API @API:item_stack@.
/// API 313.
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
let _ = batch;
let _ = stack;
@@ -1186,7 +1187,7 @@ pub trait Host {
/// is `width * height` pixels of four bytes, each side at most
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
/// size. The upload costs a texture patch of the image, so send one when the
/// image changed, not every frame. API @API:mod_image@.
/// image changed, not every frame. API 355.
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
let _ = width;
let _ = height;
@@ -1198,7 +1199,7 @@ pub trait Host {
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
/// and servers only, not the instance). Nothing is created. API 326.
fn instance_dir(&self) -> PathBuf {
unimplemented!("Host::instance_dir")
}
+17 -2
View File
@@ -43,6 +43,12 @@ use std::{
sync::Arc,
};
/// A living entity jumped (`LivingEvent.LivingJumpEvent`). 359.
#[inline(never)]
pub fn native_living_jump(entity: u32, kind: &str) {
crate::native_client_hooks::living_jump(entity, kind);
}
pub(crate) mod environment;
mod fluids;
pub use fluids::{
@@ -400,6 +406,8 @@ pub struct PlayerMotion {
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
/// its moves check no fall damage.
pub server_copy: bool,
/// A speculative anticheat replay, whose native callbacks must not escape.
pub(crate) replaying: bool,
/// The span of `events` a wall hit on the server's copy pushed (sound and
/// damage): `server_travel` keeps them while it drops the simulated moves.
pub wall_hit_events: Option<std::ops::Range<usize>>,
@@ -604,6 +612,7 @@ impl PlayerMotion {
burn_from_stepping_immune: false,
old_position: position,
server_copy: false,
replaying: false,
wall_hit_events: None,
client_side: false,
entity_id: 0,
@@ -1202,13 +1211,16 @@ impl MotionWorld<'_, '_, '_> {
/// client's own keys, and the sprinting it said it had (its
/// ServerboundPlayerCommandPacket comes before the tick's move).
pub fn replayed_tick(&mut self, keys: crate::sprint::Keys, sprinting: bool) -> Result<()> {
self.local_tick(keys, |player, _| {
self.player.replaying = true;
let result = self.local_tick(keys, |player, _| {
if player.sprinting == sprinting {
Ok(())
} else {
player.set_sprinting(sprinting)
}
})
});
self.player.replaying = false;
result
}
/// `LocalPlayer.aiStep` and the travel it ends with; `sprint` is the
/// sprint phase.
@@ -1953,6 +1965,9 @@ impl MotionWorld<'_, '_, '_> {
}
pub fn jump_from_ground(&mut self) -> Result<()> {
living_motion::jump_from_ground(self)?;
if !self.player.replaying {
native_living_jump(self.player.entity_id as u32, "minecraft:player");
}
self.player
.events
.push(MotionEvent::Health(crate::health::HealthEvent::Statistic {
@@ -1,6 +1,6 @@
//! Player liquid travel and environmental damage over the shared fluid state.
use super::*;
use crate::breathing::{Breath, apply, breathe};
use crate::breathing::{Breath, breathe, drown};
use horizon_core::random::RandomSource;
use horizon_world::{
fluid::FluidKind,
@@ -98,14 +98,21 @@ impl MotionWorld<'_, '_, '_> {
} else {
breathe(self.player.entity_id as u32, "minecraft:player", vanilla)
};
let (air, drowned) = apply(self.player.air, 300, breath);
self.player.air = air;
if drowned {
// LivingDrownEvent, likewise.
if let Some(drowning) = drown(
self.player.entity_id as u32,
"minecraft:player",
&mut self.player.air,
breath,
!self.player.client_side,
) {
self.player.events.push(MotionEvent::Drown);
self.player.events.push(MotionEvent::Health(
crate::health::HealthEvent::DrownDamage(2.0),
));
self.environment_hurt("minecraft:drown", 2.0)?;
if let Some(damage) = drowning.damage {
self.player.events.push(MotionEvent::Health(
crate::health::HealthEvent::DrownDamage(damage),
));
self.environment_hurt("minecraft:drown", damage)?;
}
}
if self.player.is_in_lava() {
self.player.ignite_for_ticks(300)?;
@@ -6,7 +6,11 @@ use std::sync::OnceLock;
/// Client-library hooks, installed before any menu, entity or recipe is used.
pub struct NativeClientHooks {
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
pub living_heal: fn(u32, &mut f32, &mut bool),
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
pub living_drown: fn(u32, &str, &mut bool, &mut f32, i32, &mut bool),
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
pub living_jump: fn(u32, &str),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
}
@@ -29,6 +33,12 @@ pub(crate) fn menu_slot_rules(
}
}
pub(crate) fn living_heal(entity: u32, amount: &mut f32, cancel: &mut bool) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_heal)(entity, amount, cancel);
}
}
pub(crate) fn living_breathe(
entity: u32,
kind: &str,
@@ -41,6 +51,44 @@ pub(crate) fn living_breathe(
}
}
pub(crate) fn living_drown(
entity: u32,
kind: &str,
is_drowning: &mut bool,
damage_amount: &mut f32,
bubble_count: i32,
cancel: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_drown)(
entity,
kind,
is_drowning,
damage_amount,
bubble_count,
cancel,
);
}
}
pub(crate) fn living_fall(
entity: u32,
kind: &str,
distance: &mut f64,
multiplier: &mut f32,
cancel: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_fall)(entity, kind, distance, multiplier, cancel);
}
}
pub(crate) fn living_jump(entity: u32, kind: &str) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_jump)(entity, kind);
}
}
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient)(kind, data, items);
+2
View File
@@ -5242,6 +5242,7 @@ mod tests {
},
}),
copy_on_death: false,
sync: false,
};
let mut map = Attachments::default();
map.set(&key, 7).unwrap();
@@ -5271,6 +5272,7 @@ mod tests {
},
}),
copy_on_death: false,
sync: false,
};
let mut chunk = Chunk::empty(2, 3);
let mut extras = ChunkExtras::default();
+171
View File
@@ -467,6 +467,124 @@ fn upgrade_condition(c: &mut Value) {
{
*c = json!(named);
}
match c["type"].as_str() {
Some("minecraft:entity_properties") => {
if let Some(p) = c.get_mut("predicate") {
upgrade_entity_predicate(p);
}
}
Some("minecraft:damage_source_properties") => {
for key in ["direct_entity", "source_entity"] {
if let Some(p) = c["predicate"].get_mut(key) {
upgrade_entity_predicate(p);
}
}
}
_ => {}
}
}
/// The fields of a 1.21 entity predicate that are, in 26.3, sub-predicates
/// keyed by their registry name (`minecraft:<field>`), as 26.3's own data
/// writes them (`"minecraft:flags": {"is_on_fire": true}`).
const ENTITY_FIELDS: [&str; 15] = [
"distance",
"movement",
"location",
"stepping_on",
"movement_affected_by",
"effects",
"nbt",
"flags",
"equipment",
"periodic_tick",
"team",
"slots",
"components",
"predicates",
"type_specific",
];
/// Entity predicates nested in an entity predicate (1.21 field names).
const ENTITY_NESTED: [&str; 3] = ["vehicle", "passenger", "targeted_entity"];
/// Kinds of 1.21's `type_specific` whose `variant` 26.3 matches as a component
/// (`"minecraft:components": {"minecraft:cat/variant": ...}`).
const VARIANT_KINDS: [&str; 14] = [
"axolotl",
"cat",
"chicken",
"cow",
"fox",
"frog",
"horse",
"llama",
"mooshroom",
"painting",
"parrot",
"pig",
"rabbit",
"wolf",
];
/// A 1.21 entity predicate in 26.3's shape, in place: `type` is
/// `minecraft:entity_type`, every other field `minecraft:<field>`, `vehicle`,
/// `passenger` and `targeted_entity` recursively; `type_specific {"type": k,
/// ...}` is `minecraft:type_specific/<k>` (`slime` is `cube_mob`), a variant
/// kind's `variant` and a sheep's `color` go to `minecraft:components`. The
/// game's loot code reads only these keys, so an old-shaped predicate never
/// matched. A predicate already in 26.3's shape stays as it is.
pub(crate) fn upgrade_entity_predicate(p: &mut Value) {
let Some(o) = p.as_object_mut() else {
return;
};
let key = |f: &str| format!("minecraft:{f}");
if let Some(t) = o.remove("type") {
o.insert(key("entity_type"), t);
}
for f in ENTITY_NESTED {
if let Some(mut inner) = o.remove(f) {
upgrade_entity_predicate(&mut inner);
o.insert(key(f), inner);
}
}
for f in ENTITY_FIELDS {
if let Some(v) = o.remove(f) {
o.insert(key(f), v);
}
}
let Some(Value::Object(mut ts)) = o.remove("minecraft:type_specific") else {
return;
};
let kind = ts.remove("type");
let kind = kind.as_ref().and_then(Value::as_str).unwrap_or("any");
let kind = match kind.trim_start_matches("minecraft:") {
"slime" => "cube_mob",
k => k,
};
let component = match kind {
k if VARIANT_KINDS.contains(&k) => {
ts.remove("variant").map(|v| (format!("{k}/variant"), v))
}
"sheep" => ts.remove("color").map(|v| ("sheep/color".to_owned(), v)),
_ => None,
};
if let Some((name, v)) = component {
let components = o.entry(key("components")).or_insert_with(|| json!({}));
if let Some(c) = components.as_object_mut() {
c.insert(key(&name), v);
}
}
// A player's `looking_at`, a lightning bolt's `entity_struck`.
for f in ["looking_at", "entity_struck"] {
if let Some(v) = ts.get_mut(f) {
upgrade_entity_predicate(v);
}
}
// A variant kind with nothing else to match is the component alone.
if !ts.is_empty() || !(kind == "any" || VARIANT_KINDS.contains(&kind)) {
o.insert(key(&format!("type_specific/{kind}")), Value::Object(ts));
}
}
/// 26.3's named tool conditions for the 1.21.1 predicates they replace.
@@ -1424,6 +1542,59 @@ mod tests {
assert_eq!(new, glass);
}
/// Entity predicates in 26.3's keys, as its own tables write them; the
/// 1.21 shape never matched in the game's loot code.
#[test]
fn entity_predicates_get_26_3_keys() {
let mut t = json!({"pools": [{"rolls": 1, "entries": [{"type": "minecraft:item", "name": "m:hide",
"functions": [{"function": "minecraft:furnace_smelt", "conditions": [{
"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"flags": {"is_on_fire": true}}}]}],
"conditions": [
{"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"type": "minecraft:cow", "vehicle": {"type": "#minecraft:boats"},
"type_specific": {"type": "minecraft:frog", "variant": "minecraft:warm"}}},
{"condition": "minecraft:damage_source_properties", "predicate": {
"source_entity": {"type": "minecraft:player", "equipment": {"mainhand": {"items": "minecraft:shears"}}},
"is_direct": true}},
{"condition": "minecraft:entity_properties", "entity": "attacker",
"predicate": {"type_specific": {"type": "minecraft:slime", "size": {"min": 2}}}},
{"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"type_specific": {"type": "sheep", "sheared": false, "color": "black"}}}
]}]}]});
upgrade_loot(&mut t);
let e = &t["pools"][0]["entries"][0];
assert_eq!(
e["modifier"][0]["condition"]["predicate"],
json!({"minecraft:flags": {"is_on_fire": true}})
);
let c = &e["condition"]["terms"];
assert_eq!(
c[0]["predicate"],
json!({"minecraft:entity_type": "minecraft:cow",
"minecraft:vehicle": {"minecraft:entity_type": "#minecraft:boats"},
"minecraft:components": {"minecraft:frog/variant": "minecraft:warm"}})
);
assert_eq!(
c[1]["predicate"],
json!({"source_entity": {"minecraft:entity_type": "minecraft:player",
"minecraft:equipment": {"mainhand": {"items": "minecraft:shears"}}}, "is_direct": true})
);
assert_eq!(
c[2]["predicate"],
json!({"minecraft:type_specific/cube_mob": {"size": {"min": 2}}})
);
assert_eq!(
c[3]["predicate"],
json!({"minecraft:type_specific/sheep": {"sheared": false},
"minecraft:components": {"minecraft:sheep/color": "black"}})
);
// Already 26.3's shape: unchanged.
let again = t.clone();
upgrade_loot(&mut t);
assert_eq!(t, again);
}
#[test]
fn types_bare_number_ranges() {
// When Dungeons Arise's and Structory's chests: rolls and counts as
+99 -4
View File
@@ -42,10 +42,12 @@ pub fn read(path: &Path) -> Result<JarInfo, String> {
zip.by_name(name).ok()?.read_to_string(&mut s).ok()?;
Some(s)
};
let mut info = if let Some(t) = text(&mut zip, "META-INF/neoforge.mods.toml")
.or_else(|| text(&mut zip, "META-INF/mods.toml"))
{
mods_toml(&t)?
let tomls: Vec<String> = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"]
.iter()
.filter_map(|n| text(&mut zip, n))
.collect();
let mut info = if !tomls.is_empty() {
metadata(&tomls)?
} else if let Some(t) = text(&mut zip, "fabric.mod.json") {
fabric(&t)?
} else if let Some(t) = text(&mut zip, "mcmod.info") {
@@ -84,6 +86,71 @@ fn manifest_value(manifest: &str, key: &str) -> Option<String> {
.filter(|v| !v.is_empty())
}
/// The jar's mod from its metadata files (`neoforge.mods.toml` first, then
/// `mods.toml`). A jar built for one loader may carry the other loader's file
/// as an unfilled template (`modId = "${id}"`, `[[dependencies.${id}]]`), which
/// its own loader never reads: the first file that parses and names a real mod
/// id is the one. When none parses, the first file is read line by line
/// ([`mods_toml_lines`]), as a loader that reads past what it does not need.
pub fn metadata(tomls: &[String]) -> Result<JarInfo, String> {
let real = |i: &JarInfo| !i.mod_id.is_empty() && !i.mod_id.contains("${");
let mut first_error = None;
for t in tomls {
match mods_toml(t) {
Ok(i) if real(&i) => return Ok(i),
Ok(_) => {}
Err(e) => {
first_error.get_or_insert(e);
}
}
}
tomls
.iter()
.map(|t| mods_toml_lines(t))
.find(real)
.ok_or_else(|| first_error.unwrap_or_else(|| "mods.toml: no [[mods]] modId".into()))
}
/// The first `[[mods]]` table's `modId`, `displayName` and `version` and the
/// top-level `license`, read line by line (`key = "value"`, `#` comments), for
/// a metadata file a TOML parser refuses. Dependencies are not read.
pub fn mods_toml_lines(text: &str) -> JarInfo {
let mut info = JarInfo::default();
// 0: top level, 1: the first [[mods]], 2: past it.
let mut section = 0;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
let name = line.trim_matches(['[', ']']).trim();
section = match (section, name) {
(0, "mods") => 1,
_ => 2,
};
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
let Some(value) = value
.strip_prefix('"')
.and_then(|v| v.split_once('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.split_once('\'')))
.map(|(v, _)| v.to_owned())
else {
continue;
};
match (section, key.trim()) {
(0, "license") => info.license = value,
(1, "modId") if info.mod_id.is_empty() => info.mod_id = value,
(1, "displayName") => info.name = value,
(1, "version") => info.version = value,
_ => {}
}
}
info
}
/// `[[mods]]` (the first one is the jar's mod), the top-level `license`, and
/// `[[dependencies.<id>]]` with `type = "required"` (or the older
/// `mandatory = true`).
@@ -295,6 +362,34 @@ mandatory = true
assert_eq!(info.depends, ["geckolib", "curios"]);
}
/// Drip Sounds' Forge jar carries an unfilled NeoForge template beside its
/// `mods.toml`; a file no parser reads still gives its id line by line.
#[test]
fn templates_and_broken_files_are_read_tolerantly() {
let template = "license = \"LGPL\"\n[[mods]]\nmodId = \"${id}\"\nversion = \"${version}\"\n\n[[dependencies.${id}]]\nmodId = \"minecraft\"\n";
let forge = "license = \"LGPL-3.0-only\"\n[[mods]]\nmodId = \"waterdripsound\"\ndisplayName = \"Drip Sounds\"\nversion = \"0.6.3\"\n[[dependencies.waterdripsound]]\nmodId = \"cloth_config\"\nmandatory = true\n";
assert!(mods_toml(template).is_err());
let info = metadata(&[template.to_owned(), forge.to_owned()]).unwrap();
assert_eq!(
(info.mod_id.as_str(), info.version.as_str()),
("waterdripsound", "0.6.3")
);
assert_eq!(info.depends, ["cloth_config"]);
// Nothing parses: the lines still name the mod (a duplicate key is a TOML error).
let broken = "license = 'MIT' # x\n[[mods]]\nmodId = \"ruby\"\nmodId = \"ruby\"\nversion = \"2\"\ndisplayName = \"Ruby\"\n[[mods]]\nmodId = \"other\"\n";
assert!(mods_toml(broken).is_err());
let info = metadata(&[template.to_owned(), broken.to_owned()]).unwrap();
assert_eq!(
(
info.mod_id.as_str(),
info.name.as_str(),
info.license.as_str()
),
("ruby", "Ruby", "MIT")
);
assert!(metadata(&[template.to_owned()]).is_err());
}
#[test]
fn reads_fabric_metadata() {
let info = fabric(
@@ -0,0 +1,212 @@
//! Attachment sync: the values of a native mod's synced attachments, sent by a
//! Horizon server to the Horizon clients that see their holder
//! (docs/native-mods.md, "Server attachments").
//!
//! A payload is the holder (a varint kind and its fields), whether the
//! client's copy of the holder is replaced, then each change: the attachment's
//! namespace and name and its value as an NBT compound, or no tag for a
//! removal.
use horizon_world::{
attachments::{AttachmentSync, Holder},
nbt::{self, Tag},
};
use crate::{
Error, Result,
buf::{Reader, Writer},
};
/// The custom payload channel, clientbound only: a client sends nothing back.
pub const CHANNEL: &str = "horizon:attachment_sync";
/// Characters in a namespace or a name at most.
pub const MAX_NAME: usize = 256;
/// Bytes of a payload at most
/// (ClientboundCustomPayloadPacket.MAX_PAYLOAD_SIZE).
pub const MAX_PAYLOAD: usize = 1_048_576;
/// A holder's changes as they travel.
#[derive(Clone, Debug, PartialEq)]
pub struct Message {
pub holder: Holder,
/// The changes are the holder's whole synced state: drop what the client
/// holds first.
pub replace: bool,
pub changes: Vec<AttachmentSync>,
}
/// A message's payload; an error for a holder it cannot write, a name past
/// [`MAX_NAME`], a value that is not an encodable compound, or a payload past
/// [`MAX_PAYLOAD`].
pub fn encode(message: &Message) -> Result<Vec<u8>> {
let mut w = Writer::new();
match message.holder {
Holder::Entity(id) => {
w.varint(0);
w.varint(id as i32);
}
Holder::Player(uuid) => {
w.varint(1);
w.uuid(uuid);
}
Holder::BlockEntity(pos) => {
w.varint(2);
pos.iter().for_each(|&axis| w.i32(axis));
}
Holder::Chunk(pos) => {
w.varint(3);
pos.iter().for_each(|&axis| w.i32(axis));
}
Holder::Level => w.varint(4),
_ => return Err(Error::new("attachment sync: unknown holder")),
}
w.bool(message.replace);
w.varint(message.changes.len() as i32);
for change in &message.changes {
for name in [&change.namespace, &change.name] {
let name = name
.to_string()
.map_err(|_| Error::new("attachment sync: name is not UTF-16"))?;
if name.encode_utf16().count() > MAX_NAME {
return Err(Error::new(format!(
"attachment sync: name {name} is too long"
)));
}
w.string(&name);
}
match &change.value {
Some(value) => {
let tag = Tag::Compound(value.clone());
let bytes = nbt::write_network(&tag, nbt::Limits::default())
.map_err(|e| Error::new(format!("attachment sync: value: {e}")))?;
w.bytes(&bytes);
}
None => w.bytes(&[0]),
}
}
if w.data.len() > MAX_PAYLOAD {
return Err(Error::new("attachment sync: payload too big"));
}
Ok(w.data)
}
/// A payload's message; an error for one that breaks a cap.
pub fn decode(data: &[u8]) -> Result<Message> {
if data.len() > MAX_PAYLOAD {
return Err(Error::new("attachment sync: payload too big"));
}
let mut r = Reader::new(data);
let holder = match r.varint()? {
0 => Holder::Entity(r.varint()? as u32),
1 => Holder::Player(r.uuid()?),
2 => Holder::BlockEntity([r.i32()?, r.i32()?, r.i32()?]),
3 => Holder::Chunk([r.i32()?, r.i32()?]),
4 => Holder::Level,
kind => return Err(Error::new(format!("attachment sync: holder kind {kind}"))),
};
let replace = r.bool()?;
let max = r.remaining();
let changes = (0..r.count(max)?)
.map(|_| {
let namespace = r.string(MAX_NAME)?;
let name = r.string(MAX_NAME)?;
let value = match r.nbt()? {
Tag::Compound(value) => Some(value),
Tag::End => None,
_ => return Err(Error::new("attachment sync: value is not a compound")),
};
Ok(AttachmentSync {
namespace: namespace.as_str().into(),
name: name.as_str().into(),
value,
})
})
.collect::<Result<Vec<_>>>()?;
if r.remaining() > 0 {
return Err(Error::new("attachment sync: trailing bytes"));
}
Ok(Message {
holder,
replace,
changes,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn changes() -> Vec<AttachmentSync> {
vec![
AttachmentSync {
namespace: "mana".into(),
name: "pool".into(),
value: Some([("value".into(), Tag::Int(12))].into()),
},
AttachmentSync {
namespace: "mana".into(),
name: "gone".into(),
value: None,
},
]
}
#[test]
fn every_holder_round_trips_with_either_replace_and_a_removal() {
let holders = [
Holder::Entity(4_000_000_000),
Holder::Player(0x0123_4567_89ab_cdef_0011_2233_4455_6677),
Holder::BlockEntity([-30_000_000, -64, 29_999_999]),
Holder::Chunk([-5, 7]),
Holder::Level,
];
for holder in holders {
for replace in [true, false] {
let message = Message {
holder,
replace,
changes: changes(),
};
assert_eq!(decode(&encode(&message).unwrap()).unwrap(), message);
}
}
}
#[test]
fn decode_refuses_trailing_bytes_and_an_unknown_holder_kind() {
let message = Message {
holder: Holder::Level,
replace: false,
changes: changes(),
};
let mut payload = encode(&message).unwrap();
payload.push(0);
assert!(decode(&payload).is_err(), "trailing bytes");
assert!(decode(&[5, 0, 0]).is_err(), "unknown holder kind");
}
#[test]
fn encode_refuses_a_change_the_client_would_not_read_as_sent() {
let mut message = Message {
holder: Holder::Level,
replace: false,
changes: changes(),
};
message.changes[0].name = "n".repeat(MAX_NAME).as_str().into();
assert!(decode(&encode(&message).unwrap()).is_ok());
message.changes[0].name = "n".repeat(MAX_NAME + 1).as_str().into();
assert!(encode(&message).is_err(), "name");
message.changes[0].name = "pool".into();
let mut deep = Tag::Int(0);
for _ in 0..600 {
deep = Tag::Compound([("v".into(), deep)].into());
}
let Tag::Compound(deep) = deep else {
unreachable!()
};
message.changes[0].value = Some(deep);
assert!(encode(&message).is_err(), "unwritable value");
let big = Tag::ByteArray(vec![0; MAX_PAYLOAD]);
message.changes[0].value = Some([("big".into(), big)].into());
assert!(encode(&message).is_err(), "payload");
}
}
+1
View File
@@ -4,6 +4,7 @@
// `deny`, not `forbid`: crypto/blocks.rs allows the ARMv8 AES intrinsics, nothing else.
#![deny(unsafe_code)]
pub mod adapter;
pub mod attachment_sync;
pub mod buf;
pub mod chunk;
pub mod connection;
+1 -1
View File
@@ -45,7 +45,7 @@ pub use lang::Lang;
pub use macros::{MC_VERSION, Macros, RENDER_STAGES, render_stage};
pub use options::{
PackOption, apply as apply_options, chosen_file, chosen_text, options, parse_chosen,
switches_on, value_options, with_options,
read_properties, switches_on, value_options, with_options,
};
pub use pack::{Pack, PackError};
pub use post::{
+21 -5
View File
@@ -3,11 +3,13 @@
use std::collections::BTreeMap;
/// The Iris feature flags (`iris.features.*`) Horizon has. `SEPARATE_HARDWARE_SAMPLERS`
/// (ShaderDoc, "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth
/// samplers when hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks
/// a comparison sampler by the declared type (`sampler2DShadow`), so they always are.
const FEATURES: [&str; 5] = [
/// The Iris feature flags (`iris.features.*`) Horizon has. `BLOCK_EMISSION_ATTRIBUTE`: terrain's
/// `at_midBlock.w` is the light its block gives. `SEPARATE_HARDWARE_SAMPLERS` (ShaderDoc,
/// "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth samplers when
/// hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks a comparison
/// sampler by the declared type (`sampler2DShadow`), so they always are.
const FEATURES: [&str; 6] = [
"BLOCK_EMISSION_ATTRIBUTE",
"COMPUTE_SHADERS",
"CUSTOM_IMAGES",
"ENTITY_TRANSLUCENT",
@@ -414,6 +416,20 @@ mod tests {
translates(fsh, &Macros::standard()).unwrap();
}
/// `BLOCK_EMISSION_ATTRIBUTE` (terrain's `at_midBlock.w` is its block's light) is one
/// Horizon has: defined when optional, no note when required.
#[test]
fn block_emission_attribute_is_a_feature() {
let properties = crate::Properties::parse(
"iris.features.optional = BLOCK_EMISSION_ATTRIBUTE\n\
iris.features.required = BLOCK_EMISSION_ATTRIBUTE\n",
&Macros::standard(),
);
let mut m = Macros::standard();
assert!(m.define_features(&properties).is_empty());
assert!(m.is_defined("IRIS_FEATURE_BLOCK_EMISSION_ATTRIBUTE"));
}
#[test]
fn iris_features_are_defined_or_noted() {
let properties = crate::Properties::parse(
+52
View File
@@ -86,6 +86,21 @@ pub fn with_options(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
out
}
/// A pack's `shaders.properties` as the game reads it before the programs: parsed with the
/// pack's options over `macros` (the standard set and the renderer's), after which `macros` gets
/// the features the pack turns on (`iris.features.*`, `Macros::define_features`). Returns the
/// properties and a note for each required feature Horizon lacks. Whatever reads a pack's
/// programs (the game, the `real_packs` test) reads them with these macros.
pub fn read_properties(
pack: &Pack,
macros: &mut crate::Macros,
) -> (crate::Properties, Vec<String>) {
let text = pack.read_text("shaders.properties").unwrap_or_default();
let properties = crate::Properties::parse(&text, &with_options(pack, macros));
let notes = macros.define_features(&properties);
(properties, notes)
}
/// An option the player can change (8.1): a switch (`values` are `false` and `true`), a value
/// option or a `const` with the list of values it may take.
#[derive(Clone, Debug, PartialEq)]
@@ -372,6 +387,43 @@ fn option_texts(pack: &Pack) -> Vec<String> {
mod tests {
use super::*;
/// `shaders.properties` read as the game reads it: its conditionals see the pack's options
/// and the macros given (the renderer's vendor here), and the features it turns on are then
/// defined for the programs, with a note for a required one Horizon lacks.
#[test]
fn properties_are_read_with_the_options_then_define_the_features() {
let bytes = crate::pack::tests::zip(&[
(
"shaders/options.glsl",
"#define LEVEL 2 // [1 2]
",
),
(
"shaders/shaders.properties",
"#if LEVEL == 2
screen = x
#endif
#ifdef MC_GL_VENDOR_OTHER
vendor = other
#endif
iris.features.optional = SSBO
iris.features.required = NOT_A_FEATURE
",
),
]);
let pack = Pack::from_zip_bytes("p.zip", bytes).unwrap();
let mut macros = crate::Macros::standard();
macros.define_renderer(4);
let (properties, notes) = read_properties(&pack, &mut macros);
assert_eq!(properties.get("screen"), Some("x"));
assert_eq!(properties.get("vendor"), Some("other"));
assert!(macros.is_defined("IRIS_FEATURE_SSBO"));
assert_eq!(
notes,
["the pack requires `NOT_A_FEATURE`, which is not supported yet"]
);
}
#[test]
fn declared_defaults_remain_selectable_and_apply() {
for (source, selected) in [
+7 -7
View File
@@ -27,11 +27,11 @@ fn real_packs_read() {
for f in found.iter().filter(|f| f.problem.is_none()) {
let start = Instant::now();
let pack = Pack::open(&f.path).unwrap();
let macros = Macros::standard();
let shaders = Properties::parse(
&pack.read_text("shaders.properties").unwrap_or_default(),
&horizon_shaders::with_options(&pack, &macros),
);
// The macros the game reads the pack's programs with: the standard set, the renderer's
// (at Mipmap Levels 4), then the features the pack turns on.
let mut macros = Macros::standard();
macros.define_renderer(4);
let (shaders, feature_notes) = horizon_shaders::read_properties(&pack, &mut macros);
let ids: Vec<Properties> = ["block", "item", "entity", "dimension"]
.iter()
.filter_map(|n| pack.read_text(&format!("{n}.properties")))
@@ -191,11 +191,11 @@ fn real_packs_read() {
index: "uint(@)".into(),
}),
};
let mut macros = Macros::standard();
let mut macros = macros.clone();
if std::env::var_os("SUBGROUPS").is_some() {
macros.define("MC_GL_KHR_shader_subgroup_arithmetic", "");
}
for note in macros.define_features(&shaders) {
for note in &feature_notes {
println!(" {note}");
}
let post = load_post(&pack, &folder, &shaders, &macros, &inputs);
+255 -32
View File
@@ -1,5 +1,9 @@
//! Typed native-mod data attached to server-owned holders.
use std::{any::Any, collections::BTreeMap, fmt};
use std::{
any::Any,
collections::{BTreeMap, BTreeSet},
fmt,
};
use crate::nbt::{Compound, JavaString, Tag};
@@ -19,6 +23,8 @@ pub enum AttachmentError {
InvalidNamespace,
/// Copy-on-death requires a serializer.
InvalidCopyPolicy,
/// Syncing to clients requires a serializer.
InvalidSyncPolicy,
}
/// NBT key reserved for server-owned attachment maps.
@@ -34,7 +40,7 @@ pub fn attachment_compound(tag: Option<&Tag>) -> Result<Option<&Compound>, Attac
}
/// A server holder in the host's current dimension.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Holder {
Entity(u32),
@@ -62,6 +68,28 @@ pub struct AttachmentType<T: 'static> {
pub default: fn() -> T,
pub codec: Option<AttachmentCodec<T>>,
pub copy_on_death: bool,
/// Send the value to the clients that see the holder; needs a codec.
pub sync: bool,
}
impl<T> AttachmentType<T> {
fn validate_policy(&self) -> Result<(), AttachmentError> {
if self.copy_on_death && self.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
if self.sync && self.codec.is_none() {
return Err(AttachmentError::InvalidSyncPolicy);
}
Ok(())
}
}
/// One attachment's new state for a client; `None` means removed.
#[derive(Clone, Debug, PartialEq)]
pub struct AttachmentSync {
pub namespace: JavaString,
pub name: JavaString,
pub value: Option<Compound>,
}
impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
@@ -74,7 +102,7 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
host.attachments(holder)?.get(self)
}
/// Whether a holder carries a value, like NeoForge's `hasData`; never inserts the default.
/// API @API:attachment_existing@.
/// API 317.
pub fn has<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
@@ -82,7 +110,7 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
) -> Result<bool, AttachmentError> {
Ok(host.attachments(holder)?.has(self))
}
/// Read a value only if present, like `getExistingData`. API @API:attachment_existing@.
/// Read a value only if present, like `getExistingData`. API 317.
pub fn get_existing<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
@@ -107,6 +135,14 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
) -> Result<Option<T>, AttachmentError> {
host.attachments(holder)?.remove(self)
}
/// Send the current value to the clients that see the holder.
pub fn sync_data<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
holder: Holder,
) -> Result<(), AttachmentError> {
host.attachments(holder)?.mark_sync(self)
}
}
trait Value: Send + Sync {
@@ -114,11 +150,13 @@ trait Value: Send + Sync {
fn clone_box(&self) -> Box<dyn Value>;
fn encoded(&self) -> Option<&Compound>;
fn copy_on_death(&self) -> bool;
fn sync(&self) -> bool;
}
struct TypedValue<T> {
value: T,
encoded: Option<Compound>,
copy_on_death: bool,
sync: bool,
}
impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
fn as_any(&self) -> &dyn Any {
@@ -129,6 +167,7 @@ impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
value: self.value.clone(),
encoded: self.encoded.clone(),
copy_on_death: self.copy_on_death,
sync: self.sync,
})
}
fn encoded(&self) -> Option<&Compound> {
@@ -137,11 +176,51 @@ impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
fn copy_on_death(&self) -> bool {
self.copy_on_death
}
fn sync(&self) -> bool {
self.sync
}
}
/// The saved form of a decoded value: its encoding and the policies it was
/// declared with. `None` when the type has no codec.
fn envelope(value: &dyn Value) -> Option<Compound> {
value.encoded().map(|encoded| {
let mut envelope = Compound::new();
envelope.insert("value".into(), Tag::Compound(encoded.clone()));
if value.copy_on_death() {
envelope.insert("copy_on_death".into(), Tag::Byte(1));
}
if value.sync() {
envelope.insert("sync".into(), Tag::Byte(1));
}
envelope
})
}
fn raw_value(raw: &Compound) -> Option<&Compound> {
match raw.get(&"value".into()) {
Some(Tag::Compound(value)) => Some(value),
_ => None,
}
}
enum Entry {
Raw(Compound),
Decoded(Box<dyn Value>),
}
impl Entry {
/// The encoded value, as clients receive it.
fn synced_value(&self) -> Option<&Compound> {
match self {
Self::Raw(raw) => raw_value(raw),
Self::Decoded(value) => value.encoded(),
}
}
fn is_synced(&self) -> bool {
match self {
Self::Raw(raw) => matches!(raw.get(&"sync".into()), Some(Tag::Byte(1))),
Self::Decoded(value) => value.sync(),
}
}
}
impl Clone for Entry {
fn clone(&self) -> Self {
match self {
@@ -157,6 +236,8 @@ pub struct Attachments {
entries: BTreeMap<(JavaString, JavaString), Entry>,
// Keep malformed or otherwise unrecognized NBT exactly as it was read.
raw: Compound,
// Keys to send to clients at the next flush; never saved.
pending: BTreeSet<(JavaString, JavaString)>,
}
impl fmt::Debug for Attachments {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -172,6 +253,7 @@ impl Attachments {
let mut result = Self {
entries: BTreeMap::new(),
raw,
pending: BTreeSet::new(),
};
for (namespace, tag) in &result.raw {
if let Tag::Compound(names) = tag {
@@ -192,14 +274,7 @@ impl Attachments {
for ((namespace, name), entry) in &self.entries {
let value = match entry {
Entry::Raw(value) => Some(value.clone()),
Entry::Decoded(value) => value.encoded().map(|encoded| {
let mut envelope = Compound::new();
envelope.insert("value".into(), Tag::Compound(encoded.clone()));
if value.copy_on_death() {
envelope.insert("copy_on_death".into(), Tag::Byte(1));
}
envelope
}),
Entry::Decoded(value) => envelope(value.as_ref()),
};
if let Some(value) = value {
let tag = raw
@@ -224,17 +299,7 @@ impl Attachments {
Some(raw.clone())
}
Entry::Decoded(value) if value.copy_on_death() => {
let value = value
.encoded()
.ok_or(AttachmentError::InvalidCopyPolicy)?
.clone();
Some(
[
("value".into(), Tag::Compound(value)),
("copy_on_death".into(), Tag::Byte(1)),
]
.into(),
)
Some(envelope(value.as_ref()).ok_or(AttachmentError::InvalidCopyPolicy)?)
}
_ => None,
};
@@ -254,9 +319,7 @@ impl Attachments {
&mut self,
ty: &AttachmentType<T>,
) -> Result<Option<T>, AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
ty.validate_policy()?;
if self
.raw
.get(&ty.namespace.into())
@@ -266,9 +329,7 @@ impl Attachments {
}
let value = match self.entries.get(&(ty.namespace.into(), ty.name.into())) {
Some(Entry::Raw(raw)) => {
let Some(Tag::Compound(value)) = raw.get(&"value".into()) else {
return Err(AttachmentError::Decode);
};
let value = raw_value(raw).ok_or(AttachmentError::Decode)?;
(ty.codec.as_ref().ok_or(AttachmentError::Decode)?.decode)(value)?
}
Some(Entry::Decoded(value)) => {
@@ -294,6 +355,9 @@ impl Attachments {
}
let value = (ty.default)();
self.insert_decoded(ty, value.clone())?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
Ok(value)
}
/// Replace a value. A key already decoded as a different type is rejected.
@@ -302,9 +366,7 @@ impl Attachments {
ty: &AttachmentType<T>,
value: T,
) -> Result<(), AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
ty.validate_policy()?;
if self
.raw
.get(&ty.namespace.into())
@@ -318,6 +380,9 @@ impl Attachments {
return Err(AttachmentError::TypeMismatch);
}
self.insert_decoded(ty, value)?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
names.remove(&ty.name.into());
}
@@ -332,6 +397,9 @@ impl Attachments {
if !self.has(ty) {
return Ok(None);
}
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
let value = self.get_existing(ty);
self.entries.remove(&(ty.namespace.into(), ty.name.into()));
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
@@ -355,10 +423,65 @@ impl Attachments {
value,
encoded,
copy_on_death: ty.copy_on_death,
sync: ty.sync,
})),
);
Ok(())
}
/// Queue a value to be sent to clients again; nothing for an unsynced type.
pub fn mark_sync<T: 'static>(&mut self, ty: &AttachmentType<T>) -> Result<(), AttachmentError> {
ty.validate_policy()?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
Ok(())
}
/// The changes to send to clients since the last call: the current value
/// of each queued key, or `None` for one that is gone.
pub fn take_sync_changes(&mut self) -> Vec<AttachmentSync> {
std::mem::take(&mut self.pending)
.into_iter()
.map(|(namespace, name)| {
let value = self
.entries
.get(&(namespace.clone(), name.clone()))
.and_then(Entry::synced_value)
.cloned();
AttachmentSync {
namespace,
name,
value,
}
})
.collect()
}
/// Every synced value, for a client that starts seeing the holder.
pub fn synced(&self) -> Vec<AttachmentSync> {
self.entries
.iter()
.filter(|(_, entry)| entry.is_synced())
.filter_map(|((namespace, name), entry)| {
Some(AttachmentSync {
namespace: namespace.clone(),
name: name.clone(),
value: Some(entry.synced_value()?.clone()),
})
})
.collect()
}
/// Client side: keep what the server sent, decoded by the next `get`.
pub fn apply_sync(&mut self, change: AttachmentSync) {
let key = (change.namespace, change.name);
match change.value {
Some(value) => {
let raw = [("value".into(), Tag::Compound(value))].into();
self.entries.insert(key, Entry::Raw(raw));
}
None => {
self.entries.remove(&key);
}
}
}
}
#[cfg(test)]
@@ -380,13 +503,30 @@ mod tests {
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: false,
sync: false,
};
static SYNCED: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "synced",
default: || 7,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: false,
sync: true,
};
fn change(name: &str, value: Option<i32>) -> AttachmentSync {
AttachmentSync {
namespace: "test".into(),
name: name.into(),
value: value.map(|n| encode(&n).unwrap()),
}
}
static MEMORY: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "memory",
default: || 3,
codec: None,
copy_on_death: false,
sync: false,
};
#[test]
fn saved_values_round_trip_and_memory_values_do_not() {
@@ -408,6 +548,7 @@ mod tests {
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: true,
sync: false,
};
let mut original = Attachments::default();
original.set(&keep, 42).unwrap();
@@ -469,4 +610,86 @@ mod tests {
assert_eq!(map.remove(&COUNTER), Err(AttachmentError::Decode));
assert_eq!(map.remove(&COUNTER), Ok(None), "removed all the same");
}
#[test]
fn synced_set_and_remove_report_the_last_state_once() {
let mut map = Attachments::default();
map.set(&SYNCED, 1).unwrap();
map.set(&SYNCED, 2).unwrap();
map.remove(&SYNCED).unwrap();
assert_eq!(map.take_sync_changes(), [change("synced", None)]);
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn default_creation_is_synced_and_a_later_read_is_not() {
let mut map = Attachments::default();
assert_eq!(map.get(&SYNCED), Ok(7));
assert_eq!(map.take_sync_changes(), [change("synced", Some(7))]);
assert_eq!(map.get(&SYNCED), Ok(7));
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn unsynced_types_report_no_changes() {
let mut map = Attachments::default();
map.set(&COUNTER, 1).unwrap();
map.remove(&COUNTER).unwrap();
map.mark_sync(&COUNTER).unwrap();
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn sync_without_a_codec_is_refused() {
let broken = AttachmentType {
namespace: "test",
name: "broken",
default: || 0,
codec: None,
copy_on_death: false,
sync: true,
};
let mut map = Attachments::default();
assert_eq!(map.get(&broken), Err(AttachmentError::InvalidSyncPolicy));
assert_eq!(map.set(&broken, 1), Err(AttachmentError::InvalidSyncPolicy));
assert_eq!(
map.mark_sync(&broken),
Err(AttachmentError::InvalidSyncPolicy)
);
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn a_saved_sync_flag_puts_undecoded_values_in_the_snapshot() {
let mut map = Attachments::default();
map.set(&SYNCED, 5).unwrap();
map.set(&COUNTER, 6).unwrap();
let saved = map.to_nbt();
let Some(Tag::Compound(names)) = saved.get(&"test".into()) else {
panic!("namespace saved");
};
let Some(Tag::Compound(envelope)) = names.get(&"synced".into()) else {
panic!("entry saved");
};
assert_eq!(envelope.get(&"sync".into()), Some(&Tag::Byte(1)));
let loaded = Attachments::from_nbt(saved);
assert_eq!(loaded.synced(), [change("synced", Some(5))]);
}
#[test]
fn applied_changes_decode_through_the_codec_and_removals_clear() {
let mut map = Attachments::default();
map.apply_sync(change("synced", Some(9)));
assert_eq!(map.get(&SYNCED), Ok(9));
map.apply_sync(change("synced", None));
assert_eq!(map.get(&SYNCED), Ok(7));
}
#[test]
fn sync_data_reports_the_current_value() {
struct One(Attachments);
impl AttachmentHost for One {
fn attachments(&mut self, _: Holder) -> Result<&mut Attachments, AttachmentError> {
Ok(&mut self.0)
}
}
let mut host = One(Attachments::default());
SYNCED.set(&mut host, Holder::Level, 4).unwrap();
host.0.take_sync_changes();
SYNCED.sync_data(&mut host, Holder::Level).unwrap();
assert_eq!(host.0.take_sync_changes(), [change("synced", Some(4))]);
}
}
+3 -3
View File
@@ -127,7 +127,7 @@ pub struct NativeLootContext<'a> {
/// Where the roll happens: the origin, the block entity and the level,
/// when the game rolled the table at a place (a broken block's drops,
/// a mob's death loot); None for the other rolls. Either engine fills it.
/// API @API:loot_context_place@.
/// API 156.
pub place: Option<Rc<NativeLootPlace>>,
}
@@ -135,7 +135,7 @@ pub struct NativeLootContext<'a> {
/// `LootContextParams.ORIGIN`, `LootContextParams.BLOCK_ENTITY`, and what
/// vanilla's own conditions read of `LootContext.getLevel` (`TimeCheck`:
/// the time; `WeatherCheck`: the rain and the thunder). The game sets it
/// around a roll with [`with_native_loot_place`]. API @API:loot_context_place@.
/// around a roll with [`with_native_loot_place`]. API 156.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeLootPlace {
/// `ORIGIN`: a broken block's centre, a dead mob's position.
@@ -171,7 +171,7 @@ thread_local! {
/// Runs `roll` with `place` as where its loot rolls happen: every
/// [`NativeLootContext`] made inside it, by either engine, carries it. The
/// place before is back when `roll` returns, so these nest.
/// API @API:loot_context_place@.
/// API 156.
pub fn with_native_loot_place<T>(place: NativeLootPlace, roll: impl FnOnce() -> T) -> T {
struct Restore(Option<Rc<NativeLootPlace>>);
impl Drop for Restore {
+474 -20
View File
@@ -934,6 +934,14 @@ API 142 adds an energy buffer on a mod block's block entity (`Capabilities.Energ
API 143 adds data maps (`RegisterDataMapTypesEvent`). `native_data_map_types(types)` pushes the types a mod registers: an `id` (`mod_id:name`, not in `minecraft`) for a `registry`, `minecraft:item` or `minecraft:block` so far; asked once per load of the server's data (a world's first open and every `/reload`). The values come from `data/<mod_id>/data_maps/<registry path>/<name>.json` in the world's data packs and from files a mod pushes through `native_data_pack_files` under the key `data_maps/<mod_id>:<registry path>/<name>`: `{"replace": false, "values": {"minecraft:coal": ..., "#minecraft:logs": ...}, "remove": [...]}`, an entry's value being the data itself or `{"value": ..., "replace": false}`; `replace` clears what earlier files set, a tag gives every entry in it the value, `remove` applies after the file's own values, and a newer value overrides an older one. `native_data_map(registry, type, entry)` reads an entry's value (the JSON the file held) and `native_data_map_entries(registry, type)` lists them. Server side only and not synced to clients; mergers, conditions, other registries and mod datapack registries are not here yet. Bundles need "hooks": 143.
`native_living_fall(entity, kind, distance, multiplier, cancel)` corresponds to NeoForge's `LivingFallEvent` at `LivingEntity.causeFallDamage`. Mods may change the distance and damage multiplier or cancel fall handling; cancellation does not reverse physical movement. This free function receives the entity ID and type and cannot call game helpers. Player landings, mounted player falls and non-equine mob falls are wired. Equines use their own fall-damage override rather than `LivingEntity.causeFallDamage`. Bundles need `"hooks": 357`.
`native_living_jump(entity, kind)` listens for NeoForge's `LivingEvent.LivingJumpEvent` after a living entity jumps. It receives the entity ID and type, and cannot cancel or change the jump. The player motion and mob ground-jump paths, including cubes and ridden horses and camels, are wired; whether every client-side prediction path reaches them remains unverified. Bundles need `"hooks": 359`.
`native_living_heal(entity, amount, cancel)` corresponds to NeoForge's `LivingHealEvent` at `LivingEntity.heal`. The amount can be changed and cancellation prevents the heal. It covers player effect, food and peaceful regeneration healing and the wired mob healing paths. Other healing paths and client-side coverage remain unverified. `entity` is the affected player or mob ID. Bundles need `"hooks": 358`.
`native_living_drown(entity, kind, is_drowning, damage_amount, bubble_count, cancel)` asks on the server when a player or generic-air mob cannot breathe and has no air (NeoForge's `LivingDrownEvent`, `LivingEntity.baseTick`). Mods may change the drowning decision, damage and bubble count, or cancel. Damage at or below zero does not hurt. The normal eight bubbles use the entity event; other positive counts are sent in a particle packet, and nonpositive counts suppress bubbles. Bundles need "hooks": 356.
API 144 adds a player's or mob's breathing, on the single player server thread and on a dedicated server: `native_living_breathe(entity, kind, can_breathe, consume, refill)` every tick for a player and for a mob with the generic air logic (NeoForge's `LivingBreatheEvent`, vanilla's `LivingEntity.baseTick`), with Horizon's answers as the defaults: set `can_breathe` and how much air `consume` takes or `refill` gives (up to 300); at -20 the entity is set to 0 air and takes 2 drowning damage. It is a free function in `horizon_client::breathing`, so it answers and cannot call helpers. Axolotls and `WaterAnimal`s keep their own air logic. Bundles that use it need "hooks": 144.
API 145 adds blocks and items a native mod declares in code, as NeoForge's `DeferredRegister.Blocks` and `.Items` drained by `RegisterEvent`, instead of one content file each: `native_blocks(blocks)` and `native_items(items)`, asked once at start on every side, take `horizon_mods::content::Block` and `Item`, the declarations a content mod's `data/<id>/horizon/block/` and `item/` files hold (a vanilla template and what differs; `item: true` gives a block its item). Ids are in the mod's namespace. They join the content mods' blocks and items, inside the content mod whose id is that namespace when it is on (its models, textures, loot and recipes), else on their own with the template's look, so their network ids, saves and the joining client's content check are the files' (docs/mods.md). They pass the files' checks; a namespace whose declarations don't is logged and left out. Bundles that use them need `"hooks": 145`.
@@ -954,9 +962,433 @@ API 152 adds a player opening and closing any menu on a server, on the single pl
API 153 adds a player starting or stopping flight: `native_player_toggle_flight(player, name, flying, cancel)` is heard once for each change a player's client makes to its flying (vanilla honours it only for a player who may fly), on a server, with the player's state swapped in. A mod sets `cancel` to refuse it: the player keeps the state they had and the server sends their abilities back, so the client lands or flies again. Only what a client asks for is heard, not the flight a game-mode change starts or ends, and not a LAN host's own player. Bundles that use it need `"hooks": 153`.
API @API:physical_window_size@ adds `native_physical_window_size()`, the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `"hooks": @API:physical_window_size@`.
API 165 adds `native_physical_window_size()`, the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `"hooks": 165`.
API @API:mod_image@ adds `Game::native_mod_image(width, height, rgba)`: a mod's own RGBA image, uploaded into the HUD atlas slot `horizon/modimage` (1024 by 1024) and drawn with `native_sprite`, whose source rectangle crops and stretches it, so a map or a schematic view needs no texture of its own. The slot is reserved only while a native mod is loaded, so a game without mods pays none of its 4 MiB. False for an image that is not `width * height` pixels of four bytes, is larger than the slot, or when there is no slot. Each upload is a texture patch of the image, so send one when the image changed, not every frame.
API 154 adds a player changing dimension: `native_player_changed_dimension(player, name, from, to)` is heard once per move of a server's player into another level, when the move has ended (a portal's exit found and the player standing there, a command's teleport landed, a respawn in another level placed for good), with `from` and `to` as dimension ids (`minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`) and the player's state swapped in so that `native_tell` and the other helpers reach them. A move within a level is not heard. Bundles that use it need `"hooks": 154`.
API 155 adds A mod that hears `native_experience_orb_pickup` sets `listened` in `native_experience_orb_pickup_listened` (a HEAD injection); `native_experience_orb_pickup(player, dimension, orb, value, count, canceled)` then runs before a server player takes an orb. Canceling leaves the orb and the pickup delay unchanged; otherwise the value is clamped to 1..=2,147,483,647 and the count to at least one, then one count is taken. `native_mending_repair(player, stack, experience, repair)` sees the damaged Mending item picked at random and can change the durability repair proposed for it, which is capped at its current damage. Turning a positive proposal into zero leaves the experience unspent (a zero vanilla proposal keeps vanilla's behaviour); if the picked stack is removed, changed except for its damage, or no longer eligible, the repair is skipped and the experience remains. The repair also decides how much experience is left for further repairs. It runs for `native_give_experience` too. Both run on the integrated and the dedicated server, guests included. Bundles need "hooks": 155.
API 156 adds where a loot roll happens, for a mod's own loot kinds: `ctx.place` in `native_loot_function` and
`native_loot_condition` brings `LootContextParams.ORIGIN` (a broken block's centre, a dead mob's position), `BLOCK_ENTITY` (the broken
block's entity as it was, saved with its id, position and `Items`) and the level (its dimension, game time, time of day, rain and
thunder), on a block broken by a player or an explosion and on a mob's death loot; None on the other rolls. The game sets it
around a roll with `with_native_loot_place`. Bundles that use it need `"hooks": 156`.
API 157 adds how a mod block burns, and whether it makes a campfire over it a signal fire, on an authoritative server. `native_fire_odds(dimension, state, odds)` answers
`FireBlock`'s flammability table (`FireBlock.setFlammable(block, igniteOdds, burnOdds)`) before vanilla's table: put
`(ignite, burn)` in `odds` for your own blocks. Ignite odds above zero let fire stand beside the block and spread
to the air around it (planks 5, wool 30); burn odds are how fast fire destroys it (planks 20, wool 60); `(0, 0)` never
burns; keep both within vanilla's 0 to 100. A waterlogged state never burns and isn't asked. It runs hundreds of times per fire tick: keep it short.
`native_smoke_source(dimension, state, smoke)` answers `CampfireBlock.isSmokeSource` for the block under a campfire,
as the campfire is placed or that block changes: `Some(true)` makes the campfire a signal fire, as a hay bale does.
Answer only for your own blocks and leave the answer empty otherwise; the level is read-only. The deprecated in-frame
path fires neither. Bundles that use them need `"hooks": 157`.
API 158 adds a server player's game mode about to change: `native_player_game_mode_changed(player, name, from, to, cancel)` is heard before the change (NeoForge's `PlayerChangeGameModeEvent`) for the `/gamemode` command, the F3+F4 switcher and a hardcore world's respawn into spectator, once for each change to a different mode (0 survival, 1 creative, 2 adventure, 3 spectator), on a server; not for the default mode a player joins with. A mod sets `cancel` to keep the old mode. Bundles that use it need `"hooks": 158`.
API 159 adds the server's difficulty changed, from one of `peaceful`, `easy`, `normal`, `hard` to another, through `native_difficulty_changed` (NeoForge's `DifficultyChangeEvent`). It is vanilla's `MinecraftServer.setDifficulty`, which `/difficulty`, a dedicated server's management and a test environment's difficulty all go through, and it is told only when the difficulty is another one. It only hears: the change has already happened. Bundles that use it need "hooks": 159.
API 160 adds a player's fishing rod: `native_player_fish(player, name, state, cancel)` is heard for the cast (`state` 0), a fish biting (1), the reel-in with a catch (2), the reel-in with nothing on the line (3) and the reel-in of a bobber hooked to an entity or item (4), for a player of a server (Bukkit's `PlayerFishEvent`; NeoForge's `ItemFishedEvent` fires later, once the loot is rolled). A mod sets `cancel` to stop a cast, make the fish get away, or leave the bobber out on a reel-in. The player's state is swapped in. Bundles that use it need `"hooks": 160`.
API 161 adds a player took a crafting grid's result, through `native_item_crafted` (NeoForge's `PlayerEvent.ItemCraftedEvent`): the player's uuid, the recipe the grid matched (`minecraft:oak_planks`) and the result stack it took, with its components. It only hears; the take has already happened. It fires for the crafting grid of an inventory or a crafting table, on the integrated server and on a dedicated server, guests included, and not for a stonecutter's, a smithing table's or a furnace's result. Bundles that use it need "hooks": 161.
API 162 adds a player's item losing durability: `native_item_damaged(player, name, item, amount, cancel)` is heard where `ItemStack.hurtAndBreak` wears the item of a player of a server (Bukkit's `PlayerItemDamageEvent`), after Unbreaking, with the points about to be lost. A mod changes `amount` or sets `cancel` for no wear. The player's state is swapped in. Bundles that use it need `"hooks": 162`.
API 163 adds `native_server_event(name, uuid, message)` posts an `announce` event to a dedicated server's event stream (`GET /api/v1/events`); horizon-server's Discord bridge relays it, in gold, when `[discord] events` lists `announce` (off by default). False elsewhere and for an empty message. Bundles that use it need "hooks": 163.
API 164 adds `native_state_block(state)` names the block a state belongs to (`BlockState.getBlock()`), on a server hook's level and on the game: `native_state_for_placement` is asked for every block placed, so a mod compares it with its own block to answer only for it. Bundles that use it need "hooks": 164.
API 165 adds the helper `native_physical_window_size()`, which returns the window's width and height in physical pixels, independent of GUI scale. It returns no size on a server or when no window exists. Bundles that use it need `"hooks": 165`.
API 166 adds a recipe ingredient of a mod's own kind. The ingredient entry that names a type in a namespace outside minecraft: reaches native_ingredient(kind, data, items) at load time. Fill items with the item ids the ingredient matches (item ids only: components are not checked; a match-time hook follows separately). Duplicates are removed keeping first-seen order. An unclaimed kind leaves the list empty and the recipe fails. The hook may be called more than once per recipe; the answer must be the same. Bundles that use it need "hooks": 166.
API 167 adds lowering the frame rate limit: `native_framerate_limit(focused, minimized, in_world, limit)` is asked each frame with the limit Horizon is about to use (Max Framerate after the inactivity throttle and Battery Mode, 260 for unlimited), whether the window has focus, whether it is minimised and whether a world is open. A mod may lower `limit`, for example to throttle a window in the background as Dynamic FPS does; Horizon keeps the lower of its own limit and the mod's and ignores values below 1. Bundles that use it need `"hooks": 167`.
API 168 adds a player swapping their main and off hand items: `native_player_swap_hands(player, name, cancel)` is heard before the swap (Bukkit's `PlayerSwapHandItemsEvent`; a swap from an open inventory is not heard), for a player of a server who is not a spectator, with their state swapped in. A mod sets `cancel` to keep the items where they are; the player's client gets its slots back. Bundles that use it need `"hooks": 168`.
API 169 adds a player's food level changing: `native_food_level_changed(player, name, from, to, cancel)` is heard for a player of a server when a turn of theirs changed it (eating, hunger and saturation effects, exhaustion, starving; Bukkit's `FoodLevelChangeEvent`), before the new level reaches them. A mod changes `to` or sets `cancel` to leave the level at `from`. Bundles that use it need `"hooks": 169`.
API 170 adds a server player teleported: `native_player_teleported(player, name, from, to, dimension)` is heard after `/tp` or `/teleport` has moved a player inside their level or into another one (NeoForge's `EntityTeleportEvent`), with their state swapped in. `from` and `to` are their feet before and after, `dimension` the level they are in now. It cannot stop the move; ender pearls, chorus fruit and a spectator's teleport menu are not heard yet, portals and respawns are not teleports here. Bundles that use it need `"hooks": 170`.
API 171 adds the command tree a server sends a player: `native_player_commands_sent(player, name, names)` is heard when a player joins and when their permission level changes, with the root command names their client would get. For a player already on their state is swapped in. A mod removes names and that client's tree is sent without them (its chat box neither completes nor colours them); a typed command still runs, `native_player_command` stops it. Bundles that use it need `"hooks": 171`.
API 172 adds a joining player the mods may refuse: `native_player_login(player, name, cancel, reason)` is heard once for each player a server has accepted (name, whitelist, bans and player limit checked), before their saved state is loaded and before they are placed in a level (NeoForge's `PlayerNegotiationEvent`). A mod sets `cancel` to disconnect them with `reason` ("Disconnected" when it stays unset); no join message and no `native_player_logged_in` follow. Bundles that use it need `"hooks": 172`.
API 173 adds a server player's sprint: `native_player_sprint(player, name, sprinting)` is heard once each time a player's client starts or stops sprinting (the sprint key, a double tap forward, letting go) and the server's player has taken it, with their state swapped in. It only hears what the client asks: a sprint that ends by itself (knockback, hunger, a wall) is not reported. Bundles that use it need `"hooks": 173`.
API 174 adds a resource pack answer: `native_resource_pack_status(player, name, status)` is heard when a server player's client answers a resource pack in play (`status` is `ServerboundResourcePackPacket.Action`'s number: 0 loaded, 1 declined, 2 failed download, 3 accepted, 4 downloaded, 5 invalid URL, 6 failed reload, 7 discarded), with their state swapped in. The pack offered during the configuration is answered before the player has joined and is not heard. Bundles that use it need `"hooks": 174`.
API 175 adds a player taking an item from or putting one on an armor stand: `native_armor_stand_manipulate(player, name, stand_id, slot, cancel)` is heard after the stand's disabled-slot checks and before anything moves, with the stand's entity id and the slot the click reached (`mainhand`, `offhand`, `feet`, `legs`, `chest`, `head`), on a server, with the player's state swapped in. A mod sets `cancel` and neither the stand nor the player's hand changes. Bundles that use it need `"hooks": 175.
API 176 adds a player opening one of the game's own menus: `native_container_opened(player, name, kind, pos, cancel)` is heard when a chest, furnace, crafting table, villager, anvil, lectern, horse inventory... is about to open (the job of NeoForge's `PlayerContainerEvent.Open`, but before the menu opens), after the checks that refuse it, with the menu type and the block's position, on a server, with the player's state swapped in. A mod sets `cancel` and nothing opens. Menus a mod opens (`native_open_menu`) are not heard. Bundles that use it need `"hooks": 176`.
API 177 adds a creative player setting a slot: `native_creative_slot(player, name, slot, item, count, cancel)` is heard when the creative screen sets an inventory slot, or throws a stack out (a slot below 0), after the packet passed vanilla's checks and before the slot changes, on a server, with the player's state swapped in. A mod sets `cancel` and the packet is ignored. Bundles that use it need `"hooks": 177`.
API 178 adds a boat or minecart moving: `native_vehicle_moved(player, kind, from, to)` is heard once a tick for each boat or minecart that carries a server player (and for one a server player left, for ten seconds after it stood still), with the vehicle's type and its position before and after (`VehicleMoveEvent`), on a server, with the rider's state swapped in. `player` is the rider's UUID, none for a vehicle that carries nobody now. It only hears. Bundles that use it need `"hooks": 178`.
API 179 adds a server player emptying a bucket: `native_bucket_empty(player, name, pos, fluid, cancel)` is heard right before the fluid is placed, once the player may build there. A mod sets `cancel` and nothing is poured. The player's state is swapped in, so the helpers reach them. `native_fill_bucket` hears the same click without the player and the fluid; dispensers and cauldrons are not heard. Bundles that use it need `"hooks": 179`.
API 180 adds a server player placing a hanging entity: `native_hanging_place(player, name, kind, pos, cancel)` is heard for an item frame, a glow item frame, a painting and a leash knot (a lead tied to a fence that has none), once it fits and before it is placed or the item used. A mod sets `cancel` and nothing is placed. The player's state is swapped in, so `native_tell` answers them. Bundles that use it need `"hooks": 180`.
API 181 adds TNT about to be primed: `native_tnt_primed(player, pos, cancel)` is heard before a flint and steel, a fire charge, fire, a redstone signal, an arrow or a dispenser lights the TNT at `pos`, with the player whose click lit it (None when no player did). A mod sets `cancel` and it is not primed: the block stays, or is put back when fire or a signal had already taken it out. The clicking player's state is swapped in. TNT set off by a blast, a TNT minecart and a sulfur cube are not asked. Bundles that use it need `"hooks": 181`.
API 182 adds a totem saving a player: `native_player_totem(player, name)` is heard for a server player whose hurt would have killed them and whose totem fired (`LivingEntity.checkTotemDeathProtection`, Bukkit's `EntityResurrectEvent`), just before the totem is used up. Listen only. The player's state is swapped in. Bundles that use it need `"hooks": 182`.
API 183 adds `native_anvil_damaged(dimension, pos, from, to)` runs after an anvil at `pos` changes from `from` to `to`; `to` is air when it broke. Bundles need "hooks": 183.
API 184 adds `native_beacon_state_change(dimension, pos, active)` is called after the beacon at `pos` gains or loses its complete beam pyramid; `active` says whether it is active now. Bundles need "hooks": 184.
API 185 adds A bell is about to ring: `native_bell_ring(dimension, player, pos, cancel)` is asked before the bell event and sound. Set `cancel` to stop the ring and its raider reveal. `player` is the acting server player when available. Bundles need "hooks": 185.
API 186 adds `native_block_break_progress_update(player, name, pos, progress)` is called when a guest's crack animation at `pos` reaches a new stage from 0 through 9. Bundles need "hooks": 186.
API 187 adds Fire about to burn a block: `native_block_burn(dimension, pos, cancel)` is heard when fire's burnout check is about to remove the block at `pos`. Set `cancel` to keep it. Bundles need "hooks": 187.
API 188 adds A campfire finishes cooking `item` into `result` at `pos`, before dropping the result. Bundles need "hooks": 188.
API 189 adds A dispenser equips `item` on the first eligible player, mob or armor stand in front of its block at `pos`, after the item is consumed and the equip sound and game event are applied. `target` identifies the kind of entity equipped. Bundles need "hooks": 189.
API 190 adds A block with a vanilla experience drop is about to award its points at `pos`; `block` is its identifier. Change `experience` to adjust the amount before the orbs are awarded. Bundles need "hooks": 190.
API 191 adds `native_block_fade(dimension, pos, from, to, cancel)` runs when ice or snow melts, or coral dies. `from` and `to` are block state IDs. Set `cancel` to keep the old block. Bundles need "hooks": 191.
API 192 adds `native_block_failed_dispense(dimension, pos, item)` is called when a dispenser at `pos` fails to dispense `item`. Bundles need "hooks": 192.
API 193 adds `native_block_form(dimension, pos, from, to, cancel)` runs when precipitation is about to place ice or snow, or add a snow layer at `pos`. `from` and `to` are block state IDs. Set `cancel` to keep the old state. Bundles need "hooks": 193.
API 194 adds Fire about to be placed: `native_block_ignite(dimension, player, pos, cause, cancel)` is heard for fire spread, lava, lightning and dispenser flint-and-steel. Set `cancel` to leave the position unchanged. Direct player flint-and-steel/fire-charge placement is not covered. Bundles need "hooks": 194.
API 195 adds `native_block_lock_check(player, name, pos, locked)` runs when a guest checks the container at `pos`; `locked` starts with vanilla's result and may be changed. The guest's state is not swapped in. Bundles need "hooks": 195.
API 196 adds A sculk sensor, calibrated sensor or shrieker received vibration event `event` at `pos`; `block` is its identifier. Set `cancel` to suppress the block's response. Bundles need "hooks": 196.
API 197 adds A dispenser sheared entity `entity` of kind `kind` at the block in front of the dispenser at `pos`, after the shearing effects. Bundles need "hooks": 197.
API 198 adds A brewing stand finished brewing: `native_brewing_complete(pos)` is heard after the bottles at `pos` received their brewed contents. Server-side. Bundles need "hooks": 198.
API 199 adds A brewing stand consumed fuel: `native_brewing_fuel(pos, fuel)` is heard after the stand at `pos` consumed the item `fuel` and gained brewing fuel. Server-side. Bundles need "hooks": 199.
API 200 adds A brewing stand starts brewing after it consumes fuel and sets its timer. Bundles need "hooks": 200.
API 201 adds A system broadcast line is about to be logged and sent to every player: `native_broadcast_message(line, cancel)` lets a mod rewrite it or set `cancel` to drop it. Bundles need "hooks": 201.
API 202 adds An item was placed on a campfire and began cooking. Bundles need "hooks": 202.
API 203 adds A cauldron's fill level is about to change: `native_cauldron_level_changed(dimension, player, pos, from_level, to_level, reason, cancel)` is asked for rain, snow, dripstone and extinguishing. Set `cancel` to leave it as it was. `from_level` and `to_level` use 0 for empty and 1 to 3 for filled levels. `player` is the acting server player when available. Bucket, bottle and dyeing interactions are not covered yet. Bundles need "hooks": 203.
API 204 adds A player adds a compostable item to a composter at `pos`; the layer has been applied. Bundles need "hooks": 204.
API 205 adds A crafter at `pos` completed `recipe`; item consumption and output handling have finished. Bundles need "hooks": 205.
API 206 adds A player used an item in `minecraft:creeper_igniters` on a creeper, after it entered its ignited state. Bundles need "hooks": 206.
API 207 adds Lightning struck a creeper and set its charged state; the hook reports the creeper id and strike position. Bundles need "hooks": 207.
API 208 adds The first Ender Dragon fight is about to place its egg at `pos`: `native_dragon_egg_form(dimension, pos, cancel)` runs on `GameLevel`. Set `cancel` to leave the block unchanged. Bundles need "hooks": 208.
API 209 adds A player applied an enchantment: `native_enchant_item(player, name, pos, item, levels)` is heard after the player at `pos` applied it, with the finished `item` and levels charged. The guest's state is not swapped in. Bundles need "hooks": 209.
API 210 adds Saved entity records for a chunk finished loading into the server world: `native_entities_load(dimension, chunk, count)` reports the dimension, chunk coordinates and number of records read. Bundles need "hooks": 210.
API 211 adds Entities in a chunk left the active world and were queued for saving: `native_entities_unload(dimension, chunk, count)` reports the dimension, chunk coordinates and number of records. Bundles need "hooks": 211.
API 212 adds A zombie or raider finished breaking the door at `pos`; the mob id and entity type are reported after the door is removed. Bundles need "hooks": 212.
API 213 adds Two mobs completed a breeding action: `native_entity_breed(parent, partner, child)` reports both parents and the baby id when one was created; egg laying has no child id. Bundles need "hooks": 213.
API 214 adds A mob changed the block at `pos`; the hook reports the mob id and kind, and the old and new block names after the new state was applied. Bundles need "hooks": 214.
API 215 adds A bee was stored in a beehive or bee nest at `pos`; the hook reports the bee id and kind and the block name, after the bee was removed from the level. Bundles need "hooks": 215.
API 216 adds A mob entered love mode after a player interaction; the hook reports its entity id, kind and feeder UUID when known. Bundles need "hooks": 216.
API 217 adds A server entity finished its portal wait and is beginning to leave through the portal block at `pos`: `native_entity_portal(dimension, entity, pos)` reports the entity's network id, source dimension and entry block. The transfer destination follows the existing portal rules. Bundles need "hooks": 217.
API 218 adds A guest player's synchronized pose changed: `native_entity_pose_change(player, name, from, to)` reports the old and new pose names. Bundles need "hooks": 218.
API 219 adds A player fired an arrow from a bow or crossbow: `native_entity_shoot_bow(player, name, projectile, force)` reports the arrow id and shooting power after the completed shot. Bundles need "hooks": 219.
API 220 adds A mob became tamed after its owner and persistence were set: `native_entity_tame(entity, kind, owner)` reports the entity type and owner UUID when known. Bundles need "hooks": 220.
API 221 adds A server entity completed a same-level teleport command: `native_entity_teleport(dimension, entity, from, to)` reports its network id and the coordinates before and after the move. Bundles need "hooks": 221.
API 222 adds A guest player's fall flying state changed after the server accepted or ended a glide: `native_entity_toggle_glide(player, name, gliding)`. Bundles need "hooks": 222.
API 223 adds A mob conversion completed through Horizon’s shared `Mob.convertTo` path; the hook reports the removed entity and its replacement ids and kinds. Bundles need "hooks": 223.
API 224 adds An experience bottle broke, after its water splash and experience orbs were created; the hook reports the impact position and awarded experience. Bundles need "hooks": 224.
API 225 adds A firework rocket completed its damage, particles and explosion effects; the hook reports its entity id, position and number of burst shapes. Bundles need "hooks": 225.
API 226 adds `native_fluid_level_change(dimension, pos, fluid, from, to, cancel)` is called when flowing water or lava at `pos` is about to change level; levels run from 0 through 8. Set `cancel` to keep the old level. Bundles need "hooks": 226.
API 227 adds A furnace consumes `fuel` and begins burning at `pos` (`native_furnace_burn`). Bundles need "hooks": 227.
API 228 adds A server player takes `count` items of `item` from a furnace at `pos` (`native_furnace_extract`). Bundles need "hooks": 228.
API 229 adds A furnace finishes smelting `item` into `result` at `pos` (`native_furnace_smelt`). Bundles need "hooks": 229.
API 230 adds A furnace begins cooking `item` at `pos` when its cooking timer first advances (`native_furnace_start_smelt`). Bundles need "hooks": 230.
API 231 adds A generic world game event fired at `pos`: `native_game_event(event, pos)` reports its registered event id and position, whether or not vibration listeners are nearby. Bundles need "hooks": 231.
API 232 adds A hopper is about to pick up a dropped stack: `native_hopper_pickup_item(dimension, pos, item, count, cancel)` names its position and stack. Set `cancel` to leave the item in the world. Bundles need "hooks": 232.
API 233 adds `native_inventory_block_start(player, name, pos, cancel)` runs before a guest opens an inventory supplied by a block at `pos`. Set `cancel` to leave it closed; the guest's state is not swapped in. Bundles need "hooks": 233.
API 234 adds A dropped item entity was successfully added to the server level: `native_item_spawn(entity, item, count, pos)` reports its entity id, item type, stack count and spawn position. Bundles need "hooks": 234.
API 235 adds `native_leaves_decay(dimension, pos, cancel)` is called when a non-persistent leaf block at distance 7 is about to decay. Set `cancel` to keep it. Bundles need "hooks": 235.
API 236 adds A weather, Channeling-trident or `/summon` lightning bolt is about to be created: `native_lightning_strike(player, pos, cause, cancel)`. Set `cancel` to prevent the bolt and its effects. Bundles need "hooks": 236.
API 237 adds `native_moisture_change(dimension, pos, from, to, cancel)` runs when farmland at `pos` is about to change moisture from `from` to `to`. Set `cancel` to keep the old moisture. Bundles need "hooks": 237.
API 238 adds A server player put a plant into a flower pot: `native_player_flower_pot_manipulate(player, name, pos, plant)` is heard after the potted state is stored and the item consumed. Bundles need "hooks": 238.
API 239 adds A server player inserted a book into a lectern: `native_player_insert_lectern_book(player, name, pos, book)` is heard after the block entity and inventory changed. Bundles need "hooks": 239.
API 240 adds A server player changed an item frame: `native_player_item_frame_change(player, name, entity_id, action, item, rotation)` is heard after its contents and comparator output changed. Bundles need "hooks": 240.
API 241 adds An orb repaired an item through Mending: `native_player_item_mend(player, name, item, repair, experience)` is heard after its damage changed. Bundles need "hooks": 241.
API 242 adds A server player changed a lectern page: `native_player_lectern_page_change(player, name, pos, page)` is heard after the page and redstone pulse changed. Bundles need "hooks": 242.
API 243 adds A server player took a book from a lectern: `native_player_take_lectern_book(player, name, pos, book)` is heard after its block entity changed. Bundles need "hooks": 243.
API 244 adds `native_spawn_change(pos, angle)` is told after `/setworldspawn` moves the world's spawn point, with its block position and rotation. Bundles need "hooks": 244.
API 245 adds `native_world_border_bounds_changed()` is told after `/worldborder set` or `/worldborder add` changes the border size or starts its timed size change. It hears successful commands only. Bundles need "hooks": 245.
API 246 adds `native_world_border_center_changed()` is told after `/worldborder center` moves the border center. It hears successful commands only. Bundles need "hooks": 246.
API 247 adds `native_world_game_rule_change(player, name, rule, value)` is told after an operator changes a world game rule through the server's game rule screen, with its final value. Bundles need "hooks": 247.
API 248 adds A note block is about to sound: `native_note_play(dimension, player, pos, instrument, note, cancel)` is asked when punched, used or powered. Set `cancel` to silence it; a canceled tuning interaction leaves the note unchanged. `player` is the acting server player when available. Bundles need "hooks": 248.
API 249 adds A server player completed an advancement after progress was recorded; `advancement` is its identifier, such as `minecraft:story/mine_stone`. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need "hooks": 249.
API 250 adds A server player's main-hand punch swing and attack-strength reset have happened: `native_player_attack_cooldown_reset(player, name)`. Helpers looking up the player through `self.lan` may find nothing during a guest packet. Bundles need "hooks": 250.
API 251 adds A server player about to enter a bed after its sleep checks pass: `native_player_bed_enter(player, name, pos, cancel)` hears the bed head position. Set `cancel` to keep the player out. The player's state is swapped in, though helpers that look them up through `self.lan` may find nothing during a guest packet. Bundles need "hooks": 251.
API 252 adds A server player woke or stood up from the bed at `pos`, after their sleeping state changed. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need "hooks": 252.
API 253 adds A server player caught a bucketable mob: `native_player_bucket_entity(player, name, entity_id, entity)` runs after the mob was removed and the filled bucket was put in the player's hand or inventory. `entity` is the mob species id. Bundles need "hooks": 253.
API 254 adds A server player's selected beacon effects were stored: `native_player_change_beacon_effect(player, name, pos, primary, secondary)`. Empty effect ids mean none is selected. Bundles need "hooks": 254.
API 255 adds A server player's client option changed their dominant hand; `native_player_change_main_hand(player, name, left)` reports whether the left hand is dominant. Bundles need "hooks": 255.
API 256 adds A server player counts as deeply asleep after 100 ticks in a bed: `native_player_deep_sleep(player, name)` fires once at that point. Helpers looking the guest up through `self.lan` may find nothing during its tick. Bundles need "hooks": 256.
API 257 adds A server player book-and-quill packet is about to be applied. `pages` is its page count and `signing` says it has a title; set `cancel` to drop it. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need "hooks": 257.
API 258 adds A server player threw an egg after its projectile was launched and its use applied. Bundles need "hooks": 258.
API 259 adds A server player used a firework rocket to boost while gliding, after the rocket was launched and consumed. Bundles need "hooks": 259.
API 260 adds A server player's experience points from an orb pickup: `native_player_exp_change(player, name, amount)` is called after mending has taken its share and before the remaining points are added. Change `amount` to change the points added; negative values become zero. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Command experience changes use another path and are not heard. Bundles need "hooks": 260.
API 261 adds A server player's XP orb pickup cooldown changed, after its new value is stored (`native_player_exp_cooldown_change(player, name, ticks)`). Bundles need "hooks": 261.
API 262 adds An item cooldown about to start for a server player: `native_player_item_cooldown(player, name, item, ticks, cancel)` is called for use cooldowns and for a shield disabled by a hit. `item` is the item id and `ticks` its duration. Set `cancel` to skip the cooldown; shield disable still stops the use and plays its configured sound. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Bundles need "hooks": 262.
API 263 adds A server player selected a different hotbar slot: `native_player_item_held(player, name, from_slot, to_slot, cancel)` is called after the slot range check and before the selected slot changes. Set `cancel` to keep the old slot; the client is sent that slot again. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Bundles need "hooks": 263.
API 264 adds A server player changed their client language option (`native_player_locale_change(player, name, locale)`), after the new locale is stored. Bundles need "hooks": 264.
API 265 adds A server player selected loom pattern button `pattern`, after the menu accepted it: `native_player_loom_pattern_select(player, name, pattern)`. Bundles need "hooks": 265.
API 266 adds A server player filled an empty map: `native_player_map_filled(player, name, map_id)` fires after the filled map and its saved map data were created. Guest lookup helpers may find nothing during packet handling. Bundles need "hooks": 266.
API 267 adds A server player named a mob with a name tag, after its name and persistence changed: `native_player_name_entity(player, name, entity_id, entity_name)`. Bundles need "hooks": 267.
API 268 adds A server player opened a sign editor at `pos`, for the front or back side, after the editor state was made: `native_player_open_sign(player, name, pos, front)`. Bundles need "hooks": 268.
API 269 adds A server player's middle-click pick resolved a block or entity's pick stack and is about to put it in the hotbar: `native_player_pick_item(player, name, from_entity, pos, entity_id, cancel)` receives block coordinates and entity_id zero for a block, or zero coordinates and the entity network id for an entity. A pick with no result is not heard. Set `cancel` to leave the inventory and client unchanged. The player's state is swapped in, though helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Bundles need "hooks": 269.
API 270 adds A server player picked up an arrow after it was removed and its pickup was applied: `native_player_pickup_arrow(player, name, entity_id, item)` gives the pickup item id. Bundles need "hooks": 270.
API 271 adds A server player about to travel through a Nether portal, End portal or End gateway: `native_player_portal(player, name, from_dimension, to_dimension, cause, cancel)` supplies the source and target dimension ids; `cause` is `nether_portal`, `end_portal` or `end_gateway`. Set `cancel` to leave the player where they are. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest tick is handled. Bundles need "hooks": 271.
API 272 adds A server player requests a known recipe be placed in their crafting grid through the recipe book (`native_player_recipe_book_click(player, name, recipe, all)`). Bundles need "hooks": 272.
API 273 adds A recipe about to become known to a server player: `native_player_recipe_discovered(player, name, recipe, cancel)` is heard for advancement rewards, knowledge books and `/recipe give`. Set `cancel` to keep it locked; the client is sent the matching removal. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Bundles need "hooks": 273.
API 274 adds A server player launches with a riptide trident: `native_player_riptide(player, name)` fires after the velocity and spin are applied. The player's state is swapped in; helpers that look them up through `self.lan` may find nothing during a guest packet. Bundles need "hooks": 274.
API 275 adds A server player about to shear a sheep, mooshroom, snow golem or bogged. `off_hand` says which hand holds the shears; set `cancel` to leave the mob and tool unchanged. Bundles need "hooks": 275.
API 276 adds A server player's shield was disabled by a blocked melee hit: `native_player_shield_disable(player, name, ticks)` fires immediately before the shield cooldown is set. Bundles need "hooks": 276.
API 277 adds A server player's respawn point about to change: `native_player_spawn_point_set(player, name, dimension, pos, cause, cancel)` is heard for `bed`, `respawn_anchor`, `command` and `death_reset`; `pos` is the block position. Set `cancel` to keep the old point. A canceled `death_reset` keeps the old point even if its bed or anchor is gone. The player's state is not swapped in, so helpers that look them up through `self.lan` may find nothing while a guest packet is handled. Bundles need "hooks": 277.
API 278 adds A server player selected stonecutter recipe button `recipe`, after the menu accepted it: `native_player_stonecutter_recipe_select(player, name, recipe)`. Bundles need "hooks": 278.
API 279 adds A spectator's camera returned to their own player after they stopped watching an entity: `native_player_stop_spectating_entity(player, name, entity_id)` gives the former target. Bundles need "hooks": 279.
API 280 adds A server player swapped their main hand with an equipment slot: `native_player_swap_with_equipment_slot(player, name, slot)` fires after both hands changed; `slot` is currently `offhand`. Guest lookup helpers may find nothing during packet handling. Bundles need "hooks": 280.
API 281 adds A player is about to travel through an End gateway (`native_player_teleport_end_gateway(player, name, gateway, cancel)`). Set `cancel` to leave them at the entrance without starting the gateway cooldown. Bundles need "hooks": 281.
API 282 adds A server player used a golden dandelion on an ageable mob: `native_player_toggle_entity_age_lock(player, name, entity_id, locked)` fires after the lock and item use changed. Bundles need "hooks": 282.
API 283 adds A server player completed a villager or wandering trader trade, after the merchant menu applied the trade: `native_player_trade(player, name, entity_id)` gives the merchant's network id. Bundles need "hooks": 283.
API 284 adds A server player's action removed an entity's leash: `native_player_unleash_entity(player, name, entity_id)` fires after the leash state is cleared. Bundles need "hooks": 284.
API 285 adds The server synced changed motion to a player: `native_player_velocity(player, name, velocity)` fires with velocity in blocks per tick. Guest lookup helpers may find nothing during packet handling. Bundles need "hooks": 285.
API 286 adds `native_portal_create(dimension, pos)` is told after a Nether portal for dimension travel or the End arrival platform has been built, with its dimension and lower corner or platform origin. Bundles need "hooks": 286.
API 287 adds A thrown splash or lingering potion finished applying its hit effects; the hook reports the potion item and impact position. Experience bottles are excluded. Bundles need "hooks": 287.
API 288 adds An anvil result was prepared: `native_prepare_anvil(player, name, pos, result, cost)` is called for a server player at `pos`; change the result or level cost before it is sent to the menu. Guest state is not swapped in. Bundles need "hooks": 288.
API 289 adds A grindstone result was prepared: `native_prepare_grindstone(player, name, pos, result)` is called for a server player at `pos`; change the result before it is sent to the menu. Guest state is not swapped in. Bundles need "hooks": 289.
API 290 adds A crafting-grid result was prepared: `native_prepare_item_craft(player, name, pos, result)` is called for the inventory grid (None) or a table at `pos`; change the result before it is sent to the menu. Guest state is not swapped in. Bundles need "hooks": 290.
API 291 adds Enchanting offers were calculated: `native_prepare_item_enchant(player, name, pos, costs)` is called for a server player at `pos`; change the three costs before they are sent to the menu. Guest state is not swapped in. Bundles need "hooks": 291.
API 292 adds A smithing result was prepared: `native_prepare_smithing(player, name, pos, result)` is called for a server player at `pos`; change the result before it is sent to the menu. Guest state is not swapped in. Bundles need "hooks": 292.
API 293 adds A projectile was added to the server level: `native_projectile_launch(player, name, entity, kind)` reports the entity id and type, plus the player launcher when there is one. Bundles need "hooks": 293.
API 294 adds A raid wave has spawned at its position, with its raid id, wave number and raider count (riders' mounts not counted). Bundles need "hooks": 294.
API 295 adds A raid is triggered or reaches `victory`, `loss` or `stopped`: `native_raid_state_change(raid, pos, state)` reports its id, center and new state. Bundles need "hooks": 295.
API 296 adds `native_sculk_bloom(dimension, pos)` is called after a sculk catalyst at `pos` takes experience and enters the blooming state. Bundles need "hooks": 296.
API 297 adds `native_sponge_absorb(dimension, pos)` is called after a sponge at `pos` has absorbed water and become wet. Bundles need "hooks": 297.
API 298 adds A guest's server command completion request was evaluated: `native_tab_complete(player, name, command, range, suggestions)` lets a mod add or filter the suggestions; `range` is the replaced byte range. The guest's state is not swapped in, so helpers that look through `self.lan` may find nothing while its packet is handled. Bundles need "hooks": 298.
API 299 adds `native_target_hit(dimension, pos, strength)` is called after a projectile hits the target block at `pos` and it takes the reported redstone output strength. Bundles need "hooks": 299.
API 300 adds The server's `/weather` command is about to change thunder: `native_thunder_change(dimension, thundering, cancel)`. Set `cancel` and the previous thunder state and timer remain. Bundles need "hooks": 300.
API 301 adds The overworld clock is about to jump after sleep or a `/time` command: `native_time_skip(dimension, from, to, cause, cancel)`. Set `cancel` to keep the old time. Bundles need "hooks": 301.
API 302 adds A player selected a trade: `native_trade_select(player, name, villager, index)` is heard after the trading menu updates; `villager` is the entity id when known. The player's state is not swapped in. Bundles need "hooks": 302.
API 303 adds `native_vault_change_state(dimension, pos, state)` is called after a vault at `pos` changes to `state` (`inactive`, `active`, `unlocking`, or `ejecting`). Bundles need "hooks": 303.
API 304 adds The server's `/weather` command is about to change its rain and thunder parameters: `native_weather_change(dimension, raining, thundering, cancel)`. Set `cancel` and the prior weather and timers remain. Bundles need "hooks": 304.
API 305 adds The dedicated server's whitelist changed state: `native_whitelist_state_update(enabled)` reports its final on/off state, after `/whitelist on` or `/whitelist off` from a player or console. Bundles need "hooks": 305.
API 306 adds The server completed a world save cycle: `native_world_saved(wait)` reports whether chunk writes were flushed before returning. Bundles need "hooks": 306.
API 307 adds energy held by items (`Capabilities.Energy.ITEM`). `native_item_energy_buffer(item, buffer)` gives a mod item a buffer as `SimpleEnergyHandler` is built: its capacity and the most one insert and one extract move (a capacity under 1 leaves it out; the limits are clamped to 0..=capacity). The amount lives in the stack's `minecraft:custom_data` as the int `horizon_energy`, so it is saved and sent with the stack; an empty item carries nothing, so it stacks with a new one. `native_item_energy(stack)` reads one item of a stack (amount, capacity, limits); `native_item_energy_insert(stack, amount)` and `native_item_energy_extract(stack, amount)` answer the amount one item takes or gives and the single item it becomes, as `EnergyHandler.insert`/`extract` through an `ItemAccess`; the stack given is not changed, so the caller shrinks it by one and hands out the result, or leaves the result unused to simulate. They work on any side. Bundles need "hooks": 307.
API 308 adds the time players wake at after sleeping, as NeoForge's `SleepFinishedTimeEvent`: `native_sleep_finished_time(dimension, current, new_time)` runs on the server once enough players have slept and the `advance_time` rule is on, before the clock moves. `new_time` comes in as the next morning and a mod may change it; a time before `current` is refused. `native_time_skip` then hears the jump to the chosen time. Bundles need "hooks": 308.
API 309 adds a shift-click in a mod container's menu routed by the mod, `AbstractContainerMenu.quickMoveStack`: `native_menu_quick_move(pos, entity, container_slots, slot, stack, handled)` runs on the server before the game's own routing, for a mod container on a vanilla menu type or its own menu type. The mod takes what it moves out of `stack` and sets `handled`; the slot keeps the rest and the client gets the menu again. Bundles need "hooks": 309.
API 310 adds a server player's trade with a villager or a wandering trader, as NeoForge's `TradeWithVillagerEvent`: `native_trade_with_villager(merchant, kind, offer)` is heard once per trade made, after its statistic and `villager_trade` trigger, with the trader's entity id and type and the `MerchantOffer` as it is after the trade. Listen only; the player's state is swapped in. Bundles need "hooks": 310.
API 311 adds a server player's `teleport_randomly` consume effect (chorus fruit) is about to try a spot: `native_chorus_fruit_teleport(player, name, from, target, cancel)`, once per attempt (up to 16). A mod may move `target`; the game still looks down from it for ground to land on. `cancel` ends the effect with no teleport and no more attempts. Foxes are not heard. Bundles need "hooks": 311.
API 312 adds a server player's ender pearl landed: `native_ender_pearl_teleport(player, name, pearl, from, target, damage, cancel)` runs before the endermite roll and the teleport. A mod may move `target` or change the fall `damage` (5; 0 or less is none); `cancel` keeps the player where they are, without the endermite roll, damage or sound, and the pearl is used up either way. Bundles need "hooks": 312.
API 313 adds drawing an item stack on a screen: `native_item_stack(batch, stack, at, size)` draws its model, glint, damage bar, cooldown and mod decorations, but not its count. It draws nothing and returns false for an unknown item or an empty stack (air, or a count of 0). Bundles need "hooks": 313.
API 314 adds hiding a player (a vanish): `native_hide_player(uuid, level)` hides that player, from the next tick, from the players below permission `level` (None shows them again): they leave those players' Tab list and world (their clients stop tracking them, as they do a spectator) and those players don't read them joining or leaving. Players of `level` or more still see them; a player made an operator sees them again. It is kept by UUID, for a player who is off too, until a mod sets it again or the server stops. Server only; false on a client. Bundles need "hooks": 314.
API 315 adds a player's item breaking: `native_player_destroy_item(player, name, item, slot)` is heard for a server player whose tool, shield, rod or armour piece ran out of durability (`LivingEntity.onEquippedItemBroken`, Bukkit's `PlayerItemBreakEvent`), with the item that broke and the slot it was in (`mainhand`, `offhand`, `head`, `chest`, `legs`, `feet`, `body`, `saddle`); armour has a slot rather than a hand. `native_item_damaged` is the wear, this is only the break. Listen only. The player's state is swapped in. Not heard for a creative player or for the host of a LAN world. Bundles that use it need `"hooks": 315`.
API 316 adds A player's damaging melee hit on a living target: `native_critical_hit(target_kind, vanilla_critical, critical, damage_multiplier)` may force or deny the critical hit and change its multiplier (1.5 for a vanilla crit, else 1.0); the sweep, sound and particles follow the decided result. Not fired for a dragon part or a hit that does no damage. Bundles need "hooks": 316.
API 317 adds attachment reads that never insert the default: `AttachmentType::has` says whether a holder carries a value, saved or set,
even before it decodes (NeoForge's `hasData`), and `AttachmentType::get_existing` returns `None` instead of the default
(NeoForge's `getExistingData`). Neither adds anything to the holder's saved data. Bundles that use them need `"hooks": 317`.
API 318 adds A player releases a bow: `native_arrow_loose(stack, has_ammo, charge, canceled)` may change held-tick `charge` or cancel the shot; negative charge also prevents it. Unlike `native_living_entity_use_item_stop`, it does not skip the whole item release; `native_entity_shoot_bow` reports the spawned arrow afterward with read-only force. Fires on the Game running the bow release, including LAN guests; a remote client's own release path, crossbows, and ArrowNockEvent are not covered. Bundles need "hooks": 318.
API 319 adds an item frame or painting about to break: `native_hanging_break(player, kind, pos, cancel)` is heard before a server player's hit, a blast or the loss of its wall breaks an item frame, a glow item frame or a painting, with the hitting player (None for a blast or a lost wall). A mod sets `cancel` and it stays. The hitting player's state is swapped in. A hit that only takes a frame's item out, the game's own player and `/kill` are not asked. Bundles that use it need `"hooks": 319`.
API 320 adds A criterion of a server player's advancement was granted or revoked after its progress changed and before any completion rewards, except the `minecraft:tick` criteria granted on a network player's first tick or a reload, which are heard after that tick's rewards and packets; `advancement` is its identifier, such as `minecraft:story/mine_stone`, `criterion` the criterion name and `granted` is false for a revoke. A grant or revoke that changes nothing is not heard. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need "hooks": 320.
API 321 adds A statistic about to be awarded to a server player: `native_player_stat_award(player, name, stat_type, stat, value, cancel)`, with `stat_type` such as `minecraft:mined` or `minecraft:custom`, `stat` such as `minecraft:stone` or `minecraft:walk_one_cm`, and `value` the amount added. Set `cancel` to award nothing: the count and its scoreboard criteria stay as they were. Helpers looking up the guest through `self.lan` may find nothing during packet handling. Bundles need "hooks": 321.
API 322 adds a player's data copied to their new player: `native_player_clone(player, name, was_death)` is heard from `ServerPlayer.restoreFrom` once for each death respawn (`was_death` true) and for each exit from the End after the credits (`was_death` false), after the retained data is copied and before the new player is placed (NeoForge's `PlayerEvent.Clone`). The uuid stays the same, so `player` names both the old and the new player. It cannot change or stop anything; no player's state is swapped in. Bundles that use it need `"hooks": 322`.
API 323 adds a player loaded from the world's save: `native_player_load_from_file(player, name, directory)` is heard once for each join (the owner of an integrated server too), after the player's `.dat` is read and applied and before they are placed in a level (NeoForge's `PlayerEvent.LoadFromFile`, `PlayerList.loadPlayerData`). `directory` is the world's player directory (`players/data`); a mod reads its own file there, named `<uuid>.<suffix>`. It cannot change or stop anything; no player's state is swapped in. Bundles that use it need `"hooks": 323`.
API 324 adds a player saved to the world's store: `native_player_save_to_file(player, name, directory)` is heard once for each save of a placed player (when they leave or the world closes, when the dedicated server saves everyone, and for an integrated server's owner when the world saves them; LAN guests are not heard on integrated autosaves) after their data is written (NeoForge's `PlayerEvent.SaveToFile`, `PlayerList.save`). `directory` is the world's player directory (`players/data`); write your own file there, named `<uuid>.<suffix>`, never the player's `.dat`. The player may be leaving, so no player's state is swapped in. It cannot change or stop anything. Bundles that use it need `"hooks": 324`.
API 325 adds `native_item_expire(entity, stack, extra_life)`, a dropped item reaching its lifetime on the server (NeoForge's `ItemExpireEvent`, vanilla `ItemEntity.tick`): every mod may set or add to `extra_life`, in ticks from zero; a positive total keeps the item that much longer, zero or less lets it expire. Extensions are capped at 32766 ticks. Bundles need "hooks": 325.
API 326 adds the title's own screens to the screen hooks. `NativeScreenView.id` gains one id per title state (`title`, `world_select`, `world_create`, `world_create_flat`, `world_create_buffet`, `world_edit`, `world_backup`, `world_backup_confirm`, `world_optimize_confirm`, `world_optimize`, `low_disk_space`, `world_delete`, `world_recover`, `world_recovered`, `error`, `multiplayer`, `add_server`, `edit_server`, `direct_connect`, `server_delete`, `friends_join`, `disconnected`, `multiplayer_warning`, `onboarding`, `ban_notice`), with no `menu`, no `title` and no `slots`, and the whole window as `rect`. `native_screen_render_post` fires after the title draws its screen (before its toasts), and the `_pre` input hooks — mouse buttons, drag, wheel, key press and release, character typed — fire on it and can cancel it as on any screen, so a mod draws its own button or browser over Create New World and takes the clicks. A dialog, a prompt or a server's pack prompt over the title keeps them away, and typing still goes to the title's own boxes (the key's text stays on the key there, as on Options, so `native_screen_char_typed_pre` does not fire from the keyboard on the title's own screens; the control socket's `type` now types into them too). `native_instance_dir()` is the instance folder, where `options.txt`, `saves/`, `resourcepacks/`, `shaderpacks/` and `horizon-mods/` are; a world's `datapacks/` is in that world's folder. Bundles that use them need `"hooks": 326`.
API 327 adds A player took an anvil's result: `native_anvil_craft_post(player, name, pos, craft)` (NeoForge's `AnvilCraftEvent.Post`) is heard after the levels, the inputs and the anvil wear are done, so a broken anvil is already air. `craft` holds the inputs and the result as they were before. Not heard when `native_anvil_craft_pre` cancelled. Bundles need "hooks": 327.
API 328 adds A player took an anvil's result: `native_anvil_craft_pre(player, name, pos, craft, cancel)` (NeoForge's `AnvilCraftEvent.Pre`) runs before the levels are charged, the inputs used up and the anvil worn. `craft` holds the left and right inputs and the result as they were before. Set `cancel` to refuse the take and skip the matching `native_anvil_craft_post`: the result stays in its slot and the inputs, levels, inventory and cursor are as before the click. Bundles need "hooks": 328.
API 329 adds A server player stopped digging `pos` before the block broke: `native_block_damage_abort(player, name, pos)` is heard after the server clears the dig and sends the block state back to the client. The player's state is not swapped in. Bundles need "hooks": 329.
API 330 adds A mob is about to drop `count` of `item` at `pos`: `native_entity_drop_item(entity, item, count, pos)`. Bundles need "hooks": 330.
API 331 adds A mob's leash was removed, before any lead drop is handled: `native_entity_unleash(entity, kind)`. Bundles need "hooks": 331.
API 332 adds A server explosion is about to begin at `center` with `radius` (`native_explosion_prime`); the hook is listen-only and the explosion proceeds as usual. Bundles need "hooks": 332.
API 333 adds A hopper selects a container while searching for items: `native_hopper_inventory_search(hopper, container, entity)` identifies the hopper and the selected block container or container entity. Bundles need "hooks": 333.
API 334 adds A hopper is about to move one item between containers: `native_inventory_move_item(source, destination, item, cancel)` runs before the source slot is changed, a composter's bone meal included; a chest boat or minecart is reported at the block it is in. Set `cancel` to keep the item in its source slot. Bundles need "hooks": 334.
API 335 adds A dropped item entity reached its 6000-tick lifetime and is about to be removed: `native_item_despawn(entity, item, count, pos)`. Bundles need "hooks": 335.
API 336 adds A boat or minecart joined the level at `pos`, after a player or dispenser placed it: `native_vehicle_create(kind, pos)` is listen-only. Bundles need "hooks": 336.
API 337 adds A player is about to damage a boat or minecart: `native_vehicle_damage(kind, damage, cancel)` may change the damage or cancel it before the vehicle's damage and wobble change; the attack still completes. Bundles need "hooks": 337.
API 338 adds A boat or minecart is about to be destroyed at `pos`: `native_vehicle_destroy(kind, pos)` is heard just before it is removed; TNT minecarts that prime instead are not heard. Bundles need "hooks": 338.
API 339 adds A server player earned one criterion of an advancement, after its progress was recorded: `native_player_advancement_criterion_grant(player, name, advancement, criterion)`. Bundles need "hooks": 339.
API 340 adds A guest's arm swing: `native_player_arm_swing(player, name, hand)` is called after the server handles the guest's `Punch` packet and resets attack strength. The current packet swings `mainhand`; the guest's state is swapped in. Bundles need "hooks": 340.
API 341 adds A server player selected the item at `index` in a bundle in menu `slot`: `native_player_bundle_item_select(player, name, slot, index)`. Bundles need "hooks": 341.
API 342 adds A server player's client finished loading the level around them after joining, respawning or changing dimension: `native_player_client_loaded_world(player, name)`. Bundles need "hooks": 342.
API 343 adds A guest's player input flags: `native_player_input(player, name, flags)` is called after a loaded guest's `PlayerInput` packet is applied to the hub. `flags` is the packet's bit field; the guest's state is swapped in. Bundles need "hooks": 343.
API 344 adds A guest jumped: `native_player_jump(player, name)` is called after accepted movement made the server player jump from the ground, once the guest's movement simulation finished. The guest's state is swapped in. Bundles need "hooks": 344.
API 345 adds A guest was kicked from the server: `native_player_kicked(player, name, reason)` is called after the hub records the rendered reason and sends the disconnect. The guest's state is swapped in. Bundles need "hooks": 345.
API 346 adds A LAN guest launched a snowball, egg, ender pearl, potion or trident: `native_player_launch_projectile(player, name, item)` runs after the projectile is created. Bundles need "hooks": 346.
API 347 adds A server player's experience level changed: `native_player_level_change(player, name, from, to)` is called after experience points or levels change their level, including orb points. The player's state is swapped in when they are a guest. Bundles need "hooks": 347.
API 348 adds A server player released a drawn bow with `native_player_ready_arrow(player, name, arrow)`: the name of the selected projectile item. Bundles need "hooks": 348.
API 349 adds A server player's recipe book open or filter setting changed: `native_player_recipe_book_settings_change(player, name, book, open, filtering)`, where `book` is its protocol id. Bundles need "hooks": 349.
API 350 adds A server player released the use-item key before the item finished being used: `native_player_stop_using_item(player, name)`. Bundles need "hooks": 350.
API 351 adds A guest's player input changed sneaking: `native_player_toggle_sneak(player, name, sneaking)` is called after the hub applies the new state, only when it changes. The guest's state is swapped in. Bundles need "hooks": 351.
API 352 adds A hopper begins a transfer attempt: `native_prepare_hopper_transfer(pos)` runs after its cooldown and enabled checks, before it pushes or pulls items. Bundles need "hooks": 352.
API 353 adds A vault is about to eject a loot item: `native_vault_loot(pos, item)` supplies the vault position and item identifier before it spawns. Bundles need "hooks": 353.
API 354 adds A timed world border size change finished: `native_world_border_bounds_change_finish()` fires after the border reaches its final size. Bundles need "hooks": 354.
API 355 adds `Game::native_mod_image(width, height, rgba)`: a mod's own RGBA image, uploaded into the HUD atlas slot `horizon/modimage` (1024 by 1024) and drawn with `native_sprite`, whose source rectangle crops and stretches it, so a map or a schematic view needs no texture of its own. The slot is reserved only while a native mod is loaded, so a game without mods pays none of its 4 MiB. False for an image that is not `width * height` pixels of four bytes, is larger than the slot, or when there is no slot. Each upload is a texture patch of the image, so send one when the image changed, not every frame.
API 356 adds a living entity drowning (`native_living_drown`), with changeable drowning and damage, a read-only bubble count and cancellation. Bundles need "hooks": 356.
API 357 adds a living entity's fall (`native_living_fall`), with changeable distance and multiplier and cancellation. Bundles need "hooks": 357.
API 358 adds a living entity healing (`native_living_heal`), with a changeable amount and cancellation. Bundles need "hooks": 358.
API 359 adds a living entity jumping (`native_living_jump`). Bundles need "hooks": 359.
## What the launcher does
@@ -1448,14 +1880,14 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
`pearl` is the pearl's entity id, `from` their feet, `target` where the pearl started its last tick and `damage` the
fall damage they then take (5). A mod may move `target` or change `damage` (0 or less: none). Canceling keeps the
player where they are: no endermite roll, no teleport, no damage, no sound; the pearl is used up either way. Mobs'
pearls are not heard. Bundles need `"hooks": @API:ender_pearl_teleport@`.
pearls are not heard. Bundles need `"hooks": 312`.
- **`native_chorus_fruit_teleport(player, name, from, target, cancel)`**: a server player whose `teleport_randomly`
consume effect (chorus fruit) is about to try a spot (NeoForge's `EntityTeleportEvent.ChorusFruit`,
`TeleportRandomlyConsumeEffect.apply`): once per attempt, up to 16, after they stopped riding. A mod may move
`target` (its y is kept inside the level's height); the game still looks down from it for ground to land on, inside
the border. Canceling ends the effect: no
teleport and no more attempts. Foxes eating chorus fruit are not heard. Bundles need
`"hooks": @API:chorus_fruit_teleport@`.
`"hooks": 311`.
- **`native_player_clone(player, name, was_death)`**: a player's data was copied to their new player (NeoForge's
`PlayerEvent.Clone`, from `ServerPlayer.restoreFrom`): after a death respawn (`was_death` true) and when a player
leaves the End after the credits (`was_death` false, vanilla's `keepAllPlayerData`). It is heard after the copy and
@@ -1624,12 +2056,12 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
set another to wake everyone then. A time before `current` is refused and the morning stays; the check runs once after
every mod, so a later mod sees the value an earlier one set. It runs before `native_time_skip`, which hears the jump
to the time chosen here and may still cancel it; the players wake either way. Bundles need
`"hooks": @API:sleep_finished_time@`.
- **`native_world_border_bounds_changed()`**: `/worldborder set` or `/worldborder add` changed the border size or started a timed size change. Bundles need `"hooks": @API:native_world_border_bounds_changed@`.
- **`native_world_border_center_changed()`**: `/worldborder center` moved the border center. It hears successful commands only. Bundles need `"hooks": @API:native_world_border_center_changed@`.
- **`native_world_game_rule_change(player, name, rule, value)`**: an operator changed a game rule through the server game rules screen; it is heard after the final value is applied. Bundles need `"hooks": @API:native_world_game_rule_change@`.
- **`native_spawn_change(pos, angle)`**: `/setworldspawn` moved the world's spawn point, after its position and rotation are applied. Bundles need `"hooks": @API:native_spawn_change@`.
- **`native_portal_create(dimension, pos)`**: a Nether portal built for dimension travel or the End arrival platform was completed in `dimension`; `pos` is its lower corner or platform origin. Bundles need `"hooks": @API:portal_create@`.
`"hooks": 308`.
- **`native_world_border_bounds_changed()`**: `/worldborder set` or `/worldborder add` changed the border size or started a timed size change. Bundles need `"hooks": 245`.
- **`native_world_border_center_changed()`**: `/worldborder center` moved the border center. It hears successful commands only. Bundles need `"hooks": 246`.
- **`native_world_game_rule_change(player, name, rule, value)`**: an operator changed a game rule through the server game rules screen; it is heard after the final value is applied. Bundles need `"hooks": 247`.
- **`native_spawn_change(pos, angle)`**: `/setworldspawn` moved the world's spawn point, after its position and rotation are applied. Bundles need `"hooks": 244`.
- **`native_portal_create(dimension, pos)`**: a Nether portal built for dimension travel or the End arrival platform was completed in `dimension`; `pos` is its lower corner or platform origin. Bundles need `"hooks": 286`.
- **`native_raid_spawn_wave(raid, pos, wave, raiders)`**: a raid wave is about to be spawned at `pos`; `raiders` is the count Horizon will spawn.
- **`native_raid_state_change(raid, pos, state)`**: a raid at `pos` was triggered or changed to `victory`, `loss`, or `stopped`.
- **`native_world_saved(wait)`**: the server completed its world save cycle; `wait` is true when chunk writes were flushed before returning.
@@ -2413,10 +2845,11 @@ before it decodes, and `get_existing` returns `None` instead of the default. Nei
adds anything to the holder's saved data. `set` runs the optional codec before
changing the value, returning an `AttachmentError` if it cannot encode.
Keys are the pair `(namespace, name)`; a key already decoded as another Rust type is
an error. A missing or non-server holder is an error, not a new phantom holder.
an error. On a server, a missing holder is an error, not a new phantom holder; a client
keeps a copy for any holder it is asked about (see the sync paragraph below).
`AttachmentError` distinguishes `MissingHolder`, `TypeMismatch`, `Decode`, `Encode`,
`InvalidNamespace` (a malformed saved compound) and `InvalidCopyPolicy` (death-copy
without a codec).
`InvalidNamespace` (a malformed saved compound), `InvalidCopyPolicy` (death-copy
without a codec) and `InvalidSyncPolicy` (sync without a codec).
```rust
use horizon_client::mod_api::{AttachmentCodec, AttachmentError, AttachmentType, Compound, Holder, Tag};
@@ -2430,6 +2863,7 @@ static COUNTER: AttachmentType<i32> = AttachmentType {
},
}),
copy_on_death: false,
sync: false,
};
// Inside a server hook, or mixin code, with `host: &mut dyn Host`:
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
@@ -2446,20 +2880,40 @@ entities, boats, minecarts, armor stands, cushions, item frames, paintings and t
server's `OtherEntity` records; projectiles, orbs and other ID-less save paths are
not covered. Player UUIDs cover online guests and the acting player. Mutable level
hosts can access their level, loaded chunks and block entities; read-only level hosts
cannot access attachments. These maps live on the server only: there is **no client
synchronization**. Unserialized values disappear on unload or restart. A saved value
is stored under `horizon:attachments`, then the mod namespace and attachment name,
as `{value: <compound>, copy_on_death: 1b}` (the flag is omitted when false).
cannot access attachments. Unserialized values disappear on unload or restart. A saved
value is stored under `horizon:attachments`, then the mod namespace and attachment name,
as `{value: <compound>, copy_on_death: 1b, sync: 1b}` (a flag is omitted when false).
Unknown mods' saved entries survive load and save without being decoded. Level
attachments use the dimension's `data/minecraft/horizon_attachments.dat` SavedData
file, not `level.dat`.
A type with `sync: true` sends its value to Horizon clients when `get` creates the
default, on `set` and `remove`, and on demand with `AttachmentType::sync_data(host,
holder)`; changes made in one tick are sent once, at its end. The recipients are the
players who track the holder: guests who were sent a chunk for a chunk or block entity,
who track an entity for an entity, the player and those tracking them for a player, and
everyone in the level for the level. A client also gets all of a holder's synced values
when it starts seeing it: a chunk sent (with its block entities), a level joined or
re-entered, an entity tracked, and its own player. With no native
mod loaded nothing is sent. The wire form is the codec's compound, so `sync` requires
a codec.
Only Horizon clients are sent values. A message with a namespace or name over 256
characters, a value that cannot be written or a payload past 1 MiB is not sent.
On a client, `Host::attachments` returns a copy of what the server synced, which `get`
decodes; `set` and `remove` change only that copy, which is dropped when the chunk,
entity or player goes. A client keeps no copy for the level hosts (`LevelRandomHost`):
they stay server-only there, though their changes on a server are synced.
`copy_on_death` requires a codec. Player respawn copies opted-in entries via their
encoded representation, including raw entries of an absent mod; other entries are
removed. Living-entity conversion is not supported. This follows the default supplier,
persistence and respawn option of NeoForge 26.3's public `AttachmentType.Builder`
contract and `IAttachmentHolder`'s `hasData`/`getExistingData`, but not its
holder-aware supplier, custom copy handler, conditional serialization or sync APIs.
optional server-side persistence, player-respawn option and `sync` with a `StreamCodec`
(with `syncData`) of NeoForge 26.3's public `AttachmentType.Builder` contract, and
`IAttachmentHolder`'s `hasData`/`getExistingData`, but not its holder-aware supplier,
custom copy handler, conditional serialization, the `sendToPlayer` predicate,
`AttachmentSyncHandler` partial updates or a wire codec apart from the disk codec.
`Host` exposes the documented `Game` helpers listed under `helpers` in
`native-hooks.json`, without their `native_` prefix. Each keeps the helper's
+3 -3
View File
@@ -518,9 +518,9 @@ runtime or new dependency. Server-sent events can replace the polling later if a
so a change by the console, a command in the game or the protocol itself is told alike (`clear` once per
entry); the event stream carries them as `operators_added`, `bans_removed`... too;
- game rules: `gamerules` lists every rule the game has as a typed rule (`{key, type, value}`), `gamerules/update`
sets one (checked against its type and bounds, logged and set only when the value changes) and
`notification/gamerules/updated` follows every change, by that method or by `/gamerule`
(`Game::set_game_rule`);
sets one (checked against its type and bounds with vanilla's error texts, and set, logged and told on every
update, as vanilla's does) and `notification/gamerules/updated` follows every change, by that method or by
`/gamerule` (`Game::set_game_rule`); the parity script compares both servers on all of it;
- `server/activity`: a login attempt (the game records `login_activity` in its login gate, as
`ServerActivityMonitor.reportLoginActivity` is called when a login starts), told at most once in thirty
seconds and lost otherwise, as the monitor clears its flag whatever it did; and `server/status`, the
+2120
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -349,6 +349,7 @@ class StressSceneTests(unittest.TestCase):
self.assertEqual(sum(summons("stress_mobs", k) for k in stress_scenes.MOBS), 1000)
self.assertEqual(summons("stress_items", "item"), 2000)
self.assertEqual(sum(summons("stress_held", k) for k in stress_scenes.HOLDERS), 1000)
self.assertEqual(sum(summons("stress_eyes", k) for k in stress_scenes.EYED), 1000)
self.assertEqual(summons("stress_frames", "item_frame"), 512)
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "anvil_damaged",
"target": "horizon_client::game::native_hooks::Game<'_>::native_anvil_damaged",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "An anvil takes damage (`native_anvil_damaged`).",
"doc_summary": "`native_anvil_damaged(dimension, pos, from, to)` runs after an anvil at `pos` changes from `from` to `to`; `to` is air when it broke. Bundles need \"hooks\": @API:anvil_damaged@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "arrow_loose",
"target": "horizon_client::game::native_hooks::Game<'_>::native_arrow_loose",
"neoforge": "ArrowLooseEvent",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "canceled"
}
}
],
"rust_summary": "A player releases a bow with adjustable charge or cancellation (`native_arrow_loose`).",
"doc_summary": "A player releases a bow: `native_arrow_loose(stack, has_ammo, charge, canceled)` may change held-tick `charge` or cancel the shot; negative charge also prevents it. Unlike `native_living_entity_use_item_stop`, it does not skip the whole item release; `native_entity_shoot_bow` reports the spawned arrow afterward with read-only force. Fires on the Game running the bow release, including LAN guests; a remote client's own release path, crossbows, and ArrowNockEvent are not covered. Bundles need \"hooks\": @API:arrow_loose@."
}
@@ -1,8 +0,0 @@
{
"hooks": [],
"rust_summary": "attachment reads that never insert the default (`AttachmentType::has`, `AttachmentType::get_existing`).",
"doc_summary": "attachment reads that never insert the default: `AttachmentType::has` says whether a holder carries a value, saved or set,\neven before it decodes (NeoForge's `hasData`), and `AttachmentType::get_existing` returns `None` instead of the default\n(NeoForge's `getExistingData`). Neither adds anything to the holder's saved data. Bundles that use them need `\"hooks\": @API:attachment_existing@`.",
"comments": [
"crates/horizon-world/src/attachments.rs"
]
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "beacon_state_change",
"target": "horizon_client::game::native_hooks::Game<'_>::native_beacon_state_change",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A beacon activates or deactivates (`native_beacon_state_change`).",
"doc_summary": "`native_beacon_state_change(dimension, pos, active)` is called after the beacon at `pos` gains or loses its complete beam pyramid; `active` says whether it is active now. Bundles need \"hooks\": @API:beacon_state_change@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "bell_ring",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_bell_ring",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event; Bukkit's BellRingEvent)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "Bell about to ring (`native_bell_ring`).",
"doc_summary": "A bell is about to ring: `native_bell_ring(dimension, player, pos, cancel)` is asked before the bell event and sound. Set `cancel` to stop the ring and its raider reveal. `player` is the acting server player when available. Bundles need \"hooks\": @API:bell_ring@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_break_progress_update",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_break_progress_update",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A guest's block-breaking progress changes (`native_block_break_progress_update`).",
"doc_summary": "`native_block_break_progress_update(player, name, pos, progress)` is called when a guest's crack animation at `pos` reaches a new stage from 0 through 9. Bundles need \"hooks\": @API:block_break_progress_update@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "block_burn",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_block_burn",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event; Bukkit's BlockBurnEvent)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "Fire about to burn a block away (`native_block_burn`).",
"doc_summary": "Fire about to burn a block: `native_block_burn(dimension, pos, cancel)` is heard when fire's burnout check is about to remove the block at `pos`. Set `cancel` to keep it. Bundles need \"hooks\": @API:block_burn@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_cook",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_cook",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A block finishes cooking an item (`native_block_cook`).",
"doc_summary": "A campfire finishes cooking `item` into `result` at `pos`, before dropping the result. Bundles need \"hooks\": @API:block_cook@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_damage_abort",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_damage_abort",
"side": "server",
"neoforge": "none (NeoForge has no such event; Bukkit's BlockDamageAbortEvent)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A server player stopped digging before the block broke (`native_block_damage_abort`).",
"doc_summary": "A server player stopped digging `pos` before the block broke: `native_block_damage_abort(player, name, pos)` is heard after the server clears the dig and sends the block state back to the client. The player's state is not swapped in. Bundles need \"hooks\": @API:block_damage_abort@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_dispense_armor",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_dispense_armor",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A dispenser equips armor or equipment (`native_block_dispense_armor`).",
"doc_summary": "A dispenser equips `item` on the first eligible player, mob or armor stand in front of its block at `pos`, after the item is consumed and the equip sound and game event are applied. `target` identifies the kind of entity equipped. Bundles need \"hooks\": @API:block_dispense_armor@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_exp",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_block_exp",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A block is about to drop experience (`native_block_exp`).",
"doc_summary": "A block with a vanilla experience drop is about to award its points at `pos`; `block` is its identifier. Change `experience` to adjust the amount before the orbs are awarded. Bundles need \"hooks\": @API:block_exp@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "block_fade",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_block_fade",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A block fades (`native_block_fade`).",
"doc_summary": "`native_block_fade(dimension, pos, from, to, cancel)` runs when ice or snow melts, or coral dies. `from` and `to` are block state IDs. Set `cancel` to keep the old block. Bundles need \"hooks\": @API:block_fade@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_failed_dispense",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_failed_dispense",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A dispenser fails to dispense an item (`native_block_failed_dispense`).",
"doc_summary": "`native_block_failed_dispense(dimension, pos, item)` is called when a dispenser at `pos` fails to dispense `item`. Bundles need \"hooks\": @API:block_failed_dispense@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "block_form",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_block_form",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "Precipitation forms ice or snow (`native_block_form`).",
"doc_summary": "`native_block_form(dimension, pos, from, to, cancel)` runs when precipitation is about to place ice or snow, or add a snow layer at `pos`. `from` and `to` are block state IDs. Set `cancel` to keep the old state. Bundles need \"hooks\": @API:block_form@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "block_ignite",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_block_ignite",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event; Bukkit's BlockIgniteEvent)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "Fire about to be placed (`native_block_ignite`).",
"doc_summary": "Fire about to be placed: `native_block_ignite(dimension, player, pos, cause, cancel)` is heard for fire spread, lava, lightning and dispenser flint-and-steel. Set `cancel` to leave the position unchanged. Direct player flint-and-steel/fire-charge placement is not covered. Bundles need \"hooks\": @API:block_ignite@."
}
@@ -1,16 +0,0 @@
{
"hooks": [
{
"name": "block_lock_check",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_lock_check",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "cancel",
"out": "locked"
}
}
],
"rust_summary": "A guest checks a block container lock (`native_block_lock_check`).",
"doc_summary": "`native_block_lock_check(player, name, pos, locked)` runs when a guest checks the container at `pos`; `locked` starts with vanilla's result and may be changed. The guest's state is not swapped in. Bundles need \"hooks\": @API:block_lock_check@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "block_receive_game",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_receive_game",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A sculk listener receives a game event (`native_block_receive_game`).",
"doc_summary": "A sculk sensor, calibrated sensor or shrieker received vibration event `event` at `pos`; `block` is its identifier. Set `cancel` to suppress the block's response. Bundles need \"hooks\": @API:block_receive_game@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "block_shear_entity",
"target": "horizon_client::game::native_hooks::Game<'_>::native_block_shear_entity",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A dispenser shears an entity (`native_block_shear_entity`).",
"doc_summary": "A dispenser sheared entity `entity` of kind `kind` at the block in front of the dispenser at `pos`, after the shearing effects. Bundles need \"hooks\": @API:block_shear_entity@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "brewing_complete",
"target": "horizon_client::game::native_hooks::Game<'_>::native_brewing_complete",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "Brewing stand finished brewing (`native_brewing_complete`).",
"doc_summary": "A brewing stand finished brewing: `native_brewing_complete(pos)` is heard after the bottles at `pos` received their brewed contents. Server-side. Bundles need \"hooks\": @API:brewing_complete@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "brewing_fuel",
"target": "horizon_client::game::native_hooks::Game<'_>::native_brewing_fuel",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "Brewing stand consumed fuel (`native_brewing_fuel`).",
"doc_summary": "A brewing stand consumed fuel: `native_brewing_fuel(pos, fuel)` is heard after the stand at `pos` consumed the item `fuel` and gained brewing fuel. Server-side. Bundles need \"hooks\": @API:brewing_fuel@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "brewing_start",
"target": "horizon_client::game::native_hooks::Game<'_>::native_brewing_start",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A brewing stand starts a brew (`native_brewing_start`).",
"doc_summary": "A brewing stand starts brewing after it consumes fuel and sets its timer. Bundles need \"hooks\": @API:brewing_start@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "broadcast_message",
"target": "horizon_client::game::native_hooks::Game<'_>::native_broadcast_message",
"neoforge": "none (NeoForge has no such event; Bukkit's BroadcastMessageEvent)",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "A server system broadcast line, rewritable or cancellable (`native_broadcast_message`).",
"doc_summary": "A system broadcast line is about to be logged and sent to every player: `native_broadcast_message(line, cancel)` lets a mod rewrite it or set `cancel` to drop it. Bundles need \"hooks\": @API:broadcast_message@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "campfire_start",
"target": "horizon_client::game::native_hooks::Game<'_>::native_campfire_start",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "An item starts cooking on a campfire (`native_campfire_start`).",
"doc_summary": "An item was placed on a campfire and began cooking. Bundles need \"hooks\": @API:campfire_start@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "cauldron_level_changed",
"target": "horizon_client::game::native_hooks::GameLevel<'_>::native_cauldron_level_changed",
"side": "server",
"cancellable": true,
"neoforge": "none (NeoForge has no such event; Bukkit's CauldronLevelChangeEvent)",
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "Cauldron fill level about to change (`native_cauldron_level_changed`).",
"doc_summary": "A cauldron's fill level is about to change: `native_cauldron_level_changed(dimension, player, pos, from_level, to_level, reason, cancel)` is asked for rain, snow, dripstone and extinguishing. Set `cancel` to leave it as it was. `from_level` and `to_level` use 0 for empty and 1 to 3 for filled levels. `player` is the acting server player when available. Bucket, bottle and dyeing interactions are not covered yet. Bundles need \"hooks\": @API:cauldron_level_changed@."
}
@@ -1,17 +0,0 @@
{
"hooks": [
{
"name": "chorus_fruit_teleport",
"target": "horizon_client::game::native_hooks::Game<'_>::native_chorus_fruit_teleport",
"neoforge": "EntityTeleportEvent.ChorusFruit",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a server player who ate chorus fruit is about to try a random spot (`native_chorus_fruit_teleport`).",
"doc_summary": "a server player's `teleport_randomly` consume effect (chorus fruit) is about to try a spot: `native_chorus_fruit_teleport(player, name, from, target, cancel)`, once per attempt (up to 16). A mod may move `target`; the game still looks down from it for ground to land on. `cancel` ends the effect with no teleport and no more attempts. Foxes are not heard. Bundles need \"hooks\": @API:chorus_fruit_teleport@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "compost_item",
"target": "horizon_client::game::native_hooks::Game<'_>::native_compost_item",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player composts an item (`native_compost_item`).",
"doc_summary": "A player adds a compostable item to a composter at `pos`; the layer has been applied. Bundles need \"hooks\": @API:compost_item@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "crafter_craft",
"target": "horizon_client::game::native_hooks::Game<'_>::native_crafter_craft",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A crafter completes a recipe (`native_crafter_craft`).",
"doc_summary": "A crafter at `pos` completed `recipe`; item consumption and output handling have finished. Bundles need \"hooks\": @API:crafter_craft@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "creeper_ignite",
"target": "horizon_client::game::native_hooks::Game<'_>::native_creeper_ignite",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player ignited a creeper (`native_creeper_ignite`).",
"doc_summary": "A player used an item in `minecraft:creeper_igniters` on a creeper, after it entered its ignited state. Bundles need \"hooks\": @API:creeper_ignite@."
}
@@ -1,15 +0,0 @@
{
"hooks": [
{
"name": "creeper_power",
"target": "horizon_client::game::native_hooks::Game<'_>::native_creeper_power",
"side": "server",
"neoforge": "none (NeoForge has no such event)",
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "Lightning charged a creeper (`native_creeper_power`).",
"doc_summary": "Lightning struck a creeper and set its charged state; the hook reports the creeper id and strike position. Bundles need \"hooks\": @API:creeper_power@."
}
@@ -1,16 +0,0 @@
{
"hooks": [
{
"name": "critical_hit",
"target": "horizon_client::game::native_hooks::Game<'_>::native_critical_hit",
"neoforge": "CriticalHitEvent",
"side": "both",
"cancellable": false,
"mod_api": {
"contract": "listen"
}
}
],
"rust_summary": "A player's melee hit on a living target decides whether it is critical (`native_critical_hit`).",
"doc_summary": "A player's damaging melee hit on a living target: `native_critical_hit(target_kind, vanilla_critical, critical, damage_multiplier)` may force or deny the critical hit and change its multiplier (1.5 for a vanilla crit, else 1.0); the sweep, sound and particles follow the decided result. Not fired for a dragon part or a hit that does no damage. Bundles need \"hooks\": @API:critical_hit@."
}
Loaded 100 of 290 files, more files were not shown because too many files have changed in this diff. Show more