Merge main: the mod API placeholder test is fixed there

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
NazaandClaude Opus 5.5 committed 2026-11-02 12:44:49 -03:00
commit be3977105b
405 files changed
+12693 -4613

No files matched your search

+3 -2
View File
@@ -96,8 +96,9 @@ jobs:
run: |
cargo +stable test --workspace --lib --bins --tests
# The full content-root tests (#[ignore]d: minutes each on a laptop). The same build as above; they need the
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
# prepared vanilla data, so without it they pass at once. Keep the target selection identical to avoid
# recompiling the workspace with a different dependency graph.
cargo +stable test --workspace --lib --bins --tests mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
+2 -1
View File
@@ -96,7 +96,8 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
@@ -688,4 +688,15 @@ mod tests {
let (v, _) = parse_snbt("{n:1.5}").unwrap();
assert_eq!(v["n"], Value::from(1.5));
}
#[test]
fn name_text_preserves_legacy_and_decodes_components() {
let legacy = Tag::String("§6Golden Name".into());
let text = name_text(&legacy);
assert_eq!(text.plain(), "Golden Name");
let comp = Tag::compound([("text", Tag::String("Custom Name".into()))]);
let text = name_text(&comp);
assert_eq!(text.plain(), "Custom Name");
}
}
@@ -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();
+199 -81
View File
@@ -62,18 +62,65 @@ pub fn radius_cells(cloud_range_chunks: i32) -> i32 {
((cloud_range_chunks * 16) as f32 / 12.0).ceil() as i32
}
#[derive(Clone)]
pub struct CellIter {
radius: i32,
ring: i32,
x: i32,
pending_z: Option<i32>,
}
impl CellIter {
pub fn new(radius: i32) -> Self {
Self {
radius,
ring: 0,
x: 0,
pending_z: None,
}
}
}
impl Iterator for CellIter {
type Item = (i32, i32);
#[inline]
fn next(&mut self) -> Option<Self::Item> {
if let Some(z) = self.pending_z.take() {
let x = self.x;
self.x += 1;
return Some((x, z));
}
let r2 = self.radius * self.radius;
while self.ring <= 2 * self.radius {
let min_abs = self.ring - self.radius;
while self.x <= self.ring {
if min_abs > 0 && self.x > -min_abs && self.x < min_abs {
self.x = min_abs;
}
let x = self.x;
let z = self.ring - x.abs();
if z <= self.radius && x * x + z * z <= r2 {
if z != 0 {
self.pending_z = Some(z);
return Some((x, -z));
} else {
self.x += 1;
return Some((x, 0));
}
}
self.x += 1;
}
self.ring += 1;
self.x = -self.ring;
}
None
}
}
/// CloudRenderer builds cells in expanding Manhattan rings, nearest first.
pub fn cells(radius: i32) -> impl Iterator<Item = (i32, i32)> {
(0..=2 * radius).flat_map(move |ring| {
(-ring..=ring).flat_map(move |x| {
let z = ring - x.abs();
let visible = z <= radius && x * x + z * z <= radius * radius;
(visible && z != 0)
.then_some((x, -z))
.into_iter()
.chain(visible.then_some((x, z)))
})
})
pub fn cells(radius: i32) -> CellIter {
CellIter::new(radius)
}
/// Per-frame cloud inputs. Resources are immutable for the renderer's lifetime:
@@ -107,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);
@@ -150,9 +203,121 @@ impl Input<'_> {
#[derive(Default)]
pub(crate) struct Mesh {
key: Option<MeshKey>,
cells: Vec<(i32, i32)>,
cached_radius: Option<i32>,
pub vertices: Vec<crate::mesher::Vertex>,
}
#[inline(always)]
fn push_quad(
vertices: &mut Vec<crate::mesher::Vertex>,
p: [[f32; 3]; 4],
shade: u32,
normal: [i8; 4],
uv: [f32; 2],
) {
for pos in p {
vertices.push(crate::mesher::Vertex {
normal,
pos,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
}
#[inline(always)]
fn emit_face(
vertices: &mut Vec<crate::mesher::Vertex>,
direction: usize,
inside: bool,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
const Y0: f32 = 0.0;
const Y1: f32 = 4.0;
let (p, shade, normal) = match direction {
0 => (
[[x1, Y0, z0], [x1, Y0, z1], [x0, Y0, z1], [x0, Y0, z0]],
1,
[0, -127, 0, 0],
),
1 => (
[[x0, Y1, z0], [x0, Y1, z1], [x1, Y1, z1], [x1, Y1, z0]],
0,
[0, 127, 0, 0],
),
2 => (
[[x0, Y0, z0], [x0, Y1, z0], [x1, Y1, z0], [x1, Y0, z0]],
2,
[0, 0, -127, 0],
),
3 => (
[[x1, Y0, z1], [x1, Y1, z1], [x0, Y1, z1], [x0, Y0, z1]],
2,
[0, 0, 127, 0],
),
4 => (
[[x0, Y0, z1], [x0, Y1, z1], [x0, Y1, z0], [x0, Y0, z0]],
3,
[-127, 0, 0, 0],
),
_ => (
[[x1, Y0, z0], [x1, Y1, z0], [x1, Y1, z1], [x1, Y0, z1]],
3,
[127, 0, 0, 0],
),
};
if inside {
push_quad(
vertices,
[p[3], p[2], p[1], p[0]],
shade,
normal.map(|c| -c),
uv,
);
} else {
push_quad(vertices, p, shade, normal, uv);
}
}
#[inline(always)]
fn emit_faces(
vertices: &mut Vec<crate::mesher::Vertex>,
mask: u16,
x0: f32,
z0: f32,
uv: [f32; 2],
) {
for d in [1, 0, 2, 3, 4, 5] {
if mask & (1 << d) != 0 {
emit_face(vertices, d, false, x0, z0, uv);
}
}
if mask >> 6 != 0 {
for d in 0..6 {
if mask & (1 << (d + 6)) != 0 {
emit_face(vertices, d, true, x0, z0, uv);
}
}
}
}
#[inline(always)]
fn emit_flat_cell(vertices: &mut Vec<crate::mesher::Vertex>, x0: f32, z0: f32, uv: [f32; 2]) {
let x1 = x0 + 12.0;
let z1 = z0 + 12.0;
let p = [[x1, 0.0, z0], [x1, 0.0, z1], [x0, 0.0, z1], [x0, 0.0, z0]];
push_quad(vertices, p, 0, [0; 4], uv);
push_quad(vertices, [p[3], p[2], p[1], p[0]], 0, [0; 4], uv);
}
impl Mesh {
/// Returns true only when vertices must be uploaded. Sub-cell motion and
/// color changes update only the uniform, as in CloudRenderer.prepare.
@@ -161,80 +326,39 @@ impl Mesh {
if self.key.as_ref() == Some(&key) {
return (false, uniform);
}
if self.cached_radius != Some(key.radius) {
self.cells = self::cells(key.radius).collect();
self.cached_radius = Some(key.radius);
}
self.vertices.clear();
let (w, h, cells) = input.texture;
let (w, h) = (i64::from(*w), i64::from(*h));
let (cell_x, cell_z, radius) = (key.cell_x, key.cell_z, key.radius);
let (w, h, tex_cells) = input.texture;
let (cell_x, cell_z) = (key.cell_x, key.cell_z);
let bottom = uniform.offset[1];
let uv = input.uv;
let filled = |cx: i64, cz: i64| cells[(cz.rem_euclid(h) * w + cx.rem_euclid(w)) as usize];
for (dx, dz) in self::cells(radius) {
let (dx, dz) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx, cell_z + dz);
let (w_i64, h_i64) = (i64::from(*w), i64::from(*h));
let filled = |cx: i64, cz: i64| {
tex_cells[(cz.rem_euclid(h_i64) * w_i64 + cx.rem_euclid(w_i64)) as usize]
};
for &(dx, dz) in &self.cells {
let (dx_i64, dz_i64) = (i64::from(dx), i64::from(dz));
let (cx, cz) = (cell_x + dx_i64, cell_z + dz_i64);
if !filled(cx, cz) {
continue;
}
let x0 = dx as f32 * 12.0;
let z0 = dz as f32 * 12.0;
let (x1, z1, y0, y1) = (x0 + 12.0, z0 + 12.0, 0.0, 4.0);
// The face's normal is for shader packs (`gl_Normal`); the game's clouds shade by
// `shade`.
let mut quad = |p: [[f32; 3]; 4], shade: u32, normal: [i8; 4]| {
for v in p {
// Overlay 4: clouds (unlit, faded by the cloud fog end).
self.vertices.push(crate::mesher::Vertex {
normal,
pos: v,
uv,
layer: 4,
color: u32::MAX,
light: shade,
glint_uv: [0.0, 0.0],
});
}
};
if input.fast {
for p in flat_cell(x0, z0, 0.0) {
quad(p, 0, [0; 4]);
}
emit_flat_cell(&mut self.vertices, x0, z0, uv);
continue;
}
let quads = [
(
[[x1, y0, z0], [x1, y0, z1], [x0, y0, z1], [x0, y0, z0]],
1,
[0, -127, 0, 0],
),
(
[[x0, y1, z0], [x0, y1, z1], [x1, y1, z1], [x1, y1, z0]],
0,
[0, 127, 0, 0],
),
(
[[x0, y0, z0], [x0, y1, z0], [x1, y1, z0], [x1, y0, z0]],
2,
[0, 0, -127, 0],
),
(
[[x1, y0, z1], [x1, y1, z1], [x0, y1, z1], [x0, y0, z1]],
2,
[0, 0, 127, 0],
),
(
[[x0, y0, z1], [x0, y1, z1], [x0, y1, z0], [x0, y0, z0]],
3,
[-127, 0, 0, 0],
),
(
[[x1, y0, z0], [x1, y1, z0], [x1, y1, z1], [x1, y0, z1]],
3,
[127, 0, 0, 0],
),
];
let mask = face_mask(
dx as i32,
dz as i32,
dx,
dz,
bottom,
[
!filled(cx, cz - 1),
@@ -243,15 +367,9 @@ impl Mesh {
!filled(cx + 1, cz),
],
);
for (direction, inside) in faces(mask) {
let (p, shade, n) = quads[usize::from(direction)];
if inside {
quad([p[3], p[2], p[1], p[0]], shade, n.map(|c| -c));
} else {
quad(p, shade, n);
}
}
emit_faces(&mut self.vertices, mask, x0, z0, uv);
}
self.key = Some(key);
(true, uniform)
}
+93 -6
View File
@@ -20,6 +20,53 @@ thread_local! {
static CONTAINED: Cell<u32> = const { Cell::new(0) };
/// The message of the last panic `contain` caught on this thread, from the hook.
static CAUGHT: RefCell<Option<String>> = const { RefCell::new(None) };
/// The native mod hook this thread is inside, for the panic hook to name.
static IN_NATIVE_MOD: Cell<Option<NativeModCall>> = const { Cell::new(None) };
}
/// A call into one hook of a native mod.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeModCall {
/// The mod's `[mod] id`, None in a build that didn't name its mods.
pub id: Option<&'static str>,
/// Its place in dispatch order, which names it then.
pub index: usize,
pub hook: &'static str,
}
/// Marks this thread as inside `call` until the guard drops, which also happens while a panic
/// unwinds. A hook called from a hook is named by the inner call.
pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
NativeModGuard {
outer: IN_NATIVE_MOD.with(|c| c.replace(Some(call))),
}
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
outer: Option<NativeModCall>,
}
impl Drop for NativeModGuard {
fn drop(&mut self) {
IN_NATIVE_MOD.with(|c| c.set(self.outer));
}
}
/// `message`, with the native mod hook this thread is inside, if any.
fn attributed(message: String) -> String {
match IN_NATIVE_MOD.with(Cell::get) {
Some(NativeModCall {
id: Some(id), hook, ..
}) => format!("{message}\nin native mod {id}, hook {hook}"),
Some(NativeModCall {
id: None,
index,
hook,
}) => format!("{message}\nin native mod #{index}, hook {hook}"),
None => message,
}
}
/// Runs `f`, and returns a panic in it as an error with the panic's message instead of letting
@@ -418,10 +465,10 @@ fn payload_text(payload: &(dyn std::any::Any + Send)) -> String {
fn panic_message(info: &std::panic::PanicHookInfo) -> String {
let text = payload_text(info.payload());
match info.location() {
attributed(match info.location() {
Some(at) => format!("panicked at {at}:\n{text}"),
None => format!("panicked:\n{text}"),
}
})
}
/// A `std::backtrace` as one line a frame, `function (file:line:col)`. Captured in a panic hook
@@ -537,7 +584,8 @@ pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str)
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
/// does. The default hook still prints the panic. A panic inside `contain` is only logged. A
/// panic inside a native mod's hook names the mod and the hook.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -676,6 +724,25 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_native_mods_hook_names_it() {
let outer = enter_native_mod(NativeModCall {
id: None,
index: 1,
hook: "render",
});
let inner = enter_native_mod(NativeModCall {
id: Some("b"),
index: 0,
hook: "tick",
});
assert!(attributed("boom".into()).ends_with("\nin native mod b, hook tick"));
drop(inner);
assert!(attributed("boom".into()).ends_with("\nin native mod #1, hook render"));
drop(outer);
assert_eq!(attributed("boom".into()), "boom");
}
fn report() -> Report {
Report {
time: chrono::TimeZone::with_ymd_and_hms(&Local, 2026, 10, 14, 9, 5, 7).unwrap(),
@@ -787,17 +854,37 @@ mod tests {
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
let caught = std::sync::Arc::new(Mutex::new(None));
let caught = std::sync::Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
if panic_message(info).contains("the test crash") {
*sink.lock().unwrap() = Some(panic_report(info, "client"));
sink.lock().unwrap().push(panic_report(info, "client"));
}
}));
let _ = std::panic::catch_unwind(|| panic!("the test crash"));
// The same panic inside a native mod's hook, which the hook sees before the unwind.
let _ = std::panic::catch_unwind(|| {
let _guard = enter_native_mod(NativeModCall {
id: Some("a"),
index: 0,
hook: "tick",
});
panic!("the test crash")
});
std::panic::set_hook(previous);
let report = caught.lock().unwrap().take().expect("the hook ran");
// A caught panic leaves the thread as it found it.
assert_eq!(attributed("boom".into()), "boom");
let mut reports = std::mem::take(&mut *caught.lock().unwrap());
assert_eq!(reports.len(), 2, "the hook ran for both panics");
let in_mod = reports.pop().unwrap();
assert!(
in_mod.error.ends_with("\nin native mod a, hook tick"),
"{}",
in_mod.error
);
let report = reports.pop().unwrap();
assert!(!report.error.contains("native mod"));
assert!(report.error.contains("the test crash"));
assert!(report.error.contains("crash.rs"));
// The first frame is the test's own code, not the panic machinery.
+23 -4
View File
@@ -4817,11 +4817,13 @@ impl Renderer {
) -> impl Iterator<Item = (&'a ([i32; 3], u32), bool)> + 'a {
let fade = fade_millis(fade_seconds);
let now = Instant::now();
// No section uploaded within the fade: all are whole, none is looked up.
let all = !self.sections_fading_in(fade, now);
let min_y = self.layout.min_y();
self.block_entities
.iter()
.filter(move |(key, _)| {
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
all || chunk_visibility(self.section_elapsed_ms(**key, now), fade)
>= CHUNK_VISIBILITY_THRESHOLD
})
.filter_map(move |(key, blocks)| {
@@ -5332,6 +5334,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 +5361,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,
@@ -7002,7 +7013,11 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
// Glowing entities' silhouettes posed on the GPU go last, past every
// material (the outline pass draws them from there).
let outline_base = posed + all_entities.len() as u32;
let outlined = skin.map_or(0, |s| s.outline_vertices);
let entity_vertices = u64::from(outline_base) + u64::from(outlined);
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
@@ -7028,6 +7043,7 @@ impl Renderer {
skin,
&self.entity_buffer,
0,
outline_base,
);
}
let static_sections = frame.static_block_entities;
@@ -7154,6 +7170,7 @@ impl Renderer {
&mut self.uploads,
&mut encoder,
frame.glowing,
(outline_base, outlined / 4),
[self.config.width, self.config.height],
);
if let Some((rotation, brightness)) = frame.stars {
@@ -7185,7 +7202,8 @@ impl Renderer {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
.max(frame.glowing.len() as u32 / 4)
.max(outlined / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -9074,6 +9092,7 @@ impl Renderer {
view_target,
&self.entity_group,
&self.indices,
&self.entity_buffer,
timing.as_mut(),
);
// The world icon grabs here: after the level and the hand, before
@@ -29,6 +29,8 @@ pub(super) struct EntityOutline {
vertices: wgpu::Buffer,
capacity: u64,
quads: u32,
/// The silhouettes posed on the GPU: first vertex in the entity buffer, quads.
posed: (u32, u32),
targets: Option<Targets>,
}
@@ -159,6 +161,7 @@ impl EntityOutline {
vertices: Self::vertex_buffer(device, 256),
capacity: 256,
quads: 0,
posed: (0, 0),
targets: None,
}
}
@@ -175,16 +178,20 @@ impl EntityOutline {
/// Uploads this frame's silhouettes (`LevelRenderer.currentFrameRendersEntityOutline`
/// when there are any) and sizes the targets like the window. A frame with
/// none gives the targets' video memory back until an entity glows again.
/// `posed`: where the silhouettes posed on the GPU start in the entity
/// buffer (`entity_skin`'s outline jobs), and how many quads they are.
pub(super) fn prepare(
&mut self,
device: &wgpu::Device,
uploads: &mut super::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
silhouettes: &[Vertex],
posed: (u32, u32),
size: [u32; 2],
) {
self.quads = (silhouettes.len() / 4) as u32;
if self.quads == 0 {
self.posed = posed;
if self.quads == 0 && posed.1 == 0 {
self.targets = None;
return;
}
@@ -193,12 +200,14 @@ impl EntityOutline {
self.capacity = vertices.next_power_of_two();
self.vertices = Self::vertex_buffer(device, self.capacity);
}
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
if !silhouettes.is_empty() {
uploads.buffer(
encoder,
&self.vertices,
0,
bytemuck::cast_slice(silhouettes),
);
}
if self.targets.as_ref().is_some_and(|t| t.size == size) {
return;
}
@@ -260,9 +269,14 @@ impl EntityOutline {
frame: &wgpu::TextureView,
entity_group: &wgpu::BindGroup,
indices: &wgpu::Buffer,
entities: &wgpu::Buffer,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
let Some(t) = self.targets.as_ref().filter(|_| self.quads > 0) else {
let Some(t) = self
.targets
.as_ref()
.filter(|_| self.quads > 0 || self.posed.1 > 0)
else {
return;
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
@@ -277,8 +291,15 @@ impl EntityOutline {
pass.set_pipeline(&self.silhouette);
pass.set_bind_group(0, entity_group, &[]);
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
if self.quads > 0 {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.quads * 6, 0, 0..1);
}
let (first, quads) = self.posed;
if quads > 0 {
pass.set_vertex_buffer(0, entities.slice(..));
pass.draw_indexed(0..quads * 6, first as i32, 0..1);
}
}
// post_effect/entity_outline.json; its last pass only copies swap back to the
// outline target, so the blit reads swap.
@@ -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);
@@ -96,6 +107,11 @@ pub struct SkinFrame {
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
/// Glowing entities' silhouettes posed here (`OutlineBufferSource`'s copy of
/// their draws, [`SkinFrame::outline_since`]): `dst` counts from the first.
pub outline_jobs: Vec<SkinJob>,
/// Vertices the outline jobs write.
pub outline_vertices: u32,
}
impl SkinFrame {
@@ -104,6 +120,24 @@ impl SkinFrame {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
self.outline_jobs.clear();
self.outline_vertices = 0;
}
/// Copies the draws since job `first` into the glowing entities' outline
/// (`Frame::glowing`'s, on the GPU), as `glowing.rs`'s `mark_outline` copies
/// vertices: the same ones, in `color` (`Vertex::color`) with no normal.
pub fn outline_since(&mut self, first: usize, color: u32) {
for i in first..self.jobs.len() {
let job = self.jobs[i];
self.outline_jobs.push(SkinJob {
dst: self.outline_vertices,
color,
mode: SkinJob::FLAT,
..job
});
self.outline_vertices += job.count;
}
}
/// The renderer took the new models.
@@ -127,7 +161,10 @@ impl SkinFrame {
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
self.reset
|| !self.new_model.is_empty()
|| !self.jobs.is_empty()
|| !self.outline_jobs.is_empty()
}
}
@@ -282,7 +319,8 @@ impl EntitySkin {
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
/// `out` from vertex `base` on, and the outline jobs `frame.outline_vertices`
/// from vertex `outline_base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
@@ -292,26 +330,32 @@ impl EntitySkin {
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
outline_base: u32,
) {
if frame.jobs.is_empty() {
if frame.jobs.is_empty() && frame.outline_jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let count = (frame.jobs.len() + frame.outline_jobs.len()) as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
count as usize
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
let moved = |at: u32| {
move |job: &SkinJob| SkinJob {
dst: job.dst + at,
..*job
}
};
self.staged.extend(frame.jobs.iter().map(moved(base)));
self.staged
.extend(frame.outline_jobs.iter().map(moved(outline_base)));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
@@ -358,8 +402,22 @@ impl EntitySkin {
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
expand_jobs(model, frame, &frame.jobs, frame.vertices)
}
/// [`expand`] of the frame's outline jobs: the silhouettes they write.
pub fn expand_outline(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
expand_jobs(model, frame, &frame.outline_jobs, frame.outline_vertices)
}
fn expand_jobs(
model: &[SkinVertex],
frame: &SkinFrame,
jobs: &[SkinJob],
vertices: u32,
) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); vertices as usize];
for job in jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
@@ -375,9 +433,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,18 +584,50 @@ 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,
outline_jobs: Vec::new(),
outline_vertices: 0,
};
// A glowing entity's silhouette: the item job and the first job again,
// in its team colour with no normal.
let mut frame = frame;
frame.jobs.push(frame.jobs[2]);
frame.jobs.last_mut().expect("pushed").dst = 120;
frame.jobs.push(SkinJob {
dst: 144,
..frame.jobs[0]
});
frame.vertices = 192;
frame.outline_since(4, 0xff20_c0e0);
assert_eq!(frame.outline_vertices, 72);
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let outline = expand_outline(&model, &frame);
// The posed vertices land after 5 others, which they leave alone, and the
// silhouettes 3 past them.
let before = 5u32;
let outline_base = before + frame.vertices + 3;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
size: u64::from(outline_base + frame.outline_vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
@@ -544,7 +636,15 @@ mod tests {
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
skin.dispatch(
&device,
&mut uploads,
&mut encoder,
&frame,
&out,
before,
outline_base,
);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
@@ -567,5 +667,24 @@ mod tests {
"vertex {i}: {got:?} != {want:?}"
);
}
let silhouettes = &got[(outline_base - before) as usize..];
assert_eq!(silhouettes.len(), outline.len());
for (i, (got, want)) in silhouettes.iter().zip(&outline).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"silhouette {i}: {got:?} != {want:?}"
);
}
// A silhouette is its draw's vertex with the colour and no normal
// (`mark_outline`).
for (copy, of) in outline.iter().zip(&expected[120..]) {
let marked = Vertex {
color: 0xff20_c0e0,
normal: [0; 4],
..*of
};
assert_eq!(bytemuck::bytes_of(copy), bytemuck::bytes_of(&marked));
}
}
}
@@ -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]);
+43 -6
View File
@@ -115,6 +115,10 @@ pub const MAX_ATLAS_SIZE: u32 = 16384;
pub const MAP_SLOTS: usize = 256;
/// The atlas slot the minimap extra draws into.
pub const MINIMAP_SPRITE: &str = "horizon/minimap";
/// A mod's own image (`native_mod_image`), drawn with `native_sprite`; big
/// enough for a world map's 1024 blocks at a block a pixel.
pub const MOD_IMAGE_SPRITE: &str = "horizon/modimage";
pub const MOD_IMAGE_SIZE: u32 = 1024;
/// Atlas slots for downloaded player skins (`skins/<sha1>`), 64x64 each.
/// Vanilla registers a texture per skin. Here the slots are shared
/// (`skins::SkinSlots`): skins drawn in the last frame keep theirs, and a
@@ -681,13 +685,21 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
Self::build_over_with_image(root, defaults, false)
}
/// The atlas above, with a slot for a native mod's own image
/// (`horizon/modimage`) only when `mod_image` says a mod is loaded that
/// draws one: the slot costs 4 MiB of pixels, so a game without native
/// mods holds none of them.
pub fn build_over_with_image(root: &Path, defaults: &Path, mod_image: bool) -> Result<Self> {
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true, mod_image)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
/// minimap, a native mod's own image, downloaded skins, world and pack
/// icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32), mod_image: bool) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
@@ -698,6 +710,15 @@ impl HudAtlas {
crate::minimap::SIZE as u32,
vec![0; crate::minimap::SIZE * crate::minimap::SIZE * 4],
));
// A mod's own image (native `mod_image`), only when a mod is loaded.
if mod_image {
images.push((
MOD_IMAGE_SPRITE.into(),
MOD_IMAGE_SIZE,
MOD_IMAGE_SIZE,
vec![0; (MOD_IMAGE_SIZE * MOD_IMAGE_SIZE * 4) as usize],
));
}
// Downloaded skins (game/scoreboard.rs).
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
@@ -740,7 +761,7 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false, false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
@@ -886,7 +907,13 @@ impl HudAtlas {
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(
root: &Path,
defaults: &Path,
folders: &[&str],
full: bool,
mod_image: bool,
) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
@@ -1117,7 +1144,7 @@ impl HudAtlas {
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images, crate::trims::slot_size(root));
Self::slots(&mut images, crate::trims::slot_size(root), mod_image);
// MissingTextureAtlasSprite.generateMissingImage: 16x16, magenta
// and black quarters.
let missing = (0..256)
@@ -2632,6 +2659,16 @@ impl HudRenderer {
mod tests {
use super::*;
/// The mod image slot is paid only by a game with a native mod loaded.
#[test]
fn the_mod_image_slot_exists_only_when_a_mod_is_loaded() {
let root = Path::new("/nonexistent/horizon-test-assets");
let plain = HudAtlas::build(root).unwrap();
assert!(!plain.sprites.contains_key(MOD_IMAGE_SPRITE));
let with_mod = HudAtlas::build_over_with_image(root, root, true).unwrap();
assert!(with_mod.sprites.contains_key(MOD_IMAGE_SPRITE));
}
#[test]
fn dumping_the_atlas_writes_its_pixels_and_sprite_rects() {
let root = std::env::temp_dir().join("horizon-atlas-dump-test");
+11 -1
View File
@@ -61,6 +61,7 @@ fn cpu_if_enabled() -> Option<Duration> {
/// Run `f`, recording its duration under `name` when spans are enabled.
#[inline]
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
let _heap = horizon_threads::heap_tag(name);
if !recording_here() {
return f();
}
@@ -291,9 +292,15 @@ pub mod startup {
pub struct Step {
name: &'static str,
started: Instant,
traced: bool,
/// A heap-census build's tag for the step's allocations.
_heap: Option<horizon_threads::heap_census::Tag>,
}
impl Drop for Step {
fn drop(&mut self) {
if !self.traced {
return;
}
let end = Instant::now();
write(format_args!(
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
@@ -307,9 +314,12 @@ pub mod startup {
/// Starts a step named `name` (a literal: no quotes or backslashes).
#[inline]
pub fn step(name: &'static str) -> Option<Step> {
on().then(|| Step {
let traced = on();
(traced || horizon_threads::HEAP_CENSUS).then(|| Step {
name,
started: Instant::now(),
traced,
_heap: horizon_threads::heap_tag(name),
})
}
+97 -4
View File
@@ -774,12 +774,23 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
};
(kind, crate::spans::thread_cpu().unwrap_or_default())
});
let _tag = horizon_threads::heap_tag(match &job.job {
Job::Generate(..) => "stream.generate",
Job::Decorate(..) => "stream.decorate",
Job::Finish(..) => "stream.finish",
Job::Light(..) => "stream.light",
Job::Mesh(..) => "stream.mesh",
_ => "stream.other",
});
let done = self.locals.with(
worker,
|| WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
|| {
let _tag = horizon_threads::heap_tag("stream.worker_state");
WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
}
},
|local| self.run_job(job.job, local),
);
@@ -1987,6 +1998,14 @@ impl Streaming {
if positions.is_empty() {
return;
}
let positions = if positions.len() > INLINE_RELIGHT_MAX {
self.relight_on_workers(world, positions)
} else {
positions
};
if positions.is_empty() {
return;
}
let mut changed = LightChanges::default();
crate::light::relight(
&mut world.chunks,
@@ -2005,6 +2024,25 @@ impl Streaming {
}
}
}
/// Hands a big batch of edits (a `/fill` arriving block by block) to the
/// workers: the columns they touch and their neighbours get full light
/// jobs, as `World::edit_runs` does, instead of relighting every changed
/// cell in the frame (clearing a 64x64x16 volume in the air relit the
/// sky below it: 43 ms of one frame). The sections wait for those jobs
/// before they are meshed again. Returns the edits left to light in the
/// frame: those in columns that can't be lit whole yet (the edge of the
/// loaded area).
fn relight_on_workers(
&self,
world: &mut World,
positions: Vec<(i32, i32, i32)>,
) -> Vec<(i32, i32, i32)> {
let (columns, inline) = whole_relight_columns(positions, |x, z| {
self.lit.contains(&(x, z)) && self.light_region_ready(world, x, z)
});
world.dirty_light.extend(columns);
inline
}
/// A light job computed from blocks older than some edits: redo those
/// edits on top of its result.
fn replay_light_edits(&mut self, world: &mut World, x: i32, z: i32, snapshot: u64) {
@@ -2693,6 +2731,36 @@ fn same_values(
x == y
}
/// The most changed blocks one frame relights itself (`Streaming::relight_edits`): a 16^3
/// section's worth. Past it, the columns are lit again whole on the workers.
const INLINE_RELIGHT_MAX: usize = 4096;
/// Splits edits at `positions` into the columns to light again whole and the
/// edits to relight in place. An edited column is lit whole with its
/// neighbours (light reaches 15 blocks, never past the next column) when all
/// nine are `ready` to be lit whole; otherwise its edits stay in place.
fn whole_relight_columns(
positions: Vec<(i32, i32, i32)>,
ready: impl Fn(i32, i32) -> bool,
) -> (FxHashSet<(i32, i32)>, Vec<(i32, i32, i32)>) {
let around =
|(x, z): (i32, i32)| (-1..=1).flat_map(move |dz| (-1..=1).map(move |dx| (x + dx, z + dz)));
let touched: FxHashSet<(i32, i32)> = positions
.iter()
.map(|&(x, _, z)| (x >> 4, z >> 4))
.collect();
let whole: FxHashSet<(i32, i32)> = touched
.into_iter()
.filter(|&column| around(column).all(|(x, z)| ready(x, z)))
.collect();
let columns = whole.iter().flat_map(|&column| around(column)).collect();
let inline = positions
.into_iter()
.filter(|&(x, _, z)| !whole.contains(&(x >> 4, z >> 4)))
.collect();
(columns, inline)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2840,6 +2908,31 @@ mod tests {
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
/// Edits in two neighbouring columns light those and the ten around
/// them again; a column next to one that can't be lit whole keeps its
/// edits in the frame.
#[test]
fn whole_relights_cover_the_edited_columns_and_their_neighbours() {
// Blocks in columns (0, 0) and (1, 0).
let positions = vec![(3, 70, 4), (15, 64, 15), (16, 80, 0), (31, 64, 9)];
let (columns, inline) = whole_relight_columns(positions.clone(), |_, _| true);
let mut columns: Vec<_> = columns.into_iter().collect();
columns.sort();
let expected: Vec<(i32, i32)> = (-1..=2)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
assert_eq!(columns, expected);
assert!(inline.is_empty());
// (2, 1) is next to (1, 0) only: (0, 0) is still lit whole.
let (columns, inline) = whole_relight_columns(positions, |x, z| (x, z) != (2, 1));
assert_eq!(inline, vec![(16, 80, 0), (31, 64, 9)]);
assert_eq!(columns.len(), 9);
assert!(columns.contains(&(-1, -1)) && columns.contains(&(1, 1)));
// Negative coordinates floor to their column.
let (west, _) = whole_relight_columns(vec![(-1, 64, -17)], |_, _| true);
assert!(west.contains(&(-2, -3)) && west.contains(&(0, -1)) && west.len() == 9);
}
#[test]
fn queued_jobs_follow_the_centre() {
let forced = HashSet::new();
@@ -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(),
+3
View File
@@ -117,6 +117,9 @@ rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
system-allocator = []
# mimalloc with a tag on each block: `horizon-ctl memory` lists the live heap by startup step and
# thread (`heap_by_tag_mb`). For measuring only: each block costs 16 bytes more.
alloc-census = ["horizon-threads/alloc-census"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
@@ -753,6 +753,9 @@ fn print(out: &mut String, t: i32, i: usize, m: &Mob, player_down: bool, now: i6
if let Some((acts, mem)) = brain {
let _ = write!(out, "\tact {acts}\tmem {mem}");
}
if horizon_client::mobs::finalize::ageable(m.species()) {
let _ = write!(out, "\tage {}", m.extra.age);
}
if m.species() == Species::Villager {
let _ = write!(out, "\tvx {}", villager(m));
}
@@ -1026,6 +1029,7 @@ pub fn main() -> horizon_client::Result<()> {
m.rider = Some(horizon_client::mobs::equine::Rider::default());
}
"noai" => m.no_ai = true,
"agelocked" => m.extra.age_locked = true,
// Warden.finalizeSpawn's own part.
"digcooldown" | "emerge" => horizon_client::mobs::warden::finalize(
&mut m,
+23
View File
@@ -1425,6 +1425,29 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
/// the second half of #7186.
#[test]
fn a_recipe_crafted_criterion_matches_the_whole_id_only() {
let w = Flat;
let criterion = serde_json::json!({"recipes": "minecraft:folder_recipe"});
assert!(
!id_matches(&criterion["recipes"], "recipe", "folder_recipe", &w),
"the crafting copy's bare name never matched the criterion"
);
assert!(
id_matches(
&criterion["recipes"],
"recipe",
&crate::crafting::qualified("folder_recipe"),
&w
),
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
+3
View File
@@ -170,6 +170,8 @@ pub struct ArrowState {
/// The block it stuck in (`lastState`).
pub last_state: Option<BlockStateId>,
pub pickup: Pickup,
/// Its entity type is `SpectralArrow` (the type is its own, not the pickup item's).
pub spectral: bool,
/// `pickupItemStack`: what picking the arrow up gives.
pub pickup_item: crate::items::ItemStack,
/// `AbstractArrow.firedFromWeapon`: the crossbow or bow it was shot from.
@@ -227,6 +229,7 @@ impl ArrowState {
shake: 0,
last_state: None,
pickup: Pickup::Disallowed,
spectral: false,
pickup_item: crate::items::ItemStack::new("minecraft:arrow", 1),
fired_from_weapon: None,
no_physics: false,
@@ -221,7 +221,7 @@ pub const COMMANDS: &[Cmd] = &[
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
["horizon-ctl hello"], &[], mcp: true, local: false),
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts. A build with the alloc-census feature adds heap_by_tag_mb: the live heap by startup step, timed span, chunk job and thread.", ["horizon-ctl memory"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
+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
+13 -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,
@@ -1305,6 +1305,18 @@ fn matches_all(ingredients: &[Ingredient], stacks: &[&ItemStack]) -> bool {
augment(i, ingredients, stacks, &mut owner, &mut seen)
})
}
/// A resource id with its default namespace filled in — the whole id the
/// book, the network copy and the advancements' JSON know a recipe by,
/// whatever the caller passes. `sources` names a `minecraft:` recipe bare
/// on purpose (vanilla's own ids); this puts the namespace back.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl Recipes {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Result<Self> {
let sources = Self::sources(root, overlay)?;
+62 -2
View File
@@ -1483,6 +1483,7 @@ impl ArmorStand {
facts: &BlockFacts,
in_water: bool,
push: &mut dyn FnMut(Aabb, &mut Vec3),
border: Option<&BorderGeometry>,
) {
self.previous = self.position;
if self.marker {
@@ -1519,7 +1520,20 @@ impl ArmorStand {
1.0
};
let mut velocity = wanted;
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
// Entity.collide: the world border's walls stop the stand too.
let moved = SceneCollisions {
world: scene.world,
binding: scene.binding,
subject: scene.subject,
}
.resolve_in_border(
self.bounds(),
wanted,
0.0,
self.on_ground,
border.map(|b| (b, [self.position.x, self.position.z])),
);
if let Ok(moved) = moved {
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
@@ -2699,7 +2713,53 @@ mod armor_stand_motion_tests {
binding: collision,
subject: None,
};
stand.tick(&mut scene, facts, false, &mut |_, _| {});
stand.tick(&mut scene, facts, false, &mut |_, _| {}, None);
}
/// `Entity.collectColliders` (#7080): the world border's wall stops an
/// armor stand as it stops the player. A border of size 10 around
/// (20, 20) has its east wall at x = 25; the stand starts at x = 24 on the
/// test scene's flat ground, moving 1.5 east.
#[test]
fn the_world_border_stops_an_armor_stand() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let border = BorderGeometry::new([20.0, 20.0], 10.0, 29_999_984).unwrap();
for bordered in [false, true] {
let mut stand = ArmorStand::new(1, Vec3::new(24.0, 4.0, 20.5), 0.0);
stand.on_ground = true;
stand.velocity = Vec3::new(1.5, 0.0, 0.0);
let mut scene = SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
stand.tick(
&mut scene,
&facts,
false,
&mut |_, _| {},
bordered.then_some(&border),
);
let east = stand.bounds().max_position().x;
if bordered {
assert!(
(east - 25.0).abs() < 1.0e-7,
"stops on the wall, got {east}"
);
} else {
assert!(east > 25.0, "no border: goes on, got {east}");
}
}
}
#[test]
+4 -4
View File
@@ -380,7 +380,7 @@ mod tests {
binding: &collision,
subject: None,
};
still.tick(&mut scene, &facts, in_water, &mut |_, _| {});
still.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
@@ -394,7 +394,7 @@ mod tests {
binding: &collision,
subject: None,
};
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {});
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
assert_eq!(marker.position, at);
assert_eq!(marker.velocity, Vec3::ZERO);
@@ -406,7 +406,7 @@ mod tests {
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
for _ in 0..20 {
@@ -416,7 +416,7 @@ mod tests {
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {}, None);
}
assert!(carried.position.x > at.x + 0.5);
}
+247 -10
View File
@@ -27,7 +27,7 @@ fn string(s: &str) -> Tag {
}
/// Entity type ID (`id`).
pub fn kind(tag: &Tag) -> Option<String> {
text(tag.get("id"))
horizon_client_core::identifier::normalize_id(&text(tag.get("id"))?)
}
pub fn position(tag: &Tag) -> Option<Vec3> {
read_doubles::<3>(tag.get("Pos")).map(|p| Vec3::new(p[0], p[1], p[2]))
@@ -519,8 +519,10 @@ pub fn mob_to_nbt(
}
}
let species = m.species();
// MushroomCow saves its variant under `Type` (below), not `variant`.
if species != crate::mobs::Species::Mooshroom
// `VariantUtils.writeVariant`: only the species that store the name. A
// mooshroom saves `Type` (below), a fox `Type`, a horse and a llama a
// packed `Variant` int, so those report the component but save no name.
if crate::mobs::finalize::saves_variant_name(species)
&& let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
@@ -1643,7 +1645,7 @@ pub fn mob_from_nbt(
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
if crate::mobs::finalize::saves_variant_name(m.species())
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
@@ -2238,7 +2240,12 @@ pub fn arrow_to_nbt(
return None;
};
let base = Base {
id: "minecraft:arrow",
// A spectral arrow is its own entity type.
id: if a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow" {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
},
uuid,
position: p.position,
motion: p.velocity,
@@ -2298,10 +2305,17 @@ pub fn arrow_from_nbt(
let position = position(tag)?;
let owner = uuid_from_tag(tag.get("Owner"));
let from_player = player.is_some() && owner == player;
// `getDefaultPickupItem`: a `SpectralArrow` is picked up as its own item.
let spectral = kind(tag).as_deref() == Some("minecraft:spectral_arrow");
let default_item = if spectral {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
};
let item = tag
.get("item")
.and_then(item_from_nbt)
.unwrap_or_else(|| crate::items::ItemStack::new("minecraft:arrow", 1));
.unwrap_or_else(|| crate::items::ItemStack::new(default_item, 1));
let kind = ProjectileKind::Arrow {
base_damage: float(tag.get("damage")).unwrap_or(2.0),
critical: flag(tag, "crit"),
@@ -2322,6 +2336,7 @@ pub fn arrow_from_nbt(
a.has_been_shot = flag(tag, "HasBeenShot");
a.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i16 as i32;
a.fall_distance = float(tag.get("fall_distance")).unwrap_or(0.0);
a.spectral = spectral;
a.pickup_item = item;
a.fired_from_weapon = tag.get("weapon").and_then(item_from_nbt);
p.stuck = a.in_ground;
@@ -2403,12 +2418,13 @@ fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLE_ITEMS: [&str; 5] = [
const THROWABLE_ITEMS: [&str; 6] = [
"minecraft:snowball",
"minecraft:egg",
"minecraft:ender_pearl",
"minecraft:splash_potion",
"minecraft:lingering_potion",
"minecraft:experience_bottle",
];
/// The item a throwable flies as: its stack (a potion's), the mod type's
/// item, or the item its entity type is named after.
@@ -2432,6 +2448,7 @@ pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
"minecraft:egg" => Some(ProjectileKind::Egg),
"minecraft:ender_pearl" => Some(ProjectileKind::EnderPearl),
// The entity type is the id; the `Item` read later may be another potion.
"minecraft:experience_bottle" => Some(ProjectileKind::ExperienceBottle),
_ => Some(ProjectileKind::Potion(
Box::new(crate::items::ItemStack::new(id, 1)),
id == "minecraft:lingering_potion",
@@ -2454,12 +2471,17 @@ pub fn saved_with_chunk(p: &Projectile) -> bool {
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::Potion(..)
| ProjectileKind::ExperienceBottle
| ProjectileKind::LlamaSpit
| ProjectileKind::ShulkerBullet { target: None, .. }
| ProjectileKind::Thrown(_) => true,
ProjectileKind::Arrow { .. } => p.arrow.is_some(),
// Not written yet: tridents save from their own list.
// Not written yet: tridents save from their own list, and a bullet
// with a target would lose it.
ProjectileKind::Trident
| ProjectileKind::LlamaSpit
| ProjectileKind::ShulkerBullet { .. }
| ProjectileKind::ShulkerBullet {
target: Some(_), ..
}
| ProjectileKind::WitherSkull { .. } => false,
}
}
@@ -2522,6 +2544,59 @@ pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Project
Some(p)
}
/// `LlamaSpit` and `ShulkerBullet` as `/summon` or a chunk loads them:
/// `Projectile`'s `Owner`, `LeftOwner`, `NoGravity` and the motion. A bullet
/// loaded without a `Target` has none, so it falls (`ShulkerBullet.tick`).
pub fn spit_or_bullet_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:llama_spit" => ProjectileKind::LlamaSpit,
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
target: None,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
_ => return None,
};
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
Some(p)
}
/// The save of [`spit_or_bullet_from_nbt`]'s kinds: `Projectile`'s keys.
pub fn spit_or_bullet_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
if !matches!(
p.kind,
ProjectileKind::LlamaSpit | ProjectileKind::ShulkerBullet { target: None, .. }
) {
return None;
}
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
Some(tag)
}
/// `Projectile`'s `Owner`.
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("Owner"))
@@ -3311,6 +3386,31 @@ mod tests {
use super::*;
use crate::items::ItemStack;
#[test]
fn mob_loading_accepts_default_namespace_and_preserves_custom_namespaces() {
let cow = crate::mobs::sensor_scan_tests::kind();
let types = [
("minecraft:cow".into(), cow.clone()),
("test:cow".into(), cow),
]
.into();
let pos = || {
Tag::List(
[4.5_f64, 220.0, 6.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
)
};
for id in ["cow", ":cow", "minecraft:cow", "test:cow"] {
let tag = Tag::compound([("id", string(id)), ("Pos", pos()), ("NoAI", Tag::Byte(1))]);
let mob = mob_from_nbt(&tag, &types, 7, None).expect(id);
assert_eq!(mob.position, Vec3::new(4.5, 220.0, 6.5));
assert!(mob.no_ai);
}
let unknown = Tag::compound([("id", string("other:cow")), ("Pos", pos())]);
assert!(mob_from_nbt(&unknown, &types, 8, None).is_none());
}
/// A saved rocket and trident come back with their own Life, LifeTime,
/// flags and position (the keys vanilla's addAdditionalSaveData writes).
#[test]
@@ -3858,6 +3958,14 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
// alone, so a spawn egg carrying one hatches that variant.
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
// Horse.applyImplicitComponent: `setVariant` sets the coat colour and
// keeps the markings the spawn picked (`getTypeVariant() & -256`),
// which Horizon keeps in `extra.variant2` and leaves alone here.
crate::mobs::Species::Horse => (Some("minecraft:horse/variant"), None),
// Llama.applyImplicitComponent, which a trader llama inherits.
crate::mobs::Species::Llama | crate::mobs::Species::TraderLlama => {
(Some("minecraft:llama/variant"), None)
}
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -4757,6 +4865,69 @@ mod item_component_tests {
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
}
/// `Horse.applyImplicitComponent` and `Llama.applyImplicitComponent`: the
/// `.../variant` component sets the coat colour. A horse's markings are
/// the other half of the packed variant its spawn picked and stay.
#[test]
fn a_horse_or_llama_spawn_egg_picks_its_variant_component() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let spawn = |kind: &str, id: u32| {
Mob::new(
id,
Arc::new(data.types[kind].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
)
};
// A black horse (Horse.Variant.BLACK, 4) with a white field (markings
// 2), as a spawn in a plains leaves it.
let mut horse = spawn("minecraft:horse", 1);
(horse.extra.variant, horse.extra.variant2) = (4, 2);
let mut egg = crate::items::ItemStack::new("minecraft:horse_spawn_egg", 1);
egg.data_mut().set_other(
"minecraft:horse/variant",
Some(string("minecraft:dark_brown")),
);
apply_item_components(&mut horse, &egg);
assert_eq!(horse.extra.variant, 6, "Horse.Variant.DARK_BROWN");
assert_eq!(
horse.extra.variant2, 2,
"the markings the spawn picked stay"
);
// `Horse.get(DataComponents.HORSE_VARIANT)` reports it back.
let (id, variant) = crate::mobs::finalize::variant_component(&horse).unwrap();
assert_eq!(
(id, variant.as_str()),
("minecraft:horse/variant", "minecraft:dark_brown")
);
// The save keeps the packed `Variant` int and gains no `variant` name,
// which only `VariantUtils.writeVariant` species store.
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
assert_eq!(tag.get("variant"), None);
assert!(
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
"colour 6 and markings 2 packed"
);
// A llama, as a spawn in a plains leaves it: creamy.
let mut egg = crate::items::ItemStack::new("minecraft:llama_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:llama/variant", Some(string("minecraft:gray")));
let mut llama = spawn("minecraft:llama", 2);
assert_eq!(llama.extra.variant, 0);
apply_item_components(&mut llama, &egg);
assert_eq!(llama.extra.variant, 3, "Llama.Variant.GRAY");
// TraderLlama inherits the llama's arm.
let mut trader = spawn("minecraft:trader_llama", 3);
apply_item_components(&mut trader, &egg);
assert_eq!(trader.extra.variant, 3, "the trader llama reads it too");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
@@ -5287,6 +5458,72 @@ mod mod_thrown_nbt_tests {
// a type no mod declares now is not a thrown kind.
assert!(throwable_kind("gone:bolt").is_none());
}
fn summoned(id: &str, extra: &[(&str, Tag)]) -> Tag {
let mut c = vec![
("id", Tag::String(id.into())),
("Pos", doubles(&[0.5, 110.0, 0.5])),
("Motion", doubles(&[0.0, 0.0, 0.0])),
];
c.extend(extra.iter().cloned());
Tag::compound(c)
}
/// Four summoned kinds had no loader and stayed inert (a spectral arrow,
/// a shulker bullet, a llama's spit and an experience bottle never fell).
#[test]
fn summoned_projectiles_of_every_kind_load_and_honour_no_gravity() {
use crate::entity_rules::EntityRules;
let flag = ("NoGravity", Tag::Byte(1));
let arrow = arrow_from_nbt(&summoned("minecraft:spectral_arrow", &[]), None, |_| None)
.unwrap()
.0;
assert!(arrow.is_spectral_arrow() && !arrow.no_gravity);
let held = arrow_from_nbt(
&summoned("minecraft:spectral_arrow", std::slice::from_ref(&flag)),
None,
|_| None,
)
.unwrap()
.0;
assert!(held.no_gravity);
// ...and it is saved as the entity type it is.
let saved = arrow_to_nbt(&arrow, [1; 4], None, None, None).unwrap();
assert_eq!(kind(&saved).as_deref(), Some("minecraft:spectral_arrow"));
let plain = arrow_from_nbt(&summoned("minecraft:arrow", &[]), None, |_| None)
.unwrap()
.0;
let saved = arrow_to_nbt(&plain, [1; 4], None, None, None).unwrap();
assert_eq!(kind(&saved).as_deref(), Some("minecraft:arrow"));
for id in ["minecraft:llama_spit", "minecraft:shulker_bullet"] {
let p = spit_or_bullet_from_nbt(&summoned(id, &[]), None).unwrap();
assert_eq!(p.kind.id(), id);
assert!(!p.no_gravity && p.position == Vec3::new(0.5, 110.0, 0.5));
let held =
spit_or_bullet_from_nbt(&summoned(id, std::slice::from_ref(&flag)), None).unwrap();
assert!(held.no_gravity);
}
assert!(spit_or_bullet_from_nbt(&summoned("minecraft:arrow", &[]), None).is_none());
// a spit and a target-less bullet are saved and selectable (`/data`, `@e`).
let spit = spit_or_bullet_from_nbt(&summoned("minecraft:llama_spit", &[]), None).unwrap();
assert!(saved_with_chunk(&spit));
let saved = spit_or_bullet_to_nbt(&spit, [1; 4], None, None).unwrap();
assert_eq!(kind(&saved).as_deref(), Some("minecraft:llama_spit"));
// A summoned experience bottle flies as the bottle item, with a bottle's gravity.
let bottle =
throwable_from_nbt(&summoned("minecraft:experience_bottle", &[]), None).unwrap();
assert_eq!(bottle.kind.id(), "minecraft:experience_bottle");
assert!(!bottle.no_gravity && (bottle.get_gravity() - 0.07).abs() < 1e-12);
let held =
throwable_from_nbt(&summoned("minecraft:experience_bottle", &[flag]), None).unwrap();
assert!(held.no_gravity && held.get_gravity() == 0.0);
let saved = throwable_to_nbt(&bottle, [1; 4], None, None).unwrap();
assert_eq!(kind(&saved).as_deref(), Some("minecraft:experience_bottle"));
let item = saved.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:experience_bottle");
}
#[test]
fn no_gravity_survives_save_and_load_for_orbs() {
// Entity.saveWithoutId / load: NoGravity round-trips on a kind that
+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);
},
+48 -16
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;
@@ -2011,7 +2012,10 @@ impl UiResources {
horizon_client::gfx::panorama::Faces::load(root, defaults)
})
});
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
let mod_image = !horizon_client::mod_api::mods().is_empty();
let atlas = startup::time("ui.atlas", || {
HudAtlas::build_over_with_image(root, defaults, mod_image)
});
let panorama = panorama
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
@@ -3778,13 +3782,13 @@ impl Game<'_> {
/// Item entity `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_dropped(&mut self, i: usize) -> ItemEntity {
let e = self.dropped.remove(i);
self.native_entities_left("minecraft:item", &[e.id]);
self.native_entity_left(e.id);
e
}
/// Experience orb `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_orb(&mut self, i: usize) -> ExperienceOrb {
let o = self.orbs.remove(i);
self.native_entities_left("minecraft:experience_orb", &[o.id]);
self.native_entity_left(o.id);
o
}
/// The item entity `spawn_item` just added, unless a native mod kept it
@@ -4102,6 +4106,13 @@ impl Game<'_> {
if extra_life > 0 {
item.age = 6000 - extra_life.min(32766);
alive = true;
} else if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_item_despawn(
item.id,
&item.stack.item,
item.stack.count as i32,
[item.position.x, item.position.y, item.position.z],
);
}
}
if !alive || removed[i] {
@@ -7312,6 +7323,9 @@ impl Game<'_> {
if running {
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
// What the blocks spawned (a dispensed minecart, a lit TNT, a
// placed boat) joins before the entities tick.
self.native_joins_flush(false, 0);
}
if running {
horizon_client::spans::time("tick_paintings", || self.tick_paintings());
@@ -7343,34 +7357,35 @@ impl Game<'_> {
self.handle_entity_portals();
self.handle_end_gateway_portals();
});
let before = self.dropped_ids();
horizon_client::spans::time("tick_items", || self.tick_items());
if self.remote.is_none() {
self.hoppers_catch_items();
}
// Merged, burnt, despawned or picked up.
self.native_items_left_since(&before);
self.native_leaves_flush();
self.native_joins_flush(false, 0);
horizon_client::spans::time("touch_arrows", || self.touch_arrows());
}
horizon_client::spans::time("spawn_loaded_entities", || self.spawn_loaded_entities());
// The ride the player's file names, once the vehicle is in the world.
self.restore_root_vehicle();
if running {
let mobs_before = self.mob_ids();
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
// Dead, despawned or gone into a hive.
self.native_mobs_left_since(&mobs_before);
self.native_leaves_flush();
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
self.tick_mod_container_viewers();
let before = self.orb_ids();
horizon_client::spans::time("tick_experience", || self.tick_experience());
// Merged, despawned or picked up.
self.native_orbs_left_since(&before);
self.native_leaves_flush();
horizon_client::spans::time("entity_portals", || {
self.find_entity_portals();
self.resolve_arrivals();
});
}
// The rest of the tick's spawns join, and what left is told of.
self.native_joins_flush(false, 0);
self.native_leaves_flush();
horizon_client::spans::time("tick_lan", || self.tick_lan(false));
self.tick_friends();
horizon_client::spans::time("tick_hand", || self.tick_hand());
@@ -8771,9 +8786,15 @@ impl Game<'_> {
/// (special ones never do).
fn award_crafted(&mut self, crafts: &[(String, Vec<Option<ItemStack>>)]) {
for (recipe, ingredients) in crafts {
self.trigger_recipe_crafted(recipe, ingredients);
// The whole id: the advancements' `recipe_crafted` criteria name
// `minecraft:x`, and the crafting copy names the recipe bare.
let recipe = horizon_client::crafting::qualified(recipe);
self.trigger_recipe_crafted(&recipe, ingredients);
}
let recipes: Vec<String> = crafts.iter().map(|(r, _)| r.clone()).collect();
let recipes: Vec<String> = crafts
.iter()
.map(|(r, _)| horizon_client::crafting::qualified(r))
.collect();
let index = &world_stations!(self).recipe_index;
let (_, unlocked) = self.level.recipe_book.add_recipes(index, &recipes);
self.recipe_unlocked(&unlocked);
@@ -10787,10 +10808,18 @@ impl Game<'_> {
);
}
// Mod block entities: their mods draw them (`native_block_entity_submit`),
// culled by the bounds they give.
let mods = r
.visible_block_entities(self.settings.chunk_fade)
.filter(|(_, state)| !self.res.block_entity_render().renders(*state));
// culled by the bounds they give. A world without any (`has_mods`) has none
// to find among its other unrendered block entities (furnaces, hoppers...).
let mods = self
.world
.block_entities
.has_mods()
.then(|| {
r.visible_block_entities(self.settings.chunk_fade)
.filter(|(_, state)| !self.res.block_entity_render().renders(*state))
})
.into_iter()
.flatten();
let untemplated = self.untemplated_mod_block_entities(camera);
self.mod_block_entity_geometry(
mods.chain(untemplated.iter()),
@@ -13353,9 +13382,12 @@ impl Game<'_> {
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let at = self.eye_position();
let old_level = self.experience.level;
let gave = self.experience.give(gained as i32);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = self.experience.give(gained as i32)
if let Some(level) = gave
&& self.ticks > self.last_level_up + 100
{
self.last_level_up = self.ticks;
@@ -614,6 +614,11 @@ impl Game<'_> {
let criterion = &a.criteria[ci].name;
if self.adv.progress.grant(&a.id, criterion, &date) {
self.advancement_progress(&a.id, criterion, true);
if let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_advancement_criterion_grant(player, &name, &a.id, criterion);
}
}
self.progress_changed(ai);
if !was && self.adv.progress.done(a) {
@@ -740,7 +745,9 @@ impl Game<'_> {
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
self.experience.give(a.rewards.experience);
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
+36 -15
View File
@@ -810,17 +810,23 @@ impl Game<'_> {
subject: None,
};
let mut tops: Vec<BlockPos> = Vec::new();
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push_over(
world,
registry,
facts,
collision,
bounds,
velocity,
|pos| tops.push(pos),
);
});
a.tick(
&mut scene,
facts,
in_water,
&mut |bounds, velocity| {
horizon_client::motion::bubble_column_push_over(
world,
registry,
facts,
collision,
bounds,
velocity,
|pos| tops.push(pos),
);
},
Some(self.border.geometry()),
);
for pos in tops {
self.bubble_column_particles(pos);
}
@@ -1067,7 +1073,7 @@ impl Game<'_> {
);
}
}
self.armor_stand_items(a, model, base, &posed, packed, out);
self.armor_stand_items(a, model, base, &posed, packed, skin.as_deref_mut(), out);
// HumanoidArmorLayer: head, chest, legs, feet. One pose map for every layer
// (`reset` keeps its capacity).
let mut layer_poses = Poses::default();
@@ -1380,7 +1386,9 @@ impl Game<'_> {
impl Game<'_> {
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
/// items in the third-person contexts, and a non-armour head item on the
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
/// head (`translateToHead`: down a quarter, turned, 0.625 scale). With
/// `skin`, items that can are posed on the GPU with the stand.
#[allow(clippy::too_many_arguments)]
fn armor_stand_items(
&self,
a: &ArmorStand,
@@ -1388,8 +1396,21 @@ impl Game<'_> {
base: glam::Mat4,
poses: &Poses,
light: u32,
mut skin: Option<&mut horizon_client::mobs::skin::Skin>,
out: &mut Vec<Vertex>,
) {
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
let mut submit = |state: &horizon_client::item_render::ItemRenderState,
pose: glam::Mat4,
out: &mut Vec<Vertex>| {
let on_gpu = skin
.as_deref_mut()
.is_some_and(|skin| self.skin_item(state, pose, light, lights, skin));
if !on_gpu {
self.submit_item_world(state, pose, light, out);
}
};
use horizon_assets::item_model::DisplayContext;
use horizon_client::mobs::render::part_matrix;
let owner = super::item_draw::owner_at(
@@ -1429,7 +1450,7 @@ impl Game<'_> {
} else {
self.resolve_item(stack, context, Some(&owner), seed)
};
self.submit_item_world(&state, pose, light, out);
submit(&state, pose, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
if let Some(stack) = &a.equipment[5]
@@ -1490,7 +1511,7 @@ impl Game<'_> {
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
let state =
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
self.submit_item_world(&state, pose, light, out);
submit(&state, pose, out);
}
}
}
@@ -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);
}
}
}
+18 -2
View File
@@ -1121,6 +1121,16 @@ impl Game<'_> {
);
self.world.modified.insert(key);
self.boats.push(boat);
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_vehicle_create(
self.boats.last().unwrap().entity_type().as_str(),
[
self.boats.last().unwrap().position.x,
self.boats.last().unwrap().position.y,
self.boats.last().unwrap().position.z,
],
);
}
if !self.creative {
self.consume_held();
}
@@ -1345,18 +1355,24 @@ impl Game<'_> {
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
pub(super) fn hurt_boat(&mut self, i: usize, mut damage: f32, creative: bool) -> bool {
let mut cancel = false;
self.native_vehicle_damage("boat", &mut damage, &mut cancel);
if cancel {
return false;
}
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
b.hurt_time = 10;
b.damage += damage * 10.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
let at = [b.position.x, b.position.y, b.position.z];
self.native_vehicle_destroy("boat", at);
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
// `AbstractChestBoat.remove` spills the chest through
+43 -5
View File
@@ -159,6 +159,17 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
i32::try_from(id).ok()
}
impl Game<'_> {
/// `native_player_level_change` for a level that was `from` before an experience change.
pub(super) fn native_player_level_changed(&mut self, from: i32) {
let to = self.experience.level;
if from != to
&& !horizon_client::mod_api::mods().is_empty()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_level_change(player, &name, from, to);
}
}
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
if self.client_mod_command(command) {
@@ -2094,9 +2105,8 @@ impl Game<'_> {
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
let before = self.orb_ids();
self.orbs.retain(|o| o.age < u32::MAX / 2);
self.native_orbs_left_since(&before);
self.native_leaves_flush();
}
fn cmd_kill(&mut self, ctx: &Context) -> R<i32> {
let targets = if ctx.get("targets").is_some() {
@@ -2175,7 +2185,8 @@ impl Game<'_> {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
self.spawn_mob_spawned(&short, pos)
.ok_or_else(|| err("commands.summon.failed.uuid", &[]))?;
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
@@ -2250,7 +2261,11 @@ impl Game<'_> {
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
let id = cart.id;
self.carts.push(cart);
if !self.native_joins_flush(false, id) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
// SummonCommand.createEntity: any other type from an empty compound.
@@ -2731,6 +2746,7 @@ impl Game<'_> {
Ok(count)
}
/// Player.giveExperiencePoints including negative amounts.
/// The caller reports the level change to the mods.
fn give_points(&mut self, amount: i32) {
let e = &mut self.experience;
e.progress += amount as f32 / horizon_client::entities::Experience::needed(e.level) as f32;
@@ -2916,6 +2932,7 @@ impl Game<'_> {
/// ExperienceCommand's add and set on the player in place: false when
/// the points to set do not fit the level.
fn change_experience(&mut self, add: bool, levels: bool, amount: i32) -> bool {
let old_level = self.experience.level;
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
@@ -2934,8 +2951,30 @@ impl Game<'_> {
}
self.experience.progress = amount as f32 / needed as f32;
}
self.native_player_level_changed(old_level);
true
}
/// `GameRules.set` as the `/gamerule` command and the management protocol call it: the rule changes, the
/// world follows it, and a dedicated server tells the event stream (`MinecraftServer.onGameRuleChanged`
/// tells the management protocol's clients).
pub(super) fn set_game_rule(&mut self, rule: &gamerules::Rule, value: Value) {
self.ui.rules.set(rule.name, value);
self.sync_rules();
if self.options.dedicated.is_some() {
let (value_type, json) = match value {
Value::Bool(b) => ("boolean", serde_json::json!(b)),
Value::Int(i) => ("integer", serde_json::json!(i)),
};
super::dedicated::events::record_server(
"gamerule",
serde_json::json!({
"key": format!("minecraft:{}", rule.name),
"valueType": value_type,
"value": json,
}),
);
}
}
fn cmd_gamerule(&mut self, ctx: &Context) -> R<i32> {
let Some(name) = ctx.literals.get(1).cloned() else {
return Ok(0);
@@ -2958,8 +2997,7 @@ impl Game<'_> {
if current == value {
return Err(err("commands.gamerule.not_set", &[&rule.name, &value]));
}
self.ui.rules.set(rule.name, value);
self.sync_rules();
self.set_game_rule(rule, value);
self.feedback(Text::tr("commands.gamerule.set", &[&rule.name, &value]));
Ok(match value {
Value::Bool(b) => i32::from(b),
@@ -173,7 +173,8 @@ impl Game<'_> {
let raw = self.persist.raw.get(&id);
entity_nbt::arrow_to_nbt(p, random_uuid(), owner, in_block, raw)
.or_else(|| entity_nbt::fireball_to_nbt(p, random_uuid(), owner, raw))
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, raw))?
.or_else(|| entity_nbt::throwable_to_nbt(p, random_uuid(), owner, raw))
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, random_uuid(), owner, raw))?
}
Target::Crystal(id) => {
let c = self.crystals.iter().find(|c| c.id == id)?;
@@ -879,7 +880,9 @@ impl Game<'_> {
let target = self.spawn_entity_from_nbt(tag);
self.native_mobs_joined(from, false);
// A mod kept the entity out: tryAddFreshEntityWithPassengers is false.
if target.is_none() && self.persist.joins_canceled != canceled {
if self.persist.joins_canceled != canceled
&& target.is_none_or(|t| self.target_position(t).is_none())
{
return Err(err("commands.summon.failed.uuid", &[]));
}
Ok(target)
@@ -276,7 +276,14 @@ impl Game<'_> {
}
}
pub(in crate::game) fn tick_worldborder(&mut self) {
let remaining = self.border.remaining_ticks();
self.border.tick();
if remaining > 0
&& self.border.remaining_ticks() == 0
&& (self.serving.is_some() || self.options.dedicated.is_some())
{
self.native_world_border_bounds_change_finish();
}
self.player_border();
self.border_damage();
}
+59 -35
View File
@@ -431,6 +431,9 @@ impl Game<'_> {
let id = mob.id;
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
if !self.mobs.iter().any(|m| m.id == id) {
return None;
}
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
@@ -1050,9 +1053,9 @@ impl Game<'_> {
for e in &mut events[from..] {
if let MobEvent::Projectile(p) = e
&& let ProjectileKind::ShulkerBullet { target, .. } = &mut p.kind
&& *target == Who::Player
&& *target == Some(Who::Player)
{
*target = Who::Mob(id);
*target = Some(Who::Mob(id));
}
}
}
@@ -2309,21 +2312,29 @@ impl Game<'_> {
thrown.thrower = Some(thrower);
}
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
count,
damage: 0,
extra: None,
},
MobEvent::DropItem {
mob,
item,
count,
at,
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
),
} => {
self.native_entity_drop_item(mob, &item, count as i32, at);
self.spawn_item(
ItemStack {
item,
count,
damage: 0,
extra: None,
},
at,
// The ItemEntity constructor's motion, from the entity's
// own random (not the level's), so spawnAtLocation drops
// (a fox's berries, a panda's held food, a goat's horn)
// land apart.
dispenser::new_item_motion(),
10,
);
}
MobEvent::DropGoatHorn {
mob,
screaming,
@@ -4766,7 +4777,16 @@ impl Game<'_> {
// flies as: the splash happens where the hit was, a lingering
// potion's cloud sits at the mob it hit (ThrownLingeringPotion),
// and the potion that lands names the hurt it deals.
(ProjectileKind::Potion(stack, _), hit) => {
(kind @ (ProjectileKind::Potion(..) | ProjectileKind::ExperienceBottle), hit) => {
// A bottle flies as the bottle item (`getDefaultItem`).
let bottle;
let stack = match kind {
ProjectileKind::Potion(stack, _) => stack,
_ => {
bottle = Box::new(ItemStack::new("minecraft:experience_bottle", 1));
&bottle
}
};
let block = match hit {
Hit::Block { pos, face, .. } => Some((
pos,
@@ -5359,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.
@@ -5497,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);
}
@@ -5553,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");
}
@@ -6288,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(),
}
}
@@ -6484,12 +6501,15 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& outline.is_none()
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
@@ -6568,14 +6588,18 @@ impl Game<'_> {
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws unless an outline reads their vertices.
let skin = (skinning && outline.is_none()).then_some(&mut skinned);
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
@@ -1100,6 +1100,7 @@ impl Game<'_> {
| K::EnderPearl
| K::LlamaSpit
| K::Potion { .. }
| K::ExperienceBottle
| K::Thrown(_) => (0.25, 0.2125),
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
@@ -890,6 +890,9 @@ impl Game<'_> {
self.settle_spawn()?;
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, however it ends. The management
// protocol's `server/activity` notification is rate limited from these.
events::record_server("login_activity", serde_json::json!({}));
let user = NameAndId {
id: uuid,
name: name.to_owned(),
@@ -1831,6 +1834,16 @@ impl Game<'_> {
}
}
/// `DedicatedServer.setMaxPlayers`: the live login limit, and `max-players` in
/// server.properties.
fn set_max_players(&mut self, state: &mut ServerState, max_players: usize) {
state.max_players = max_players;
if let Some(lan) = &self.lan {
lan.hub.lan.shared.set_max_players(max_players);
}
self.store_property("max-players", max_players.to_string());
}
/// `DedicatedServer.setEnforceWhitelist`: the flag, and `enforce-whitelist` in
/// server.properties.
pub(super) fn set_enforce_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
@@ -7,6 +7,7 @@ use super::{GAME_MODES, ServerState, auto_save, log, set_auto_save};
use crate::game::Game;
use horizon_client::{
command::vanilla::DIFFICULTIES,
gamerules::{self, Value as GameRuleValue},
server::{
profiles,
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
@@ -29,6 +30,15 @@ struct Op {
bypass: Option<bool>,
}
/// A `GameRuleUpdate` as `TYPED_CODEC` writes it: the rule's id, its type and its value.
fn typed_rule(rule: &gamerules::Rule, value: GameRuleValue) -> Value {
let (kind, value) = match value {
GameRuleValue::Bool(b) => ("boolean", json!(b)),
GameRuleValue::Int(i) => ("integer", json!(i)),
};
json!({ "key": format!("minecraft:{}", rule.name), "type": kind, "value": value })
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
format!(
@@ -49,6 +59,62 @@ impl Game<'_> {
let entries = body["entries"].as_array().cloned().unwrap_or_default();
let lists = state.lists.clone();
match body["method"].as_str().unwrap_or_default() {
// `GameRulesService.get`: every rule the server has, with its type and current value.
"gamerules" => {
let rules = &self.ui.rules;
let all: Vec<Value> = rules
.available_rules()
.map(|r| typed_rule(r, rules.get(r.name).unwrap_or(r.default)))
.collect();
return json!({ "value": all });
}
// `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(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)) => 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 value = match value {
Ok(value) => value,
Err(invalid) => return json!({ "invalid": invalid }),
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
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" => {
return json!({ "value": auto_save(state) });
}
@@ -120,6 +186,23 @@ impl Game<'_> {
self.set_force_game_mode(force);
return json!({ "value": state.force_game_mode });
}
"serversettings/max_players" => {
return json!({ "value": state.max_players });
}
"serversettings/max_players/set" => {
let Some(max_players) = body["value"]
.as_i64()
.and_then(|value| usize::try_from(value).ok())
else {
return json!({ "error": "max players must be a non-negative integer" });
};
rpc(format!(
"Update max players from {} to {}",
state.max_players, max_players
));
self.set_max_players(state, max_players);
return json!({ "value": state.max_players });
}
"serversettings/use_allowlist" => {
return json!({
"value": lists.lock().unwrap().use_whitelist
@@ -491,3 +574,22 @@ fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
reason,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_game_rule_is_written_as_vanillas_typed_codec_does() {
let pvp = gamerules::rule("pvp").unwrap();
assert_eq!(
typed_rule(pvp, GameRuleValue::Bool(false)),
json!({ "key": "minecraft:pvp", "type": "boolean", "value": false })
);
let tick = gamerules::rule("minecraft:random_tick_speed").unwrap();
assert_eq!(
typed_rule(tick, GameRuleValue::Int(3)),
json!({ "key": "minecraft:random_tick_speed", "type": "integer", "value": 3 })
);
}
}
+1 -1
View File
@@ -241,7 +241,7 @@ mod tests {
// A shulker bullet takes a blow, with no pick radius round its box.
let bullet = Projectile::new(
ProjectileKind::ShulkerBullet {
target: Who::Player,
target: Some(Who::Player),
dir: None,
steps: 0,
delta: [0.0; 3],
@@ -1216,6 +1216,13 @@ impl Game<'_> {
}
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.boats.push(boat);
if self.serving.is_some() || self.options.dedicated.is_some() {
let boat = self.boats.last().unwrap();
self.native_vehicle_create(
boat.entity_type().as_str(),
[boat.position.x, boat.position.y, boat.position.z],
);
}
(shrink(stack), CLICK)
}
/// `MinecartDispenseItemBehavior`.
@@ -1255,6 +1262,13 @@ impl Game<'_> {
);
self.world.modified.insert((front.0 >> 4, front.2 >> 4));
self.carts.push(cart);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
cart.kind.id(),
[cart.position.x, cart.position.y, cart.position.z],
);
}
(shrink(stack), CLICK)
}
+3
View File
@@ -1456,6 +1456,7 @@ impl Game<'_> {
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
self.dropped.remove(i);
self.native_leave_skip(id);
self.detach_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
@@ -1479,11 +1480,13 @@ impl Game<'_> {
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.native_leave_skip(g);
self.detach_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
self.native_leave_skip(id);
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Pearl(pearl),
+4 -3
View File
@@ -623,7 +623,6 @@ impl Game<'_> {
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
spared: exploding.filter(|_| self.combat.explosion_source_named),
};
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
@@ -723,8 +722,7 @@ impl Game<'_> {
}
self.dropped.drain(items_kept..items_len);
self.orbs.drain(orbs_kept..orbs_len);
self.native_items_left_since(&items_before);
self.native_orbs_left_since(&orbs_before);
self.native_leaves_flush();
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
@@ -856,6 +854,9 @@ impl Game<'_> {
damage_type: &str,
resists: Option<([i32; 3], f32)>,
) -> Result<()> {
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_explosion_prime(center, radius);
}
if let Some(p) = &self.player {
let d = [
p.position.x - center[0],
+212 -74
View File
@@ -36,6 +36,7 @@ pub(super) struct Hook {
pub(super) previous: Vec3,
velocity: Vec3,
state: State,
tick_count: i32,
life: i32,
on_ground: bool,
nibble: i32,
@@ -64,6 +65,67 @@ fn bounds(p: Vec3) -> Aabb {
Vec3::new(p.x + h, p.y + SIZE, p.z + h),
)
}
fn segment_entry(start: Vec3, end: Vec3, bounds: Aabb) -> Option<f64> {
let min = bounds.min_position();
let max = bounds.max_position();
let start = [start.x, start.y, start.z];
let min = [min.x, min.y, min.z];
let max = [max.x, max.y, max.z];
if (0..3).all(|axis| start[axis] >= min[axis] && start[axis] < max[axis]) {
return None;
}
let delta = [end.x - start[0], end.y - start[1], end.z - start[2]];
let mut entry: f64 = 0.0;
let mut exit: f64 = 1.0;
for axis in 0..3 {
if delta[axis] == 0.0 {
if start[axis] < min[axis] || start[axis] > max[axis] {
return None;
}
continue;
}
let a = (min[axis] - start[axis]) / delta[axis];
let b = (max[axis] - start[axis]) / delta[axis];
entry = entry.max(a.min(b));
exit = exit.min(a.max(b));
if entry > exit {
return None;
}
}
(entry <= 1.0 && exit >= 0.0).then_some(entry)
}
/// `ProjectileUtil.computeMargin`: the fishing hook's target-box margin grows
/// to 0.3 blocks over its first eight ticks.
fn entity_hit_margin(tick_count: i32) -> f64 {
f64::from((tick_count - 2).clamp(0, 6)) / 20.0
}
fn first_entity_hit(
start: Vec3,
end: Vec3,
margin: f64,
targets: impl IntoIterator<Item = (u32, Aabb)>,
) -> Option<u32> {
let mut hit = None;
let mut hit_at = f64::INFINITY;
for (id, target_bounds) in targets {
if let Some(at) = segment_entry(
start,
end,
target_bounds.inflate(Vec3::new(margin, margin, margin)),
) && at < hit_at
{
hit = Some(id);
hit_at = at;
}
}
hit
}
fn pull_item(item: &mut horizon_client::entities::ItemEntity, delta: Vec3) {
item.velocity = item.velocity.add_vector(delta);
// The change is made outside ItemEntity::tick, so its velocity comparison
// cannot request the immediate motion update for tracking clients.
item.needs_sync = true;
}
impl Hook {
pub(super) fn bounds(&self) -> Aabb {
bounds(self.position)
@@ -170,6 +232,7 @@ impl Game<'_> {
previous: start,
velocity: v,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -221,7 +284,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.velocity = pulled(m.velocity);
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
pull_item(item, Vec3::new(d.x * 0.1, d.y * 0.1, d.z * 0.1));
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
@@ -566,6 +629,7 @@ impl Game<'_> {
if !self.holds_rod() || d2 > 1024.0 {
return;
}
h.tick_count += 1;
// The players it can catch, as they stand now (the host's own hook;
// guests' hooks use the ones taken as their turns start).
if self.lan_acting.is_none()
@@ -600,58 +664,67 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: hook the first living mob or other player it
// touches, or (via `canHitEntity`) a dropped item.
let bb = bounds(h.position);
let (lo, hi) = (bb.min_position(), bb.max_position());
let touches = |b: Aabb| {
let (a, z) = (b.min_position(), b.max_position());
a.x < hi.x && lo.x < z.x && a.y < hi.y && lo.y < z.y && a.z < hi.z && lo.z < z.z
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
})
.ok()
.flatten()
};
let hit = self
.mobs
.iter()
.find(|m| m.alive() && touches(m.bounds()))
.map(|m| m.id)
.or_else(|| {
let owner = self.lan_acting;
self.creatures
.hook_targets
.iter()
.find(|t| t.conn != owner && touches(t.bounds))
.map(|t| t.id)
})
.or_else(|| {
// Entity.canBeHitByProjectile: `isAlive() && isPickable()`;
// ArmorStand.isPickable is false for a marker.
self.armor_stands
.iter()
.find(|a| a.health > 0.0 && !a.marker && touches(a.bounds()))
.map(|a| a.id)
})
.or_else(|| {
// AbstractBoat.isPickable; Projectile.canHitEntity skips the
// boat the owner rides (approximating `leftOwner`, as the
// players above skip their owner).
let own = self.own_vehicle();
self.boats
.iter()
.find(|b| Some(b.id) != own && touches(b.bounds()))
.map(|b| b.id)
})
.or_else(|| {
self.dropped
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
})
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && touches(t.bounds()))
.map(|t| t.id)
});
let entity_end = block_hit.map_or(to, |hit| hit.location);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
let hit = first_entity_hit(
h.position,
entity_end,
entity_hit_margin(h.tick_count),
self.mobs
.iter()
.filter(|m| m.alive())
.map(|m| (m.id, m.bounds()))
.chain(
self.creatures
.hook_targets
.iter()
.filter(|t| t.conn != owner)
.map(|t| (t.id, t.bounds)),
)
.chain(
self.armor_stands
.iter()
.filter(|a| a.health > 0.0 && !a.marker)
.map(|a| (a.id, a.bounds())),
)
.chain(
self.boats
.iter()
.filter(|b| Some(b.id) != own_vehicle)
.map(|b| (b.id, b.bounds())),
)
.chain(self.dropped.iter().map(|i| (i.id, i.bounds())))
.chain(
self.tnts
.iter()
.filter(|t| t.id != 0)
.map(|t| (t.id, t.bounds())),
),
);
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
self.projectile_event(
@@ -664,26 +737,7 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: the block the path hits before the move, then
// `onHitBlock`'s rebound.
let to = h.position.add_vector(h.velocity);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
if let Some(hit) = block_hit {
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
@@ -1124,6 +1178,7 @@ impl Hook {
previous: at,
velocity: Vec3::ZERO,
state: State::Flying,
tick_count: 0,
life: 0,
on_ground: false,
nibble: 0,
@@ -1419,8 +1474,9 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::rebound;
use horizon_core::geometry::Vec3;
use super::{entity_hit_margin, first_entity_hit, pull_item, rebound};
use horizon_client::{entities::ItemEntity, items::ItemStack};
use horizon_core::geometry::{Aabb, Vec3};
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
#[test]
@@ -1429,4 +1485,86 @@ mod tests {
let hit = Vec3::new(0.0, 0.0, -0.5);
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
}
#[test]
fn reeling_an_item_requests_an_immediate_motion_update() {
let mut item = ItemEntity {
no_gravity: false,
health: 5,
fire_ticks: 0,
id: 1,
stack: ItemStack::new("minecraft:stone", 1),
thrower: None,
target: None,
position: Vec3::ZERO,
previous: Vec3::ZERO,
velocity: Vec3::new(0.2, 0.0, -0.1),
on_ground: true,
vertical_collision: false,
horizontal_collision: false,
age: 0,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 0,
needs_sync: false,
stuck: Vec3::ZERO,
};
pull_item(&mut item, Vec3::new(0.5, 0.1, -0.2));
assert!((item.velocity.x - 0.7).abs() < 1.0e-12);
assert!((item.velocity.y - 0.1).abs() < 1.0e-12);
assert!((item.velocity.z + 0.3).abs() < 1.0e-12);
assert!(item.needs_sync);
}
#[test]
fn a_flying_hook_hits_a_small_target_between_ticks() {
let target = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.8, 0.0, 0.0), target).is_some());
assert!(super::segment_entry(Vec3::ZERO, Vec3::new(0.4, 0.0, 0.0), target).is_none());
}
#[test]
fn a_flying_hook_uses_vanillas_growing_entity_hit_margin() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let target = Aabb::new(Vec3::new(0.5, 0.31, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(entity_hit_margin(2), 0.0);
assert_eq!(entity_hit_margin(8), 0.3);
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, target)]),
None
);
let within_margin = Aabb::new(Vec3::new(0.5, 0.29, -0.1), Vec3::new(0.7, 0.5, 0.1));
assert_eq!(
first_entity_hit(start, end, entity_hit_margin(8), [(1, within_margin)]),
Some(1)
);
}
#[test]
fn a_flying_hook_does_not_hit_a_target_it_starts_inside() {
let target = Aabb::new(Vec3::new(-0.1, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
assert_eq!(
first_entity_hit(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0), 0.0, [(1, target)]),
None
);
}
#[test]
fn a_flying_hook_uses_the_first_target_along_its_path() {
let start = Vec3::ZERO;
let end = Vec3::new(1.0, 0.0, 0.0);
let near = Aabb::new(Vec3::new(0.45, -0.1, -0.1), Vec3::new(0.7, 0.15, 0.1));
let far = Aabb::new(Vec3::new(0.8, -0.1, -0.1), Vec3::new(0.95, 0.15, 0.1));
assert_eq!(
first_entity_hit(start, end, 0.0, [(2, far), (1, near)]),
Some(1)
);
}
}
+12 -7
View File
@@ -623,6 +623,9 @@ impl Game<'_> {
items.push((stack, f.id, m.as_mat4(), item_light));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
let skinning = self.skinning();
let lights =
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
for (stack, id, m, light) in items {
let entity_start = out.len();
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
@@ -632,13 +635,15 @@ impl Game<'_> {
Some(&owner),
id as i32,
);
self.submit_item_world(
&state,
m,
horizon_client::block_entity_render::vertex_light(light),
out,
);
self.cull_entity_vertices(out, entity_start);
let light = horizon_client::block_entity_render::vertex_light(light);
// Posed on the GPU when every layer can be.
let skinned = skinning
.then(|| self.submit_item_skinned(&state, &[m], light, lights))
.flatten();
if skinned.is_none() {
self.submit_item_world(&state, m, light, out);
}
self.cull_skinned(out, entity_start, skinned);
}
for (id, m, light) in maps {
let entity_start = out.len();
+26 -9
View File
@@ -478,20 +478,33 @@ impl Game<'_> {
frame: &mut frame,
bounds: None,
};
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
for &pose in poses {
let renderer = &self.pack.item_renderer;
if !state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, &mut skin) {
if !self.skin_item(state, pose, light, lights, &mut skin) {
// Decided before any draw, the same for every pose.
return None;
}
}
Some((mark, skin.bounds))
}
/// One pose of [`Self::submit_item_skinned`] into an entity's open
/// `skin` (its bounds grow by the item's): false, with nothing drawn,
/// when the item cannot take the GPU path.
pub(super) fn skin_item(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
lights: (glam::Vec3, glam::Vec3),
skin: &mut horizon_client::mobs::skin::Skin,
) -> bool {
let sink = (
item_hdr_light(state, light),
(state.hdr_light == 0).then_some(lights),
state.hdr_tier,
);
let renderer = &self.pack.item_renderer;
state.submit_skinned(renderer, &self.gui.atlas.sprites, pose, sink, skin)
}
/// [`Self::submit_item_world`] of `state` under `pose` on the GPU path, its draws counted
/// with an entity's (`skinned`: its mark and bounds, `cull_skinned`'s); false, with
/// nothing drawn, when the state cannot take it.
@@ -605,8 +618,8 @@ impl Game<'_> {
seed,
);
let glowing = self.entity_glowing(item.id);
// Posed on the GPU when every layer can be (the outline reads the CPU's vertices).
let skinned = (skinning && !glowing)
// Posed on the GPU when every layer can be, its silhouette too when it glows.
let skinned = skinning
.then(|| self.submit_item_skinned(&state, &copies, vertex_light(packed), lights))
.flatten();
if skinned.is_none() {
@@ -617,6 +630,10 @@ impl Game<'_> {
if glowing {
let rgb = super::glowing::team_color(self.mob_team(item.id));
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
} else {
self.cull_skinned(out, entity_start, skinned);
}
+64
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;
@@ -1939,6 +1940,47 @@ impl Game<'_> {
HostEvent::Flight { conn, flying } => {
self.lan_flight_toggled(&mut host, conn, flying);
}
HostEvent::Sneak { conn, sneaking } => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.as_guest(&mut host, conn, |game| {
game.native_player_toggle_sneak(uuid, &name, sneaking);
});
}
}
HostEvent::Kicked {
conn,
player,
name,
reason,
} => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
// The same poll can have removed the guest (their `Left`): the mods hear the
// kick anyway, without the guest's state swapped in.
if host.hub.guest(conn).is_some() {
self.as_guest(&mut host, conn, |game| {
game.native_player_kicked(player, &name, &reason);
});
} else {
self.native_player_kicked(player, &name, &reason);
}
}
HostEvent::Input { conn, flags } => {
if horizon_client::mod_api::mods().is_empty() {
continue;
}
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.as_guest(&mut host, conn, |game| {
game.native_player_input(uuid, &name, flags);
});
}
}
HostEvent::MainHandChanged(conn, left) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
@@ -1951,6 +1993,25 @@ impl Game<'_> {
self.native_player_locale_change(uuid, &name, &locale);
}
}
HostEvent::PlayerLoaded(conn) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_client_loaded_world(uuid, &name);
}
}
HostEvent::RecipeBookSettings {
conn,
book,
open,
filtering,
} => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_recipe_book_settings_change(
uuid, &name, book, open, filtering,
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
@@ -2002,6 +2063,7 @@ impl Game<'_> {
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
let pass = self.level_pass;
let mut events = if pass.first {
host.hub.poll(&mut ctx)
@@ -2184,6 +2246,7 @@ impl Game<'_> {
// (`tick_server`): the network is read once, before the first.
// Player packets may already have been drained before
// scheduled block ticks (`tick_lan_packets`).
host.hub.mods_loaded = !horizon_client::mod_api::mods().is_empty();
let pass = self.level_pass;
let mut events = if pass.first && !pass.packets_drained {
host.hub.poll(&mut ctx)
@@ -2273,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));
@@ -769,6 +769,34 @@ impl Game<'_> {
p.guest = Some(conn);
}
}
let guest = &host.hub.guests[i];
let listening = !horizon_client::mod_api::mods().is_empty();
for projectile in projectiles..self.creatures.projectiles.len() {
if !listening {
break;
}
let item = match &self.creatures.projectiles[projectile].kind {
horizon_client::projectiles::ProjectileKind::Snowball => {
Some("minecraft:snowball".to_owned())
}
horizon_client::projectiles::ProjectileKind::Egg => {
Some("minecraft:egg".to_owned())
}
horizon_client::projectiles::ProjectileKind::EnderPearl => {
Some("minecraft:ender_pearl".to_owned())
}
horizon_client::projectiles::ProjectileKind::Trident => {
Some("minecraft:trident".to_owned())
}
horizon_client::projectiles::ProjectileKind::Potion(stack, _) => {
Some(stack.item.to_string())
}
_ => None,
};
if let Some(item) = item {
self.native_player_launch_projectile(guest.uuid, &guest.name, &item);
}
}
// The guest's swings are theirs: the host's own count stays the host's.
play.swings += self.swings_started.wrapping_sub(swings);
self.swings_started = swings;
@@ -1836,6 +1864,8 @@ impl Game<'_> {
}),
Some(conn),
);
let guest = &host.hub.guests[gi];
self.native_block_damage_abort(guest.uuid, &guest.name, a.pos);
}
4 | 5 => {
if !spectator {
@@ -1845,6 +1875,13 @@ impl Game<'_> {
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
// hand of the item in use acting.
6 => {
let listening = !horizon_client::mod_api::mods().is_empty();
let (uuid, name) = if listening {
let guest = &host.hub.guests[gi];
(guest.uuid, guest.name.clone())
} else {
(0, String::new())
};
self.as_guest(host, conn, |g| {
let Some(hand) = g.used_hand() else {
return;
@@ -1853,7 +1890,18 @@ impl Game<'_> {
if let Some((slot, ticks)) = g.using.take()
&& let Some(stack) = g.stack_in(slot).cloned()
{
if listening
&& &*stack.item == "minecraft:bow"
&& let Some(arrow) = g
.projectile_slot("minecraft:bow")
.and_then(|slot| g.stack_in(slot).map(|s| s.item.clone()))
{
g.native_player_ready_arrow(uuid, &name, &arrow);
}
g.release_used_item(&stack, ticks, false);
if listening {
g.native_player_stop_using_item(uuid, &name);
}
}
});
});
@@ -1957,6 +2005,7 @@ impl Game<'_> {
g.reset_attack_strength();
});
self.native_player_attack_cooldown_reset(player, &name);
self.native_player_arm_swing(player, &name, "mainhand");
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
@@ -2302,6 +2351,8 @@ impl Game<'_> {
if !spectator && let Some(slot) = slot {
host.hub
.select_bundle_item(conn, slot, p.index, &mut self.world);
let guest = &host.hub.guests[gi];
self.native_player_bundle_item_select(guest.uuid, &guest.name, p.slot, p.index);
}
}
S::ContainerClick(p) => {
@@ -4051,6 +4102,7 @@ impl Game<'_> {
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let riding = g.vehicle.is_some();
let mut soul = None;
let mut jumps = 0;
let events = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -4111,6 +4163,7 @@ impl Game<'_> {
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
// the server's copy jumps with the player, so the velocity
// it sends them keeps the jump.
jumps += 1;
let _ = w.jump_from_ground();
}
let landed = (|| -> horizon_client::Result<()> {
@@ -4225,6 +4278,16 @@ impl Game<'_> {
let Some((events, marked)) = events else {
return;
};
if jumps > 0
&& !horizon_client::mod_api::mods().is_empty()
&& let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
self.as_guest(host, conn, |game| {
for _ in 0..jumps {
game.native_player_jump(uuid, &name);
}
});
}
if let Some((at, velocity)) = soul {
super::lan_particles::send_soul_particle(host, at, velocity);
}
@@ -126,7 +126,13 @@ impl Game<'_> {
if let Some(maps) = maps {
self.register_made_maps(maps);
}
let dropped = std::mem::take(&mut menu.dropped);
// A taken anvil result waits for AnvilCraftEvent.Pre: what the click
// dropped waits with it (lan_station_op throws it or refuses the take).
let dropped = if menu.anvil_craft.is_some() {
Vec::new()
} else {
std::mem::take(&mut menu.dropped)
};
let equipped = std::mem::take(&mut menu.equipped);
let brewed = std::mem::take(&mut menu.brewed);
let crafted = std::mem::take(&mut menu.crafted);
+54 -11
View File
@@ -13,7 +13,7 @@ use super::{
use horizon_client::{
containers::BlockEntity,
items::ItemStack,
menu::{Kind, Menu},
menu::{AnvilCraft, Kind, Menu},
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, Clientbound as C};
@@ -80,6 +80,8 @@ impl Game<'_> {
return true;
};
let mut menu = Box::new(Menu::new(kind));
// Without a mod the take runs AnvilMenu.onTake's order in the click itself.
menu.anvil_hook = kind == Kind::Anvil && !horizon_client::mod_api::mods().is_empty();
if let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& kind == Kind::Enchanting
{
@@ -136,14 +138,42 @@ impl Game<'_> {
let slots: Vec<_> = (0..=r).map(|s| gs.menu.slot(s).cloned()).collect();
(slots, gs.menu.data[0])
});
self.guest_menu_op(host, gi, &mut gs.menu, op);
let recomputed = before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
let mut undo = None;
let hook = gs.menu.anvil_hook;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, env| {
undo = hook.then(|| menu.anvil_undo());
op(menu, env);
});
// AnvilCraftEvent.Pre: the taken result's onTake waits for the mods.
let mut anvil_craft = gs.menu.anvil_craft.take();
let mut cancelled = false;
if let Some(craft) = &anvil_craft {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
let mut cancel = false;
self.native_anvil_craft_pre(uuid, &name, gs.pos, craft, &mut cancel);
if cancel && let Some(undo) = undo {
// The take is refused: the result, inputs, cost, inventory
// and cursor are as before the click (the resync below
// tells the client).
anvil_craft = None;
cancelled = true;
self.guest_menu_op(host, gi, &mut gs.menu, |menu, _| menu.anvil_cancel(undo));
} else {
gs.menu.anvil_take(&self.menu_env());
let dropped = std::mem::take(&mut gs.menu.dropped);
self.drop_guest_items(host, gi, dropped);
}
}
// A cancelled take runs no createResult: the inputs are untouched.
let recomputed = !cancelled
&& before.is_some_and(|(slots, cost)| {
cost != gs.menu.data[0]
|| slots
.iter()
.enumerate()
.any(|(s, b)| gs.menu.slot(s) != b.as_ref())
});
if recomputed && gs.kind == StationKind::Anvil {
let guest = &host.hub.guests[gi];
let (uuid, name) = (guest.uuid, guest.name.clone());
@@ -172,7 +202,7 @@ impl Game<'_> {
if let Some(amount) = gs.menu.ground.take() {
self.grindstone_taken(gs.pos, amount);
}
self.lan_station_combined(host, conn, &mut gs);
self.lan_station_combined(host, conn, &mut gs, anvil_craft.as_ref());
if let Some(button) = gs.menu.enchant_button.take() {
self.lan_enchant(host, gi, &mut gs, button);
}
@@ -305,8 +335,15 @@ impl Game<'_> {
/// The rest of a taken anvil or smithing result's `onTake`: the guest
/// pays the levels, the anvil wears (level event 1030 or 1029), the
/// smithing table sounds (1044).
fn lan_station_combined(&mut self, host: &mut LanHost, conn: u32, gs: &mut GuestStation) {
/// smithing table sounds (1044). An anvil's `craft` is then reported to
/// the mods (`AnvilCraftEvent.Post`).
fn lan_station_combined(
&mut self,
host: &mut LanHost,
conn: u32,
gs: &mut GuestStation,
craft: Option<&AnvilCraft>,
) {
let change = std::mem::take(&mut gs.menu.level_change);
if change != 0 {
self.as_guest(host, conn, |g| g.give_levels(change));
@@ -320,6 +357,12 @@ impl Game<'_> {
let creative = gs.menu.creative;
// A broken anvil closes the menu on the next stillValid check.
self.anvil_used(gs.pos, creative);
if let Some(craft) = craft
&& let Some(guest) = host.hub.guests.iter().find(|g| g.conn == conn)
{
let (uuid, name) = (guest.uuid, guest.name.clone());
self.native_anvil_craft_post(uuid, &name, gs.pos, craft);
}
}
}
+1
View File
@@ -146,6 +146,7 @@ impl Game<'_> {
}) else {
return;
};
self.native_entity_unleash(id, &kind);
if used
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
+2 -4
View File
@@ -635,7 +635,6 @@ impl Game<'_> {
// over the 40 a vehicle takes) destroys them. Lightning is no fire
// source, so a TNT cart does not prime. `ExperienceOrb.hurtServer`:
// 5 damage off 5 health. Last first, since each removes its entry.
let orbs_before = self.orb_ids();
let mut left = self.thunder_count(strike, ThunderHit::Vehicle)
+ self.thunder_count(strike, ThunderHit::Orb);
for h in self.entity_handles_rev() {
@@ -659,7 +658,7 @@ impl Game<'_> {
_ => {}
}
}
self.native_orbs_left_since(&orbs_before);
self.native_leaves_flush();
// Every player in the box, the server's players too (0.6 x 1.8).
let strike_players = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
@@ -679,7 +678,6 @@ impl Game<'_> {
strike_players(self);
self.each_guest_hit(strike_players);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
let before = self.dropped_ids();
let mut left = self.thunder_count(strike, ThunderHit::Item);
for h in self.entity_handles_rev() {
if left == 0 {
@@ -696,7 +694,7 @@ impl Game<'_> {
self.dropped.remove(i);
}
}
self.native_items_left_since(&before);
self.native_leaves_flush();
struck
}
/// `LightningBolt.shouldRenderAtSqrDistance`: a bolt is drawn within 64
+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;
@@ -740,6 +740,7 @@ impl Game<'_> {
if self.hoppers.get(&pos).is_some_and(|h| h.cooldown > 0) || !enabled {
return;
}
self.native_prepare_hopper_transfer([pos.0, pos.1, pos.2]);
let mut changed = false;
if !c.is_empty(&view) {
changed = self.hopper_eject(pos, &view);
@@ -866,6 +867,11 @@ impl Game<'_> {
let front = offset(pos, facing);
let face = facing.opposite();
if let Some(target) = c.view(front, false) {
self.native_hopper_inventory_search(
[pos.0, pos.1, pos.2],
Some([front.0, front.1, front.2]),
None,
);
return self.eject_into(pos, hopper, &target, face);
}
let Some(id) = self.entity_container_at([
@@ -875,15 +881,62 @@ impl Game<'_> {
]) else {
return false;
};
self.native_hopper_inventory_search([pos.0, pos.1, pos.2], None, Some(id));
self.with_entity_container(id, |g, target| g.eject_into(pos, hopper, target, face))
.unwrap_or(false)
}
/// Where a container is in the world for the mods: its block, or the block a chest
/// boat or minecart is in (its view sits under a placeholder key).
fn container_world_pos(&self, view: &View) -> [i32; 3] {
let (x, y, z) = view.parts[0].0;
if x == i32::MAX - 15 && y == super::minecarts::CART_KEY_Y {
let id = z as u32;
let at = self
.carts
.iter()
.find(|c| c.id == id)
.map(|c| c.position)
.or_else(|| self.boats.iter().find(|b| b.id == id).map(|b| b.position));
if let Some(p) = at {
return [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
}
}
[x, y, z]
}
/// `InventoryMoveItemEvent` for the item in `slot` of `holder` going from `source` to
/// `destination`: whether a mod canceled the move. Nothing is looked up with no mod loaded.
fn hopper_move_canceled(
&self,
holder: &View,
slot: usize,
source: &View,
destination: &View,
) -> bool {
if horizon_client::mod_api::mods().is_empty() {
return false;
}
let containers = self.containers();
let Some(stack) = containers.get(holder, slot) else {
return false;
};
let mut cancel = false;
self.native_inventory_move_item(
self.container_world_pos(source),
self.container_world_pos(destination),
&stack.item,
&mut cancel,
);
cancel
}
/// The rest of `HopperBlockEntity.ejectItems`: one item into `target`.
fn eject_into(&mut self, pos: Pos, hopper: &View, target: &View, face: Direction) -> bool {
if self.containers().is_full(target, face) {
return false;
}
for slot in 0..hopper.len() {
if self.hopper_move_canceled(hopper, slot, hopper, target) {
continue;
}
let Some(one) = self.take_one(hopper, slot) else {
continue;
};
@@ -946,9 +999,27 @@ impl Game<'_> {
let c = self.containers();
let Some(source) = c.view(above, false) else {
let id = self.entity_container_at(at)?;
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
None,
Some(id),
);
return self
.with_entity_container(id, |g, source| g.suck_from_slots(None, source, hopper));
};
self.native_hopper_inventory_search(
[
hopper.parts[0].0.0,
hopper.parts[0].0.1,
hopper.parts[0].0.2,
],
Some([above.0, above.1, above.2]),
None,
);
if let Kind::Composter(level) = source.kind {
if level != 8 {
return Some(false);
@@ -959,6 +1030,18 @@ impl Game<'_> {
// room, the setItem putting the bone meal back into the detached
// container (whose bone meal is lost with it).
let bone_meal = c.bone_meal.clone();
if !horizon_client::mod_api::mods().is_empty() {
let mut cancel = false;
self.native_inventory_move_item(
[above.0, above.1, above.2],
self.container_world_pos(hopper),
&bone_meal.item,
&mut cancel,
);
if cancel {
return Some(false);
}
}
let at = BlockPos::new(above.0, above.1, above.2);
self.with_level(|b, level, ticks| b.composter_empty(level, ticks, at));
let moved = self.add_item(None, hopper, bone_meal, None).is_none();
@@ -984,6 +1067,9 @@ impl Game<'_> {
if !c.can_take(source, slot, stack, Direction::Down) {
continue;
}
if self.hopper_move_canceled(source, slot, source, hopper) {
continue;
}
let Some(one) = self.take_one(source, slot) else {
continue;
};
+9
View File
@@ -141,6 +141,15 @@ impl Game<'_> {
json!({ "active": active, "cached": cached })
}),
"renderer": renderer,
// A build with the `alloc-census` feature: the live heap by the
// startup step, span or pool job that allocated it, else its thread.
"heap_by_tag_mb": horizon_threads::HEAP_CENSUS.then(|| {
horizon_threads::heap_census::census()
.into_iter()
.take(60)
.map(|(tag, bytes)| json!([tag, mb(bytes.max(0) as u64)]))
.collect::<Vec<_>>()
}),
})
}
}
+22 -1
View File
@@ -1875,6 +1875,13 @@ impl Game<'_> {
.modified
.insert((hit.pos[0] >> 4, hit.pos[2] >> 4));
self.carts.push(cart);
if self.serving.is_some() || self.options.dedicated.is_some() {
let cart = self.carts.last().unwrap();
self.native_vehicle_create(
cart.kind.id(),
[cart.position.x, cart.position.y, cart.position.z],
);
}
if !self.creative {
self.consume_held();
}
@@ -2134,7 +2141,13 @@ impl Game<'_> {
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
/// once for creative players); TNT minecarts prime instead when moving
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
pub(super) fn hurt_cart(&mut self, i: usize, mut damage: f32, creative: bool, fire: bool) {
let kind = self.carts[i].kind.id();
let mut cancel = false;
self.native_vehicle_damage(kind, &mut damage, &mut cancel);
if cancel {
return;
}
let destroyed = {
let c = &mut self.carts[i];
c.hurt_dir = -c.hurt_dir;
@@ -2154,6 +2167,14 @@ impl Game<'_> {
if !tnt_prime {
self.release_vehicle_container(id);
}
if !tnt_prime {
let at = [
self.carts[i].position.x,
self.carts[i].position.y,
self.carts[i].position.z,
];
self.native_vehicle_destroy(self.carts[i].kind.id(), at);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
+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> {
@@ -483,6 +483,9 @@ impl Host for super::Game<'_> {
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
self.native_item_stack(batch, stack, at, size)
}
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
self.native_mod_image(width, height, rgba)
}
fn instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
+31 -4
View File
@@ -273,10 +273,20 @@ fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
/// Whether the launcher built this game with native mods (`built`: its [`BUILD_INFO`]).
fn built_with_mods(built: &str) -> bool {
/// The native mods the launcher built this game with, `id version, id version`, for the crash
/// report (`built`: its [`BUILD_INFO`]). None for a release or Nightly, which have no mods.
fn native_mods_detail(built: &str) -> Option<String> {
let info: serde_json::Value = serde_json::from_str(built).unwrap_or_default();
info["mods"].as_array().is_some_and(|mods| !mods.is_empty())
let list = info["mods"]
.as_array()?
.iter()
.map(|m| {
let text = |key: &str| m[key].as_str().unwrap_or("?").to_owned();
format!("{} {}", text("id"), text("version"))
})
.collect::<Vec<_>>()
.join(", ");
(!list.is_empty()).then_some(list)
}
/// A row for each native mod the launcher installed in `dir` (`<id>.hnmod`) that this game wasn't
@@ -366,8 +376,13 @@ impl Game<'_> {
.ok()
.and_then(|exe| std::fs::read_to_string(exe.with_file_name(BUILD_INFO)).ok())
.unwrap_or_default();
let built_mods = native_mods_detail(&built);
let built_with_mods = built_mods.is_some();
if let Some(list) = built_mods {
horizon_client::crash::set_detail("Native Mods", list);
}
horizon_client::gfx::native_render::set_mods_built_in(
built_with_mods(&built)
built_with_mods
|| self
.settings
.mods
@@ -428,6 +443,18 @@ mod tests {
zip.finish().unwrap();
}
#[test]
fn the_crash_report_lists_the_builds_native_mods() {
let built = r#"{"mods":[{"id":"machine_sample","version":"0.1.0","sha256":null},{"id":"pipes","version":"2.0"}]}"#;
assert_eq!(
native_mods_detail(built).as_deref(),
Some("machine_sample 0.1.0, pipes 2.0")
);
// A release has no file; a build without mods has an empty list.
assert_eq!(native_mods_detail(""), None);
assert_eq!(native_mods_detail(r#"{"mods":[]}"#), None);
}
#[test]
fn native_mods_installed_but_not_built_get_a_row_saying_so() {
let dir = std::env::temp_dir().join(format!("horizon-not-built-{}", std::process::id()));
+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 {
+177 -21
View File
@@ -258,9 +258,38 @@ impl Game<'_> {
}
}
/// Entities joining the level (`native_entity_join_level`), for every kind in
/// the entity walk. Kept out of `native_hooks.rs`, whose lines may not hold macros.
/// The entities the walk does not hold: Horizon keeps these in lists of their own.
#[derive(Clone, Copy)]
enum Apart {
Crystal(usize),
Cushion(usize),
Painting(usize),
Frame(usize),
Dragon,
}
/// One entity a mod is told of: in the walk, or apart from it.
#[derive(Clone, Copy)]
enum Slot {
Walk(super::entity::EntityHandle),
Apart(Apart),
}
/// Entities joining and leaving the level (`native_entity_join_level`,
/// `native_entity_leave_level`), for every kind in the entity walk and the
/// five kept apart from it. Kept out of `native_hooks.rs`, whose lines may not hold macros.
impl Game<'_> {
/// Whether an entity may be waiting to be told of: one with an id above
/// the last announced, or one of the kinds that take their id late and
/// have none. Reads a counter and five lists' ids; no walk.
fn joins_pending(&self) -> bool {
self.next_entity > self.persist.joined_mark
|| self.tnts.iter().any(|t| t.id == 0)
|| self.falling.iter().any(|f| f.id == 0)
|| self.creatures.projectiles.iter().any(|p| p.id == 0)
|| self.tridents.iter().any(|t| t.id == 0)
|| self.rockets.iter().any(|r| r.id == 0)
}
/// Tells `native_entity_join_level` once for every entity added since the
/// last call, of any kind, in the order they were created (ids count up
/// from one counter). A mod that cancels takes the entity back out here,
@@ -268,11 +297,10 @@ impl Game<'_> {
/// `loaded_from_disk` is for the entities that came with a chunk. Returns
/// whether `watch` (an id, or 0) is still in the level. Walks the lists
/// with static dispatch and allocates nothing but the kind name of an
/// entity it announces; nothing runs when no mod is loaded or on a client
/// of a server (which tells nothing).
/// entity it announces; nothing runs when no mod is loaded, on a client
/// of a server (which tells nothing), or when nothing is new.
pub(super) fn native_joins_flush(&mut self, loaded_from_disk: bool, watch: u32) -> bool {
use super::entity::Entity;
if self.remote.is_some() || crate::mod_api::mods().is_empty() {
if self.remote.is_some() || crate::mod_api::mods().is_empty() || !self.joins_pending() {
return true;
}
for t in &mut self.tnts {
@@ -291,11 +319,9 @@ impl Game<'_> {
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
}
let mut kept = true;
while let Some((handle, id)) = self.next_unannounced() {
while let Some((slot, id)) = self.next_unannounced() {
self.persist.joined_mark = id;
let (kind, at) = super::entity::with_entity!(self, handle, e => {
(e.native_type(), e.position())
});
let (kind, at) = self.slot_kind_and_position(slot);
let mut canceled = false;
let dimension = self.dim.id();
self.native_entity_join_level(
@@ -311,32 +337,162 @@ impl Game<'_> {
self.forget_entity(id);
// Handles are indexes: the entity is taken out before any
// other change to its list.
self.discard_entity(handle);
self.discard_slot(slot);
kept &= id != watch;
} else {
self.persist.live.push((id, kind.into_owned(), true));
}
}
kept
}
/// The entity with the lowest id above the last one announced, by handle.
fn next_unannounced(&self) -> Option<(super::entity::EntityHandle, u32)> {
/// The entity with the lowest id above the last one announced.
fn next_unannounced(&self) -> Option<(Slot, u32)> {
use super::entity::Entity;
let mark = self.persist.joined_mark;
let mut best: Option<(super::entity::EntityHandle, u32)> = None;
for handle in self.entity_handles() {
let id = {
use super::entity::Entity;
super::entity::with_entity!(self, handle, e => e.id())
};
let mut best: Option<(Slot, u32)> = None;
let mut offer = |slot: Slot, id: u32| {
if id > mark && best.is_none_or(|(_, b)| id < b) {
best = Some((handle, id));
best = Some((slot, id));
}
};
for handle in self.entity_handles() {
offer(
Slot::Walk(handle),
super::entity::with_entity!(self, handle, e => e.id()),
);
}
for (i, c) in self.crystals.iter().enumerate() {
offer(Slot::Apart(Apart::Crystal(i)), c.id);
}
for (i, c) in self.cushions.iter().enumerate() {
offer(Slot::Apart(Apart::Cushion(i)), c.id);
}
for (i, p) in self.paintings.iter().enumerate() {
offer(Slot::Apart(Apart::Painting(i)), p.id);
}
for (i, f) in self.item_frames.iter().enumerate() {
offer(Slot::Apart(Apart::Frame(i)), f.id);
}
if let Some(d) = &self.dragon {
offer(Slot::Apart(Apart::Dragon), d.id);
}
best
}
/// The type id and position a mod is told of.
fn slot_kind_and_position(&self, slot: Slot) -> (std::borrow::Cow<'static, str>, Vec3) {
use super::entity::Entity;
let block = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
f64::from(p[1]) + 0.5,
f64::from(p[2]) + 0.5,
)
};
match slot {
Slot::Walk(h) => {
super::entity::with_entity!(self, h, e => (e.native_type(), e.position()))
}
Slot::Apart(Apart::Crystal(i)) => {
("minecraft:end_crystal".into(), self.crystals[i].position)
}
Slot::Apart(Apart::Cushion(i)) => {
("minecraft:cushion".into(), self.cushions[i].position)
}
Slot::Apart(Apart::Painting(i)) => {
("minecraft:painting".into(), block(self.paintings[i].pos))
}
Slot::Apart(Apart::Frame(i)) => {
let f = &self.item_frames[i];
let kind = if f.glow {
"minecraft:glow_item_frame"
} else {
"minecraft:item_frame"
};
(kind.into(), block(f.pos))
}
Slot::Apart(Apart::Dragon) => (
"minecraft:ender_dragon".into(),
self.dragon.as_ref().map_or(Vec3::ZERO, |d| d.position),
),
}
}
/// Takes the entity out of the level, before it ever ticked.
fn discard_slot(&mut self, slot: Slot) {
match slot {
Slot::Walk(h) => self.discard_entity(h),
Slot::Apart(Apart::Crystal(i)) => drop(self.crystals.remove(i)),
Slot::Apart(Apart::Cushion(i)) => drop(self.cushions.remove(i)),
Slot::Apart(Apart::Painting(i)) => drop(self.paintings.remove(i)),
Slot::Apart(Apart::Frame(i)) => drop(self.item_frames.remove(i)),
Slot::Apart(Apart::Dragon) => self.dragon = None,
}
}
/// Entities that were moved, not added (a portal's arrival keeps being
/// silent): everything now in the level counts as announced.
/// silent): everything now in the level counts as announced, and will
/// be told when it leaves.
pub(super) fn native_joins_skip(&mut self) {
if self.remote.is_none() && !crate::mod_api::mods().is_empty() {
while let Some((slot, id)) = self.next_unannounced() {
self.persist.joined_mark = id;
let kind = self.slot_kind_and_position(slot).0.into_owned();
self.persist.live.push((id, kind, true));
}
}
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
}
/// An entity taken out of the level without leaving it (an item through
/// an End gateway): forgotten, not told of.
pub(super) fn native_leave_skip(&mut self, id: u32) {
if let Ok(i) = self.persist.live.binary_search_by_key(&id, |l| l.0) {
self.persist.live.remove(i);
}
}
/// Entity `id` has left the level: told once, if mods were told it joined.
pub(super) fn native_entity_left(&mut self, id: u32) {
if self.remote.is_some() {
return;
}
if let Ok(i) = self.persist.live.binary_search_by_key(&id, |l| l.0) {
let (id, kind, _) = self.persist.live.remove(i);
let dimension = self.dim.id();
self.native_entity_leave_level(dimension, id, &kind);
}
}
/// Tells `native_entity_leave_level` for every announced entity that is
/// no longer in any list (died, despawned, picked up, burnt, blown up, gone
/// through a portal, unloaded), lowest id first. Marks the entities still
/// there in the registry and tells the rest: no allocation, and nothing
/// runs when no mod is loaded or none was announced.
pub(super) fn native_leaves_flush(&mut self) {
use super::entity::Entity;
if self.persist.live.is_empty() || self.remote.is_some() {
return;
}
for l in &mut self.persist.live {
l.2 = false;
}
let live = &mut self.persist.live;
let mut seen = |id: u32| {
if let Ok(i) = live.binary_search_by_key(&id, |l| l.0) {
live[i].2 = true;
}
};
super::entity::for_each_entity!(super::entity::entity_lists_of!(self), e => {
seen(e.id());
});
self.crystals.iter().for_each(|c| seen(c.id));
self.cushions.iter().for_each(|c| seen(c.id));
self.paintings.iter().for_each(|p| seen(p.id));
self.item_frames.iter().for_each(|f| seen(f.id));
if let Some(d) = &self.dragon {
seen(d.id);
}
while let Some(i) = self.persist.live.iter().position(|l| !l.2) {
let (id, kind, _) = self.persist.live.remove(i);
let dimension = self.dim.id();
self.native_entity_leave_level(dimension, id, &kind);
}
}
}
#[cfg(test)]
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;
+5 -2
View File
@@ -461,11 +461,14 @@ pub(super) fn stage_pack(
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
// each other: about a second each, side by side.
let mod_image = !horizon_client::mod_api::mods().is_empty();
// The sound events, the GUI sprites' metadata and the language are read here too, not in
// the frame that applies the reload (about 20 ms of it).
let (render, atlas, panorama, (sounds, ui_metadata, language)) = std::thread::scope(|s| {
let atlas =
s.spawn(|| HudAtlas::build_over(&root, vanilla_root).map_err(|e| e.to_string()));
let atlas = s.spawn(|| {
HudAtlas::build_over_with_image(&root, vanilla_root, mod_image)
.map_err(|e| e.to_string())
});
let panorama = s.spawn(|| horizon_client::gfx::panorama::Faces::load(&root, vanilla_root));
let small = s.spawn(|| {
(
+24 -7
View File
@@ -45,6 +45,10 @@ pub struct EntityMemory {
pub(super) joins_canceled: u32,
/// The highest entity id `native_entity_join_level` has been told of.
pub(super) joined_mark: u32,
/// The entities mods were told of and that have not left since: id (in
/// increasing order), type, and whether the last leave sweep saw it.
/// Only filled while a mod is loaded.
pub(super) live: Vec<(u32, String, bool)>,
}
/// The key that marks a compound queued with the chunk loads as a fresh add
@@ -355,6 +359,10 @@ impl Game<'_> {
kind.as_str(),
"minecraft:experience_orb"
| "minecraft:arrow"
| "minecraft:spectral_arrow"
| "minecraft:llama_spit"
| "minecraft:shulker_bullet"
| "minecraft:experience_bottle"
| "minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:wind_charge"
@@ -398,7 +406,7 @@ impl Game<'_> {
self.remember_tags(id, &tag);
}
}
"minecraft:arrow" => {
"minecraft:arrow" | "minecraft:spectral_arrow" => {
if let Some((mut p, in_block)) = entity_nbt::arrow_from_nbt(
&tag,
player_uuid,
@@ -431,6 +439,17 @@ impl Game<'_> {
self.remember_last_projectile_tags(&tag);
}
}
"minecraft:llama_spit" | "minecraft:shulker_bullet" => {
if let Some(mut p) = entity_nbt::spit_or_bullet_from_nbt(&tag, player_uuid)
{
self.claim_owner(&mut p);
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
}
id if entity_nbt::throwable_kind(id).is_some() => {
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
@@ -1326,7 +1345,6 @@ impl Game<'_> {
})
.map(|i| self.mobs[i].id)
.collect();
let mobs_before = self.mob_ids();
self.mobs.retain(|m| {
let keep = !leaving.contains(&m.id);
if !keep {
@@ -1334,7 +1352,6 @@ impl Game<'_> {
}
keep
});
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
self.dropped.retain(|e| {
let keep = !gone(e.position);
if !keep {
@@ -1344,9 +1361,7 @@ impl Game<'_> {
});
self.orbs.retain(|o| !gone(o.position));
// Unloaded with their chunk: PersistentEntitySectionManager.stopTracking.
self.native_mobs_left_since(&mobs_before);
self.native_items_left_since(&items_before);
self.native_orbs_left_since(&orbs_before);
self.native_leaves_flush();
// the ones saved with the chunk go with it (a trident too, unsaved).
self.creatures.projectiles.retain(|p| {
let leaves = entity_nbt::saved_with_chunk(p) || p.arrow.is_some();
@@ -1512,7 +1527,8 @@ impl Game<'_> {
.map(|s| self.state_to_nbt(s));
let tag = entity_nbt::arrow_to_nbt(p, uuid, owner, in_block, raw.as_ref())
.or_else(|| entity_nbt::fireball_to_nbt(p, uuid, owner, raw.as_ref()))
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()))?;
.or_else(|| entity_nbt::throwable_to_nbt(p, uuid, owner, raw.as_ref()))
.or_else(|| entity_nbt::spit_or_bullet_to_nbt(p, uuid, owner, raw.as_ref()))?;
Some((
chunk_of(p.position),
with_raw(tag, self.persist.raw.get(&p.id)),
@@ -1770,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);
}
}
}
+56 -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));
}
}
}
@@ -5741,6 +5781,20 @@ impl Game<'_> {
if self.integrated.is_none() && self.server_address().is_some() {
keys.analog = None;
}
let [mut strafe, mut forward] = keys.direction();
let original = [strafe, forward];
self.native_movement_input(&mut forward, &mut strafe, &mut keys.jump, &mut keys.shift);
if [strafe, forward] != original {
if forward != original[1] {
keys.forward = forward > 0.0;
keys.backward = forward < 0.0;
}
if strafe != original[0] {
keys.left = strafe > 0.0;
keys.right = strafe < 0.0;
}
keys.analog = Some([strafe, forward]);
}
let input = u8::from(keys.forward)
| u8::from(keys.backward) << 1
| u8::from(keys.left) << 2
@@ -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
@@ -98,7 +98,7 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
// Homing state stays on the server; the client interpolates its positions.
"minecraft:shulker_bullet" => ProjectileKind::ShulkerBullet {
target: horizon_client::mobs::Who::Player,
target: Some(horizon_client::mobs::Who::Player),
dir: None,
steps: 0,
delta: [0.0; 3],
+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};
+4 -1
View File
@@ -222,7 +222,10 @@ impl Game<'_> {
if r.trial {
tag.insert("PersistenceRequired".into(), Tag::Byte(1));
}
let tag = Tag::Compound(tag);
let mut tag = Tag::Compound(tag);
// EntityType.loadEntityRecursive applies the spawner's placement
// callback to every passenger as well as the root.
super::commands::place_summoned(&mut tag, r.position);
let mob = horizon_client::entity_nbt::mob_from_nbt(
&tag,
self.res.mob_types(),
@@ -979,6 +979,7 @@ impl Game<'_> {
}
/// `Player.giveExperienceLevels`.
pub(super) fn give_levels(&mut self, levels: i32) {
let old_level = self.experience.level;
let xp = &mut self.experience;
xp.level = xp.level.saturating_add(levels);
if xp.level < 0 {
@@ -986,6 +987,7 @@ impl Game<'_> {
xp.progress = 0.0;
xp.total = 0;
}
self.native_player_level_changed(old_level);
}
/// Whether the result slot may be taken (`mayPickup`).
fn may_take_result(&self) -> bool {
+1
View File
@@ -190,6 +190,7 @@ impl Game<'_> {
}
// Vec3.atBottomCenterOf(pos).relative(UP, 1.2), lowered by spawnItem.
VaultEvent::Eject(stack, v) => {
self.native_vault_loot(pos, &stack.item);
self.spawn_item(stack, [center[0], center[1] + 0.7 - 0.125, center[2]], v, 0)
}
}
+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));
+47
View File
@@ -1102,4 +1102,51 @@ mod tests {
other(&item, "minecraft:custom_model_data")
);
}
#[test]
fn rarity_survives_lan_sync_and_enchantment_upgrade() {
use horizon_protocol::buf::{Reader, Writer};
use horizon_protocol::item::Slot;
use horizon_protocol::registry::Ids;
let mut components = vec![String::new(); component::RARITY as usize + 1];
components[component::RARITY as usize] = "minecraft:rarity".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:stick".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
let dynamic = Dynamic::default();
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
item.enchant("minecraft:aqua_affinity", 1);
let wire = crate::lan::guest::stack_to_network(
|name| builtin.get("minecraft:item").unwrap().id(name).unwrap(),
|_, _| None,
&Some(item.clone()),
);
let mut w = Writer::new();
Slot::write(&wire, &mut w);
let received = Slot::read(&mut Reader::new(&w.data)).unwrap().unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
let back = from_network(&received, &builtin, &dynamic);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:rarity")),
Some(&Tag::String("epic".into()))
);
assert_eq!(crate::item_tooltip::rarity(&back, "common"), "epic");
}
}
+39 -5
View File
@@ -1053,7 +1053,7 @@ pub fn lines(
/// `ItemLore.addToTooltip`: each lore component under dark purple italic, with
/// the component's own styles (including explicit `false`) winning on children.
pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
let Some(Tag::List(entries)) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
let Some(raw) = stack.data().and_then(|d| d.other("minecraft:lore")) else {
return Vec::new();
};
let base = crate::text::Style {
@@ -1061,10 +1061,19 @@ pub fn lore_texts(stack: &ItemStack) -> Vec<crate::text::Text> {
italic: Some(true),
..Default::default()
};
entries
.iter()
.map(|entry| {
let mut text = crate::entity_nbt::name_text(entry).resolve_legacy();
let lines: Vec<crate::text::Text> = match raw {
Tag::List(entries) => entries
.iter()
.map(|entry| crate::entity_nbt::name_text(entry).resolve_legacy())
.collect(),
Tag::Compound(_) | Tag::String(_) => {
vec![crate::entity_nbt::name_text(raw).resolve_legacy()]
}
_ => Vec::new(),
};
lines
.into_iter()
.map(|mut text| {
for span in &mut text.spans {
span.style.inherit(&base);
}
@@ -1360,6 +1369,31 @@ mod tests {
assert_eq!(rarity(&sword, "common"), "rare");
}
#[test]
fn lore_texts_handles_compound_lists() {
// Direct command components from issue #6405
let cmd = r#"[custom_name=[{"text":"Simple Custom Name","italic":false}],lore=[[{"text":"Simple Custom Lore","italic":false}]],rarity=epic,enchantments={aqua_affinity:1},unbreakable={}]"#;
let comp = horizon_items::item_data::command_components(cmd).unwrap();
let (damage, extra) = horizon_items::item_data::decode_components(Some(&comp));
let stack = ItemStack {
item: "minecraft:stick".into(),
count: 1,
damage,
extra,
};
assert_eq!(rarity(&stack, "common"), "epic");
let name = styled_name(&stack, || "fallback".into());
assert_eq!(name.plain(), "Simple Custom Name");
assert_eq!(name.spans[0].style.italic, Some(false));
let lore = lore_texts(&stack);
assert_eq!(lore.len(), 1);
assert_eq!(lore[0].plain(), "Simple Custom Lore");
assert_eq!(lore[0].spans[0].style.italic, Some(false));
assert_eq!(lore[0].spans[0].style.color, Some(crate::text::DARK_PURPLE));
assert!(TooltipDisplay::for_stack(&stack).shows("minecraft:lore"));
}
#[test]
fn name_line_translates_items_of_any_namespace() {
let translate = |key: &str| match key {
@@ -716,6 +716,78 @@ mod tests {
/// A recipe whose ingredient kind no loader knows stays out of the book:
/// the server does not load it, and an empty ingredient slot in its
/// place would mislead the client.
/// The shared probe: a recipe the player crafts is learned by the
/// name the crafting copy hands out, and rejoining sends what the
/// book knows.
fn crafted_recipe_reaches_the_book(name: &str, pack: bool) {
let shaped = r#"{"type":"minecraft:crafting_shaped","pattern":["X"],
"key":{"X":"minecraft:raw_copper"},
"result":{"id":"minecraft:copper_ingot"}}"#;
let root = if pack {
resources(name, &[])
} else {
resources(name, &[(name, shaped)])
};
let packs = pack.then(|| {
vec![(
format!("recipe/minecraft:{name}"),
vec![shaped.as_bytes().to_vec()],
)]
});
let overlay = match &packs {
Some(p) => horizon_world::loot::PackOverlay { files: p },
None => horizon_world::loot::PackOverlay::NONE,
};
let sync = SyncData::load(&root, &overlay).unwrap();
// What the craft hands the book: find_with over the crafting
// copy, the way award_crafted calls it.
let items = crate::items::Items::catalog(&root, &overlay).unwrap();
let recipes = crate::crafting::Recipes::load(&root, &items, &overlay).unwrap();
let grid = vec![Some(horizon_items::items::ItemStack {
item: "minecraft:raw_copper".into(),
count: 1,
damage: 0,
extra: None,
})];
let (found, _) = recipes
.find_with(&grid, 1, &|_| None)
.unwrap_or_else(|| panic!("{name}: the recipe matches a grid"));
assert_eq!(
found, name,
"{name}: the crafting copy names it bare, folder and pack alike"
);
// The book learns what the craft awards; rejoining sends it.
let mut book = crate::recipe_book::Settings::default();
let (_, unlocked) = book.add_recipes(&sync.advancements.displays, &[found.to_owned()]);
assert_eq!(
unlocked,
[format!("minecraft:{name}")],
"{name}: the book knows it under its whole id"
);
let bytes = sync.advancements.initial_recipe_book(&book, &sync);
let mut r = Reader::new(&bytes);
let add =
<horizon_protocol::packets::recipes::RecipeBookAdd as horizon_protocol::buf::Codec<
horizon_protocol::packets::recipes::RecipeBookAdd,
>>::read(&mut r)
.unwrap();
assert!(
!add.entries.is_empty(),
"{name}: the crafted recipe reaches the book after rejoining"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_folder_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("folder_recipe", false);
}
#[test]
fn a_pack_recipe_unlocked_by_crafting_shows_in_the_book() {
crafted_recipe_reaches_the_book("pack_recipe", true);
}
#[test]
fn a_recipe_with_an_unknown_ingredient_kind_stays_out_of_the_book() {
let root = resources(
+1 -8
View File
@@ -82,14 +82,7 @@ pub struct SyncData {
pub advancements: super::advancements::ServerAdvancements,
}
/// A resource id with its default namespace filled in.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
use crate::crafting::qualified;
/// Every `*.json` under `dir`, as (path without extension relative to dir, value), sorted;
/// a file that can't be read or doesn't parse is left out. Through `json_folder`: from the
+93 -7
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) {
@@ -1106,13 +1132,55 @@ fn network_stack(
}
// Lore lines as loaded or as a server mod wrote them (a list of text
// components), so a server menu's items can say more than their name.
if let Some(horizon_world::nbt::Tag::List(lines)) =
s.data().and_then(|d| d.other("minecraft:lore"))
if let Some(lore_tag) = s.data().and_then(|d| d.other("minecraft:lore")) {
let lines = match lore_tag {
horizon_world::nbt::Tag::List(lines) => Some(lines.clone()),
// A string is one literal line, as ComponentSerialization decodes a string tag.
horizon_world::nbt::Tag::Compound(_) | horizon_world::nbt::Tag::String(_) => {
Some(vec![lore_tag.clone()])
}
_ => None,
};
if let Some(lines) = lines {
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:lore").is_some())
{
stack.added.push((
horizon_protocol::item::component::LORE,
horizon_protocol::item::Component::Lore(lines.clone()),
));
stack.removed.push(horizon_protocol::item::component::LORE);
}
// The stack's custom rarity ("common", "uncommon", "rare", "epic" or 0..=3).
if let Some(r) = s.data().and_then(|d| d.other("minecraft:rarity")) {
let index = match r {
horizon_world::nbt::Tag::String(s) => match &*s.to_string_lossy() {
"common" => Some(0),
"uncommon" => Some(1),
"rare" => Some(2),
"epic" => Some(3),
_ => None,
},
horizon_world::nbt::Tag::Byte(b) if (0..=3).contains(b) => Some(*b as i32),
horizon_world::nbt::Tag::Short(s) if (0..=3).contains(s) => Some(*s as i32),
horizon_world::nbt::Tag::Int(i) if (0..=3).contains(i) => Some(*i),
_ => None,
};
if let Some(index) = index {
stack.added.push((
horizon_protocol::item::component::RARITY,
horizon_protocol::item::Component::Int(index),
));
}
}
if s.data()
.is_some_and(|d| d.other("!minecraft:rarity").is_some())
{
stack
.removed
.push(horizon_protocol::item::component::RARITY);
}
// The component as loaded, so a translatable name stays one.
if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
@@ -1241,6 +1309,24 @@ mod tests {
);
}
#[test]
fn rarity_reaches_the_client() {
let mut item = ItemStack::new("minecraft:stick", 1);
item.data_mut()
.set_other("minecraft:rarity", Some(Tag::String("epic".into())));
let sent = stack_to_network(|_| 7, |_, _| None, &Some(item));
let mut writer = Writer::new();
horizon_protocol::item::Slot::write(&sent, &mut writer);
let mut reader = Reader::new(&writer.data);
let received = horizon_protocol::item::Slot::read(&mut reader)
.unwrap()
.unwrap();
assert_eq!(
received.component(component::RARITY),
Some(&Component::Int(3))
);
}
#[test]
fn styled_custom_name_survives_inventory_sync() {
let name = Tag::compound([
+162 -14
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,
@@ -135,6 +136,32 @@ pub enum HostEvent {
MainHandChanged(u32, bool),
/// A guest changed their client language option.
LocaleChanged(u32, String),
/// A guest's client finished loading the level around them.
PlayerLoaded(u32),
/// A guest changed recipe book settings.
RecipeBookSettings {
conn: u32,
book: i32,
open: bool,
filtering: bool,
},
/// A guest changed sneaking in a player input packet, already applied.
Sneak {
conn: u32,
sneaking: bool,
},
/// A loaded guest sent their current player input flags.
Input {
conn: u32,
flags: i32,
},
/// A guest was kicked, with the reason rendered by the hub.
Kicked {
conn: u32,
player: u128,
name: String,
reason: String,
},
/// A guest's client started (`flying`) or stopped flying (their abilities packet), applied to
/// the guest already: the game tells the mods, which may refuse it.
Flight {
@@ -190,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(),
@@ -205,12 +266,22 @@ fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
pub struct Hub {
/// Clicks on and closings of native mods' menus, for the game.
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
/// Kicks waiting for the game thread to notify native mods.
pending_kicks: Vec<HostEvent>,
/// 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>,
/// Whether any native mod is loaded: `Input` and `Sneak` events only exist for them, so
/// the hub queues none otherwise. The game sets it before each poll.
pub mods_loaded: bool,
/// Players a native mod hid (`native_hide_player`), by UUID, on or off:
/// only players of at least that permission level see them, in the
/// player list, the world and their joining and leaving.
@@ -569,8 +640,12 @@ impl Hub {
entity_gone: Vec::new(),
mod_commands: (serde_json::Value::Null, None),
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(),
teams: Vec::new(),
nicknames: std::collections::HashMap::new(),
@@ -834,8 +909,15 @@ impl Hub {
// The log's "lost connection" line gives the reason, as vanilla's.
let text = self.english(&reason);
if let Some(g) = self.guest_mut(conn) {
g.left_reason = text;
g.left_reason = text.clone();
let (player, name) = (g.uuid, g.name.clone());
g.sender.disconnect(reason);
self.pending_kicks.push(HostEvent::Kicked {
conn,
player,
name,
reason: text,
});
}
}
@@ -1419,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));
@@ -1534,7 +1623,7 @@ impl Hub {
pub fn poll(&mut self, ctx: &mut TickCtx) -> Vec<HostEvent> {
self.ticks += 1;
self.anticheat_tick(ctx.tick_rate);
let mut events = Vec::new();
let mut events = std::mem::take(&mut self.pending_kicks);
for event in self.lan.poll() {
match event {
NetEvent::Joined(j) => {
@@ -1714,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,
);
}
}
@@ -1742,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],
}));
@@ -1931,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);
}
}
@@ -1972,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);
@@ -3034,6 +3154,7 @@ impl Hub {
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
events.push(HostEvent::PlayerLoaded(conn));
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
S::ChunkBatchReceived(p) => {
@@ -3079,8 +3200,18 @@ impl Hub {
if let Some(book) = crate::recipe_book::BookType::ALL.get(p.book_type as usize)
{
let settings = &mut self.guests[i].recipe_book;
let changed = settings.open[book.index()] != p.open
|| settings.filtering[book.index()] != p.filtering;
settings.open[book.index()] = p.open;
settings.filtering[book.index()] = p.filtering;
if changed {
events.push(HostEvent::RecipeBookSettings {
conn,
book: p.book_type,
open: p.open,
filtering: p.filtering,
});
}
}
}
S::ClientInformation(info) => {
@@ -3117,7 +3248,21 @@ impl Hub {
let g = &mut self.guests[i];
g.input = p.flags;
if g.client_loaded() {
g.sneaking = p.flags & 32 != 0;
// The events only tell the mods: nothing is queued with none loaded.
let listening = self.mods_loaded;
if listening {
events.push(HostEvent::Input {
conn,
flags: i32::from(p.flags),
});
}
let sneaking = p.flags & 32 != 0;
if g.sneaking != sneaking {
g.sneaking = sneaking;
if listening {
events.push(HostEvent::Sneak { conn, sneaking });
}
}
}
// handlePlayerInput only records keys and updates crouching.
// Sprinting persists after the sprint key is released (or
@@ -3687,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;
@@ -3713,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();
@@ -3804,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;
+4
View File
@@ -412,6 +412,10 @@ impl Shared {
pub fn max_players(&self) -> usize {
self.max_players.load(Ordering::Relaxed)
}
/// `PlayerList.setMaxPlayers`: change the limit used by new logins and status responses.
pub fn set_max_players(&self, max_players: usize) {
self.max_players.store(max_players, Ordering::Relaxed);
}
pub fn release(&self, uuid: u128) {
self.online.lock().unwrap().retain(|(u, _)| *u != uuid);
}
@@ -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 -1
View File
@@ -1,9 +1,12 @@
//! Horizon: native Rust client for Minecraft Java Edition 26.3 gameplay.
#![forbid(unsafe_code)]
#[cfg(not(feature = "system-allocator"))]
#[cfg(not(any(feature = "system-allocator", feature = "alloc-census")))]
#[global_allocator]
static ALLOCATOR: horizon_threads::Allocator = horizon_threads::Allocator;
#[cfg(all(feature = "alloc-census", not(feature = "system-allocator")))]
#[global_allocator]
static ALLOCATOR: horizon_threads::CensusAllocator = horizon_threads::CensusAllocator;
/// The mod API level this game serves, kept in the program for the launcher
/// to read without running it (`horizon_mods::api_level::MARK`). `main`
+133 -1
View File
@@ -324,6 +324,14 @@ pub struct Menu {
/// a level) runs the rest of its `onTake` (the anvil's wear and level
/// event 1030 or 1029, the smithing table's 1044).
pub combined: bool,
/// The caller fires `AnvilCraftEvent.Pre` for a taken anvil result: its
/// `onTake` waits in `anvil_craft` for the caller to run `anvil_take`, or
/// to refuse the take with `anvil_cancel` (from an `anvil_undo` made
/// before the click).
pub anvil_hook: bool,
/// The anvil result taken while `anvil_hook` is set, as it was before
/// crafting.
pub anvil_craft: Option<AnvilCraft>,
/// The enchanting table's item changed to an enchantable one: the caller
/// (with a level) fills the costs and clues (`slotsChanged`'s
/// `access.execute`).
@@ -349,6 +357,26 @@ pub struct Anvil {
pub only_renaming: bool,
}
/// An anvil craft as `AnvilCraftEvent` reports it: the inputs and the result
/// taken, before the crafting changed anything.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilCraft {
pub left: Option<ItemStack>,
pub right: Option<ItemStack>,
pub output: ItemStack,
}
/// An anvil menu before a click, for `Menu::anvil_cancel`: the anvil's three
/// slots and cost, the player's slots and the cursor.
#[derive(Clone, Debug, PartialEq)]
pub struct AnvilUndo {
inputs: Vec<Option<ItemStack>>,
result: Option<ItemStack>,
cost: i32,
player: Vec<Option<ItemStack>>,
carried: Option<ItemStack>,
}
/// `StonecutterMenu`'s state besides its slots and `selectedRecipeIndex`.
#[derive(Clone, Default, Debug, PartialEq)]
pub struct Stonecutter {
@@ -742,6 +770,8 @@ impl Menu {
level_change: 0,
anvil: Anvil::default(),
combined: false,
anvil_hook: false,
anvil_craft: None,
enchant_changed: false,
enchant_button: None,
may_build: true,
@@ -1594,8 +1624,33 @@ impl Menu {
out
}
/// The anvil menu as it is before a click that may take its result.
pub fn anvil_undo(&self) -> AnvilUndo {
AnvilUndo {
inputs: self.containers[MAIN].clone(),
result: self.containers[RESULT][0].clone(),
cost: self.data[0],
player: self.containers[PLAYER].clone(),
carried: self.carried.clone(),
}
}
/// Refuses a take that `anvil_hook` deferred (a cancelled
/// `AnvilCraftEvent.Pre`): the result goes back in its slot and the
/// inputs, cost, inventory and cursor are as `undo` held them, so nothing
/// the click did (cursor, shift-click, swap, drop) is left.
pub fn anvil_cancel(&mut self, undo: AnvilUndo) {
self.containers[MAIN] = undo.inputs;
self.containers[RESULT][0] = undo.result;
self.data[0] = undo.cost;
self.containers[PLAYER] = undo.player;
self.carried = undo.carried;
self.anvil_craft = None;
self.dropped.clear();
}
/// `AnvilMenu.onTake`: the levels, the material used up, then the input.
fn anvil_take(&mut self, env: &Env) {
pub fn anvil_take(&mut self, env: &Env) {
let cost = self.data[0];
if !self.creative {
// Player.giveExperienceLevels(-cost).
@@ -1680,6 +1735,14 @@ impl Menu {
return;
}
if self.slots[s].role == Role::CombineResult && self.kind == Kind::Anvil {
if self.anvil_hook {
self.anvil_craft = Some(AnvilCraft {
left: self.containers[MAIN][0].clone(),
right: self.containers[MAIN][1].clone(),
output: taken.clone(),
});
return;
}
self.anvil_take(env);
return;
}
@@ -3477,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());
@@ -3492,6 +3559,71 @@ pub(crate) mod tests {
assert_eq!(menu.mod_slot(0, &stack), (false, 7));
}
/// A cancelled `AnvilCraftEvent.Pre` refuses the take: the result sits in
/// its slot, both inputs and the cost are as they were, and neither the
/// cursor nor the inventory holds the item (no dupe).
#[test]
fn a_cancelled_anvil_craft_leaves_the_menu_as_it_was() {
let root =
std::env::temp_dir().join(format!("horizon-anvil-cancel-{}", std::process::id()));
std::fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
let items = Items::for_tests(Default::default());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let brewing = Brewing::load(&root, &items, &PackOverlay::NONE);
let _ = std::fs::remove_dir_all(&root);
let (banners, station_recipes) = (Banners::default(), Default::default());
let (enchantments, names) = (Default::default(), Default::default());
let env = &Env {
items: &items,
recipes: &recipes,
brewing: &brewing,
banners: &banners,
station_recipes: &station_recipes,
enchantments: &enchantments,
names: &names,
trade: None,
maps: None,
};
// Both a pick-up onto the cursor and a shift-click into the inventory.
for input in [Input::Pickup, Input::QuickMove] {
let mut menu = Menu::new(Kind::Anvil);
menu.creative = false;
menu.levels = 10;
menu.anvil_hook = true;
menu.set_slot(env, 0, stack("iron_sword", 1));
menu.set_slot(env, 1, stack("iron_ingot", 2));
menu.set_slot(env, 2, stack("iron_sword", 1));
menu.data[0] = 3;
let before = (
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned(),
);
let undo = menu.anvil_undo();
menu.click(env, 2, 0, input).unwrap();
// The take waits for the mods, and the click moved the result.
let craft = menu.anvil_craft.take().expect("the take is deferred");
assert_eq!(craft.output, before.2.clone().unwrap());
assert_eq!(menu.slot(2), None);
assert_eq!(menu.data[0], 3);
menu.anvil_craft = Some(craft);
menu.anvil_cancel(undo);
assert_eq!(
(
menu.slot(0).cloned(),
menu.slot(1).cloned(),
menu.slot(2).cloned()
),
before
);
assert_eq!(menu.data[0], 3);
assert_eq!(menu.level_change, 0);
assert_eq!(menu.carried, None);
assert!((0..36).all(|i| menu.player_item(i).is_none()));
assert!(menu.anvil_craft.is_none() && menu.dropped.is_empty());
}
}
/// A mod menu type: three slots, then a result (slot 3).
fn mod_menu() -> Kind {
Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+10 -1
View File
@@ -1630,6 +1630,7 @@ pub enum MobEvent {
params: conversion::Params,
},
DropItem {
mob: u32,
item: Arc<str>,
count: u32,
at: [f64; 3],
@@ -3340,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`.
@@ -3352,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) {
+139 -59
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},
@@ -636,6 +636,7 @@ pub fn tick(
if m.species() == Species::Endermite {
endermite_lifetime(m);
}
ageable_step(m, w, level_random, events);
// AbstractHorse.tick runs the stand counter down with or without AI.
if super::equine::is_equine(m.species())
&& !super::camel::is_camel(m.species())
@@ -1040,14 +1041,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));
}
}
}
@@ -1263,6 +1264,68 @@ fn play_ambient(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
fn turtle_ambient_silent(m: &Mob) -> bool {
m.fluids.in_water || !m.on_ground || m.is_baby()
}
/// `AgeableMob.aiStep` runs even when `Mob.serverAiStep` is disabled.
fn ageable_step(
m: &mut Mob,
w: &MobWorld,
level_random: &mut LegacyRandom,
events: &mut Vec<MobEvent>,
) {
// AgeableMob.aiStep: a forced age-up's particles (`sendParticles`, so the
// players are sent them), then the age.
if m.extra.forced_age_timer > 0 {
if m.extra.forced_age_timer % 4 == 0 {
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * (2.0 * r.next_double() - 1.0);
let y = m.position.y + h * r.next_double() + 0.5;
let z = m.position.z + w * (2.0 * r.next_double() - 1.0);
// sendParticles(HAPPY_VILLAGER, x, y, z, 1, 0, 0, 0, 0): exactly there, at rest.
events.push(MobEvent::LevelParticles {
kind: "happy_villager",
at: [x, y, z],
spread: [0.0; 3],
speed: 0.0,
count: 1,
});
}
m.extra.forced_age_timer -= 1;
}
// AgeableMob.aiStep: an age counts only for its subclasses (a tadpole
// counts its own age up, a zombie's baby age never moves). `canAgeUp`:
// a baby grows unless an age lock holds it; a breeding pause counts down.
if m.alive() && super::finalize::ageable(m.species()) {
if can_age_up(m) {
let (bw, bh, _) = m.dims();
// AgeableMob.setAge(getAge() + 1): the crossing is where a species'
// ageBoundaryReached runs (a goat's and a hoglin's attack damage, a
// sulfur cube's size), which the age write used to skip.
let next = m.extra.age + 1;
super::finalize::set_age(m, next);
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
if m.species() == Species::Villager {
super::villager_brain::age_boundary_reached(m, w, level_random, events);
}
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
mob: m.id,
table: "minecraft:gameplay/turtle_grow",
at: [m.position.x, m.position.y, m.position.z],
});
}
}
} else if m.extra.age > 0 {
m.extra.age -= 1;
}
}
if super::finalize::ageable(m.species()) {
make_age_locked_particle(m, events);
}
}
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
if m.species() == Species::Parrot {
@@ -1337,59 +1400,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
if m.kind.flags.climber {
m.climbing = m.horizontal_collision;
}
// AgeableMob.aiStep: a forced age-up's particles (`sendParticles`, so the
// players are sent them), then the age.
if m.extra.forced_age_timer > 0 {
if m.extra.forced_age_timer % 4 == 0 {
let (w, h) = (f64::from(m.width()), f64::from(m.height()));
let r = &mut m.random;
let x = m.position.x + w * (2.0 * r.next_double() - 1.0);
let y = m.position.y + h * r.next_double() + 0.5;
let z = m.position.z + w * (2.0 * r.next_double() - 1.0);
// sendParticles(HAPPY_VILLAGER, x, y, z, 1, 0, 0, 0, 0): exactly there, at rest.
events.push(MobEvent::LevelParticles {
kind: "happy_villager",
at: [x, y, z],
spread: [0.0; 3],
speed: 0.0,
count: 1,
});
}
m.extra.forced_age_timer -= 1;
}
// AgeableMob.aiStep: an age counts only for its subclasses (a tadpole
// counts its own age up, a zombie's baby age never moves). `canAgeUp`:
// a baby grows unless an age lock holds it; a breeding pause counts down.
if m.alive() && super::finalize::ageable(m.species()) {
if can_age_up(m) {
let (bw, bh, _) = m.dims();
// AgeableMob.setAge(getAge() + 1): the crossing is where a species'
// ageBoundaryReached runs (a goat's and a hoglin's attack damage, a
// sulfur cube's size), which the age write used to skip.
let next = m.extra.age + 1;
super::finalize::set_age(m, next);
// setAge at the age boundary: refreshDimensions.
if m.extra.age >= 0 {
m.fudge_position_after_size_change(w, bw, bh);
if m.species() == Species::Villager {
super::villager_brain::age_boundary_reached(m, w, level_random, events);
}
// Turtle.ageBoundaryReached: a grown turtle drops a scute.
if m.species() == Species::Turtle {
events.push(MobEvent::GiftLoot {
mob: m.id,
table: "minecraft:gameplay/turtle_grow",
at: [m.position.x, m.position.y, m.position.z],
});
}
}
} else if m.extra.age > 0 {
m.extra.age -= 1;
}
}
if super::finalize::ageable(m.species()) {
make_age_locked_particle(m, events);
}
ageable_step(m, w, level_random, events);
if m.alive() {
// Animal.aiStep
if m.extra.age != 0 {
@@ -2597,7 +2608,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.
@@ -3431,6 +3442,75 @@ mod age_up_tests {
use super::*;
use std::sync::Arc;
/// Official Java 26.3: NoAI leaves both age clocks running. AgeLocked
/// holds a baby's age, but a positive breeding cooldown still expires.
#[test]
fn age_clocks_tick_with_no_ai_and_respect_baby_age_lock() {
let ran = crate::mobs::world::with_test_world(&[], |w, types| {
for species in ["pig", "cow", "horse", "turtle"] {
for no_ai in [true, false] {
for locked in [false, true] {
for age in [-2, 2] {
let kind =
Arc::new(types.types[&format!("minecraft:{species}")].clone());
let mut m = Mob::new(1, kind, Vec3::new(4.0, 220.0, 4.0), 0.0);
m.no_ai = no_ai;
m.no_gravity = true;
m.persistent = true;
m.extra.age_locked = locked;
super::super::finalize::set_age(&mut m, age);
let (mut random, mut events) = (LegacyRandom::new(0), Vec::new());
let expected = if age > 0 {
[1, 0, 0, 0]
} else if locked {
[-2, -2, -2, -2]
} else {
[-1, 0, 0, 0]
};
for (elapsed, expected_age) in expected.into_iter().enumerate() {
let previous_age = m.extra.age;
let previous_position = m.position;
tick(&mut m, w, &mut random, &mut events);
assert_eq!(
m.extra.age,
expected_age,
"{species}: NoAI={no_ai}, AgeLocked={locked}, initial Age={age}, tick={}",
elapsed + 1
);
if no_ai {
assert_eq!(m.velocity, Vec3::ZERO);
// Growing dimensions can reposition the mob at adulthood.
if previous_age != -1 || expected_age != 0 {
assert_eq!(m.position, previous_position);
}
}
}
if species == "turtle" && age < 0 && !locked {
assert_eq!(
events
.iter()
.filter(|event| matches!(
event,
MobEvent::GiftLoot {
table: "minecraft:gameplay/turtle_grow",
..
}
))
.count(),
1,
"growing up drops one scute"
);
}
}
}
}
}
});
if !ran {
eprintln!("age clock regression needs prepared vanilla resources");
}
}
/// A baby goat `age` ticks from adulthood, from no vanilla data.
fn baby_goat(age: i32) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
+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 {
+47
View File
@@ -271,6 +271,8 @@ pub enum ArmPose {
ThrowTrident,
/// Playing a goat horn.
TootHorn,
/// Looking through a spyglass (`HumanoidModel.ArmPose.SPYGLASS`).
Spyglass,
/// A spear held out (`HumanoidModel.ArmPose.SPEAR`), at rest or charging.
Spear,
/// A brush being used (`HumanoidModel.ArmPose.BRUSH`).
@@ -400,6 +402,8 @@ pub(super) fn crossbow_charge(p: &mut Poses, max: f32, ticks: f32, in_right: boo
let x = lerp(alpha, p.get(pull)[3], -PI / 2.0);
p.xrot(pull, x);
}
/// `HumanoidModel.SPYGLASS_ARM_ROT_X`.
const SPYGLASS_ARM_ROT_X: f32 = 1.919_862_2;
/// `HumanoidModel.poseRightArm` / `poseLeftArm`.
fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
let arm = if right { "right_arm" } else { "left_arm" };
@@ -436,6 +440,11 @@ fn pose_arm(p: &mut Poses, pose: ArmPose, right: bool, h: &Hum) {
p.xrot(arm, x * 0.5 - PI);
p.yrot(arm, 0.0);
}
ArmPose::Spyglass => {
let crouch = if h.crouching { 0.261_799_4 } else { 0.0 };
p.xrot(arm, (hx - SPYGLASS_ARM_ROT_X - crouch).clamp(-2.4, 3.3));
p.yrot(arm, hy + if right { -0.261_799_4 } else { 0.261_799_4 });
}
ArmPose::TootHorn => {
p.xrot(arm, hx.clamp(-1.2, 1.2) - 1.483_529_8);
p.yrot(arm, hy + if right { -FRAC_PI_6 } else { FRAC_PI_6 });
@@ -818,6 +827,7 @@ pub fn setup(
Some(_) if using_main && m.extra.blocking => ArmPose::Block,
Some("minecraft:bow") if using_main => ArmPose::Bow,
Some("minecraft:trident") if using_main => ArmPose::ThrowTrident,
Some("minecraft:spyglass") if using_main => ArmPose::Spyglass,
Some("minecraft:goat_horn") if using_main => ArmPose::TootHorn,
Some("minecraft:crossbow") if using_main && !m.extra.charged => {
ArmPose::CrossbowCharge
@@ -838,6 +848,8 @@ pub fn setup(
ArmPose::Spear
} else if using_off && m.extra.blocking {
ArmPose::Block
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:spyglass") {
ArmPose::Spyglass
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:goat_horn") {
ArmPose::TootHorn
} else if using_off && m.extra.off_hand.as_deref() == Some("minecraft:brush") {
@@ -1067,6 +1079,41 @@ mod flight_pose_tests {
}
}
}
/// HumanoidModel.poseRightArm / poseLeftArm's SPYGLASS case: pitch, yaw,
/// crouching, and the arm's pitch.
#[test]
fn a_spyglass_raises_the_arm_with_the_head_in_both_hands() {
for right in [false, true] {
for (pitch, yaw, crouching, expected_pitch) in [
(0.0, 0.0, false, -1.919_862_2),
(0.5, 0.3, false, -1.419_862_2),
(0.0, 0.0, true, -2.181_661_6),
// Looking up: limited to -2.4.
(-1.5, 0.0, false, -2.4),
] {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
hidden: Default::default(),
};
let arm = if right { "right_arm" } else { "left_arm" };
p.xrot("head", pitch);
p.yrot("head", yaw);
let mut h = Hum::new(ArmPose::Empty, ArmPose::Empty);
h.crouching = crouching;
pose_arm(&mut p, ArmPose::Spyglass, right, &h);
let pose = *p.get(arm);
assert!((pose[3] - expected_pitch).abs() < 1e-6, "{pose:?}");
let side = if right { -0.261_799_4 } else { 0.261_799_4 };
assert!((pose[4] - (yaw + side)).abs() < 1e-6, "{pose:?}");
assert_eq!(p.get(if right { "left_arm" } else { "right_arm" })[3], 0.0);
}
}
}
/// EndermanModel.setupAnim (#4565): humanoid_pub forwards isPassenger so the
/// riding leg y/z survive; only xRot is halved and clamped to ±0.4 after.
#[test]
+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
}
+15 -1
View File
@@ -24,6 +24,19 @@ pub const COLORS: [&str; 7] = [
"gray",
"darkbrown",
];
/// `Horse.Variant.getSerializedName` in id order (WHITE 0 to DARK_BROWN 6):
/// the names a `minecraft:horse/variant` component and the save's `Variant`
/// byte carry. [`COLORS`] is the same order, but names the texture
/// (`horse_darkbrown`), which drops the underscore.
pub const VARIANT_NAMES: [&str; 7] = [
"white",
"creamy",
"chestnut",
"brown",
"black",
"gray",
"dark_brown",
];
pub const MARKINGS: [&str; 5] = ["", "white", "whitefield", "whitedots", "blackdots"];
// AnimState slots.
@@ -718,6 +731,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 +765,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() {
@@ -2171,6 +2171,12 @@ pub fn variant_index(s: Species, name: &str) -> u8 {
// Fox.Variant, read from a `minecraft:fox/variant` component; the save
// keeps the same two names under `Type`.
Species::Fox => ["red", "snow"].iter().position(|v| *v == name),
// Horse.Variant and Llama.Variant, the coat colours their
// `.../variant` components carry; a trader llama inherits the llama's.
Species::Horse => super::equine::VARIANT_NAMES.iter().position(|v| *v == name),
Species::Llama | Species::TraderLlama => {
super::llama::VARIANTS.iter().position(|v| *v == name)
}
_ => ["temperate", "warm", "cold"]
.iter()
.position(|v| *v == name),
@@ -2193,10 +2199,29 @@ pub fn species_variant_component(s: Species, variant: u8) -> Option<(&'static st
Species::Mooshroom => "minecraft:mooshroom/variant",
Species::Chicken => "minecraft:chicken/variant",
Species::ZombieNautilus => "minecraft:zombie_nautilus/variant",
Species::Horse => "minecraft:horse/variant",
Species::Llama | Species::TraderLlama => "minecraft:llama/variant",
_ => return None,
};
Some((component, format!("minecraft:{}", variant_name(s, variant))))
}
/// Whether the mob's save keeps its variant under `variant` — the registry
/// id `VariantUtils.writeVariant` stores — rather than under its own key (a
/// fox's and a mooshroom's `Type`) or a packed int (a horse's and a llama's
/// `Variant`). Only these seven classes call `writeVariant`, so reporting the
/// component does not mean the save carries the name.
pub fn saves_variant_name(s: Species) -> bool {
matches!(
s,
Species::Wolf
| Species::Cat
| Species::Frog
| Species::Pig
| Species::Cow
| Species::Chicken
| Species::ZombieNautilus
)
}
/// The `*_sound_variant` registry of a species, in registry order.
pub fn sound_variants(s: Species) -> &'static [&'static str] {
match s {
@@ -2259,6 +2284,13 @@ pub fn variant_name(s: Species, index: u8) -> &'static str {
.copied()
.unwrap_or("temperate"),
Species::Mooshroom => ["red", "brown"].get(i).copied().unwrap_or("red"),
Species::Horse => super::equine::VARIANT_NAMES
.get(i)
.copied()
.unwrap_or("white"),
Species::Llama | Species::TraderLlama => {
super::llama::VARIANTS.get(i).copied().unwrap_or("creamy")
}
_ => ["temperate", "warm", "cold"]
.get(i)
.copied()
@@ -2320,6 +2352,60 @@ mod tests {
assert_eq!(variant_index(Species::Mooshroom, "minecraft:temperate"), 0);
assert_eq!(variant_name(Species::Mooshroom, 2), "red");
}
/// `Horse.get(DataComponents.HORSE_VARIANT)` and
/// `Llama.get(DataComponents.LLAMA_VARIANT)`: the coat colour a horse or a
/// llama reports, and the names a `.../variant` component carries.
#[test]
fn a_horse_and_a_llama_report_their_variant_component() {
for (species, component, names) in [
(
Species::Horse,
"minecraft:horse/variant",
&crate::mobs::equine::VARIANT_NAMES[..],
),
(
Species::Llama,
"minecraft:llama/variant",
&crate::mobs::llama::VARIANTS[..],
),
(
Species::TraderLlama,
"minecraft:llama/variant",
&crate::mobs::llama::VARIANTS[..],
),
] {
for (index, name) in names.iter().enumerate() {
let index = index as u8;
let name = format!("minecraft:{name}");
assert_eq!(
species_variant_component(species, index),
Some((component, name.clone())),
"{species:?} {index}"
);
assert_eq!(variant_index(species, &name), index, "{name}");
assert_eq!(format!("minecraft:{}", variant_name(species, index)), name);
}
}
// Horse.Variant.WHITE and Llama.Variant.CREAMY are the defaults.
assert_eq!(variant_index(Species::Horse, "minecraft:temperate"), 0);
assert_eq!(variant_index(Species::Llama, "minecraft:temperate"), 0);
assert_eq!(variant_name(Species::Horse, 7), "white");
assert_eq!(variant_name(Species::TraderLlama, 4), "creamy");
// Only `VariantUtils.writeVariant` species save the name; a horse and
// a llama save a packed `Variant` int, a fox and a mooshroom `Type`.
for species in [Species::Wolf, Species::Cat, Species::Chicken, Species::Pig] {
assert!(saves_variant_name(species), "{species:?}");
}
for species in [
Species::Horse,
Species::Llama,
Species::TraderLlama,
Species::Fox,
Species::Mooshroom,
] {
assert!(!saves_variant_name(species), "{species:?}");
}
}
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
/// own kind takes the egg.
+2
View File
@@ -958,6 +958,7 @@ impl EatBerries {
}
if count > 0 {
cx.events.push(MobEvent::DropItem {
mob: m.id,
item: "minecraft:sweet_berries".into(),
count: count as u32,
at,
@@ -975,6 +976,7 @@ impl EatBerries {
} else {
// CaveVines.use: one glow berry, the vine goes back to bare.
cx.events.push(MobEvent::DropItem {
mob: m.id,
item: "minecraft:glow_berries".into(),
count: 1,
at,
@@ -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
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff. Show more