Merge main into #7328

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 16:05:43 +01:00
commit 64faa0482a
347 files changed
+9181 -4844

No files matched your search

@@ -573,6 +573,7 @@ mod tests {
default: || 0,
codec: None,
copy_on_death: false,
sync: false,
};
let p = (1, 64, 2);
let mut entities = level();
+7 -1
View File
@@ -154,10 +154,16 @@ pub(crate) struct Uniform {
colors: [[f32; 4]; 4],
}
/// How far the clouds have drifted, in ticks: the game time within one trip across the cloud
/// texture (`width` cells of 400 ticks), plus the partial tick (`CloudRenderer.prepare`).
pub fn drift(time: i64, partial_tick: f64, width: u32) -> f64 {
time.rem_euclid(i64::from(width) * 400) as f64 + partial_tick
}
impl Input<'_> {
fn state(&self) -> (MeshKey, Uniform) {
let (w, h, _) = self.texture;
let offset = self.time.rem_euclid(i64::from(*w) * 400) as f64 + self.partial_tick;
let offset = drift(self.time, self.partial_tick, *w);
let mut x = self.camera.x + offset * 0.030_000_001;
let mut z = self.camera.z + 3.96;
let (tw, th) = (f64::from(*w) * 12.0, f64::from(*h) * 12.0);
+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.
+20 -3
View File
@@ -5332,6 +5332,14 @@ impl Renderer {
&& self.native.is_none()
}
/// Whether the eyes render type's layers may be posed on the GPU too
/// (`mobs::skin::SkinModels::eyes`): only with improved transparency off,
/// where they are ordinary entity quads. On, `EntityBatch` sorts them into
/// its stages by the CPU vertices' `OIT_ENTITY` tag.
pub fn skins_eyes(&self) -> bool {
self.skins_entities() && !self.improved_transparency
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -5351,7 +5359,8 @@ impl Renderer {
self.world_post.upscale = if taa { upscale.clamp(1.0, 4.0) } else { 1.0 };
}
/// Runs `post` over the world from now on (`None`: no pack), for the dimension `dimension`;
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) pair.
/// `block_ids` gives each block sprite layer's `mc_Entity` (id, fluid) and `at_midBlock.w`
/// (its block's light), three values a layer.
/// Returns the programs that run and the notes of loading them.
pub fn set_shader_pack(
&mut self,
@@ -7002,7 +7011,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 +7041,7 @@ impl Renderer {
skin,
&self.entity_buffer,
0,
outline_base,
);
}
let static_sections = frame.static_block_entities;
@@ -7154,6 +7168,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 +7200,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 +9090,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]);
@@ -1987,6 +1987,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 +2013,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 +2720,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 +2897,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(),
@@ -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,
+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,
+54
View File
@@ -56,6 +56,60 @@ pub fn breathe(entity: u32, kind: &str, vanilla: Breath) -> Breath {
}
}
/// An entity that can't breathe and has no air (`LivingDrownEvent`, posted
/// from `LivingEntity.baseTick`), with vanilla's answers as the defaults: it
/// drowns at -20 air, for 2 damage and 8 bubbles. Bubble count is read-only;
/// changes to the event field do not affect the vanilla entity event. Asked on
/// servers, for the entities `native_living_breathe` is. API 356.
#[inline(never)]
pub fn native_living_drown(
entity: u32,
kind: &str,
is_drowning: &mut bool,
damage_amount: &mut f32,
bubble_count: i32,
cancel: &mut bool,
) {
crate::native_client_hooks::living_drown(
entity,
kind,
is_drowning,
damage_amount,
bubble_count,
cancel,
);
}
/// A drowning tick as the mods left it.
pub struct Drowning {
pub damage: Option<f32>,
}
/// `breath` applied to `air` (`apply`), then `LivingEntity.baseTick`'s
/// drowning, which the mods may change when `server` (`native_living_drown`).
/// An entity that drowns is set to 0 air.
pub fn drown(
entity: u32,
kind: &str,
air: &mut i32,
breath: Breath,
server: bool,
) -> Option<Drowning> {
let (next, drowned) = apply(*air, 300, breath);
*air = if drowned { *air - breath.consume } else { next };
let (mut drowning, mut damage, mut cancel) = (drowned, 2.0, false);
if server && !breath.can_breathe && *air <= 0 {
native_living_drown(entity, kind, &mut drowning, &mut damage, 8, &mut cancel);
}
if cancel || !drowning {
return None;
}
*air = 0;
Some(Drowning {
damage: (damage > 0.0).then_some(damage),
})
}
/// The air supply after `breath`: raised up to `max` when it can breathe,
/// lowered otherwise. The second is whether the entity now drowns
/// (`LivingEntity.shouldTakeDrowningDamage`: -20 or less), and its air is
+1 -1
View File
@@ -26,7 +26,7 @@ use std::{
/// removed keeping first-seen order. The hook may be called more than
/// once per recipe; the answer must be the same. An unclaimed kind
/// leaves the list empty and the recipe fails, as an unknown kind does.
/// API @API:ingredient@.
/// API 166.
#[inline(never)]
pub fn native_ingredient(
kind: &str,
+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);
}
+171 -7
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]))
@@ -2238,7 +2238,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 +2303,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 +2334,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 +2416,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 +2446,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 +2469,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 +2542,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 +3384,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]
@@ -5287,6 +5385,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);
},
+13 -8
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;
@@ -3781,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
@@ -7324,6 +7325,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());
@@ -7355,34 +7359,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());
+17 -11
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);
}
@@ -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);
}
}
}
+1 -2
View File
@@ -2105,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() {
@@ -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)
+34 -21
View File
@@ -1053,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));
}
}
}
@@ -4777,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,
@@ -5370,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.
@@ -5508,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);
}
@@ -5564,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");
}
@@ -6299,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(),
}
}
@@ -6495,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(),
@@ -6579,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(),
@@ -68,37 +68,51 @@ impl Game<'_> {
.collect();
return json!({ "value": all });
}
// `GameRulesService.update`: the rule is set only when its value changes, and logged.
// `GameRulesService.update` through `MinecraftGameRuleServiceImpl.updateGameRule`: the rule is set,
// logged and told on every update, even to the value it has (as vanilla's sends its notification
// then too, which the parity script checks). What doesn't fit answers with the texts of vanilla's
// codecs, as the parity script saw them.
"gamerules/update" => {
let update = &body["value"];
let Some(rule) = update["key"]
.as_str()
.and_then(gamerules::rule)
.filter(|r| self.ui.rules.get(r.name).is_some())
else {
return json!({ "invalid": format!("Unknown game rule: {}", update["key"]) });
let Some(key) = update["key"].as_str() else {
return json!({ "invalid": format!("No key key in MapLike[{update}]") });
};
let rule = gamerules::rule(key).filter(|r| self.ui.rules.get(r.name).is_some());
let Some(rule) = rule else {
let key = if key.contains(':') {
key.to_owned()
} else {
format!("minecraft:{key}")
};
return json!({ "invalid": format!(
"Unknown registry key in ResourceKey[minecraft:root / minecraft:game_rule]: {key}"
) });
};
let wanted = &update["value"];
let value = match (rule.default, wanted) {
(GameRuleValue::Bool(_), Value::Bool(b)) => Some(GameRuleValue::Bool(*b)),
(GameRuleValue::Int(_), n) => n
.as_i64()
.and_then(|n| i32::try_from(n).ok())
.filter(|n| (rule.min..=rule.max).contains(n))
.map(GameRuleValue::Int),
_ => None,
(GameRuleValue::Bool(_), Value::Bool(b)) => Ok(GameRuleValue::Bool(*b)),
(GameRuleValue::Bool(_), other) => Err(format!("Not a boolean: {other}")),
(GameRuleValue::Int(_), other) => match other.as_i64() {
Some(n) if (i64::from(rule.min)..=i64::from(rule.max)).contains(&n) => {
Ok(GameRuleValue::Int(n as i32))
}
Some(n) => Err(format!(
"Value {n} outside of range [{}:{}]",
rule.min, rule.max
)),
None => Err(format!("Not a number: {other}")),
},
};
let Some(value) = value else {
return json!({ "invalid": format!("Invalid value {wanted} for game rule {}", rule.name) });
let value = match value {
Ok(value) => value,
Err(invalid) => return json!({ "invalid": invalid }),
};
let current = self.ui.rules.get(rule.name).unwrap_or(rule.default);
if current != value {
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
}
rpc(format!(
"Game rule '{}' updated from '{current}' to '{value}'",
rule.name
));
self.set_game_rule(rule, value);
return json!({ "value": typed_rule(rule, value) });
}
"serversettings/autosave" => {
+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],
+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),
+1 -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.
+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)
);
}
}
+6 -2
View File
@@ -618,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() {
@@ -630,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);
}
+2
View File
@@ -1301,6 +1301,7 @@ impl Game<'_> {
.then(|| horizon_client::lod_net::Service::new(net.lod_distance, biome_names.clone()));
let lan = Lan::start_with(sync.clone(), net)?;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.sync_attachments = !crate::mod_api::mods().is_empty();
hub.flat_world = self.world_flat;
hub.debug_world = self.world_debug;
let port = hub.lan.port;
@@ -2335,6 +2336,7 @@ impl Game<'_> {
self.lan_sync_border(&mut host);
host.hub.anticheat_ping();
drop(holding);
self.flush_attachment_sync(&mut host);
self.flush_native_net(&mut host);
self.flush_far_terrain(&mut host);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
+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;
+20 -1
View File
@@ -97,11 +97,29 @@ impl Game<'_> {
self.serving.is_some() || self.options.dedicated.is_some()
}
/// Access a server-owned holder's attachment map.
/// Access a holder's attachment map, to lend to a mod: a server's, whose
/// synced changes are sent at the pass's end, or a client's copy of what
/// its server synced.
pub(super) fn attachment_map(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
if self.native_authoritative() {
self.world.attachment_syncs.insert(holder);
return self.attachment_map_quiet(holder);
}
match self.remote.as_mut() {
Some(remote) => Ok(remote.attachments.entry(holder).or_default()),
None => Err(horizon_client::mod_api::AttachmentError::MissingHolder),
}
}
/// `attachment_map` without recording the holder for the sync.
pub(super) fn attachment_map_quiet(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
use horizon_client::mod_api::AttachmentError;
if !self.native_authoritative() {
@@ -162,6 +180,7 @@ impl horizon_client::mod_api::LevelRandomHost for super::GameLevel<'_> {
if !self.native_authoritative {
return Err(horizon_client::mod_api::AttachmentError::MissingHolder);
}
self.world.attachment_syncs.insert(holder);
world_attachments(self.world, holder)
}
fn random(&mut self) -> Option<&mut horizon_client::mod_api::LegacyRandom> {
+1 -1
View File
@@ -320,7 +320,7 @@ impl Game<'_> {
loved = true;
}
if m.health < m.attrs.max_health && heal > 0.0 {
m.health = (m.health + heal).min(m.attrs.max_health);
m.heal(heal);
used = true;
}
if m.is_baby() && age_up > 0 && !m.extra.age_locked {
+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;
+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(),
+1 -1
View File
@@ -249,7 +249,7 @@ impl Game<'_> {
match owner {
Some(Who::Mob(id)) => {
if let Some(w) = self.mobs.iter_mut().find(|m| m.id == id && m.alive()) {
w.health = (w.health + 5.0).min(w.attrs.max_health);
w.heal(5.0);
}
}
// The player (or the guest acting now) who owns the skull.
+28 -11
View File
@@ -13,6 +13,13 @@ use horizon_world::{
synced_floats::SyncedFloats,
};
/// A living entity is about to heal. Mods may change the amount or cancel.
/// `entity` identifies the affected entity. 358.
#[inline(never)]
pub fn native_living_heal(entity: u32, amount: &mut f32, cancel: &mut bool) {
crate::native_client_hooks::living_heal(entity, amount, cancel);
}
impl PlayerMotion {
/// Player.causeFoodExhaustion: creative and spectator abilities suppress
/// exhaustion from movement, attacks and effects.
@@ -33,16 +40,21 @@ impl PlayerMotion {
let health = self.health.health();
let maximum = self.attribute("minecraft:max_health")? as f32;
if health < maximum {
let h = &mut self.health;
let mut observer = Updates(&mut self.events);
PlayerAttributeHealth::new(
&mut self.attributes,
&mut h.data,
h.ids,
h.dead,
&mut observer,
)?
.heal(1.0)?;
let mut amount = 1.0;
let mut cancel = false;
native_living_heal(self.entity_id as u32, &mut amount, &mut cancel);
if !cancel {
let h = &mut self.health;
let mut observer = Updates(&mut self.events);
PlayerAttributeHealth::new(
&mut self.attributes,
&mut h.data,
h.ids,
h.dead,
&mut observer,
)?
.heal(amount)?;
}
}
let saturation = self.health.food.saturation;
if saturation < 20.0 {
@@ -117,7 +129,12 @@ impl FoodWorld for TickWorld<'_> {
)?
.is_hurt()?)
}
fn heal(&mut self, _: &mut FoodData, amount: f32) -> Result<()> {
fn heal(&mut self, _: &mut FoodData, mut amount: f32) -> Result<()> {
let mut cancel = false;
native_living_heal(self.entity as u32, &mut amount, &mut cancel);
if cancel {
return Ok(());
}
let mut observer = Updates(self.events);
Ok(PlayerAttributeHealth::new(
self.attributes,
+1 -1
View File
@@ -343,7 +343,7 @@ impl ItemRenderState {
rect: [r.u0, r.v0, r.u1, r.v1],
light,
overlay: u32::from(tier) << 16,
item: 1,
mode: SkinJob::ITEM,
..Default::default()
});
frame.parts.push(SkinPart::item(m, colors));
+27 -1
View File
@@ -9,8 +9,12 @@ use crate::{
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
attachment_sync,
item::Stack,
packets::{configuration::ClientInformation, play},
packets::{
configuration::{ClientInformation, CustomPayload},
play,
},
};
use std::{
collections::VecDeque,
@@ -200,6 +204,8 @@ pub struct Guest {
// Chunks.
pub center: Option<(i32, i32)>,
pub sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
/// Sent as far-ring columns and needing a near snapshot on promotion.
pub far_sent_chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
pub chunk_queue: Vec<(i32, i32)>,
/// The far ring's chunks still to send, past the view distance (`lan::far`), closest first.
pub far_queue: Vec<(i32, i32)>,
@@ -416,6 +422,7 @@ impl Guest {
load_timeout: CLIENT_LOADED_TIMEOUT,
center: None,
sent_chunks: Default::default(),
far_sent_chunks: Default::default(),
chunk_queue: Vec::new(),
far_queue: Vec::new(),
unacknowledged: 0,
@@ -501,6 +508,25 @@ impl Guest {
}
self.sender.send(packet.encode());
}
/// Whether the guest is placed and on a Horizon client (the only one that
/// reads synced attachments).
pub fn reads_attachment_sync(&self) -> bool {
self.placed && self.brand == "horizon"
}
/// A holder's synced attachments, if the guest reads them; a message too big
/// for one payload is not sent.
pub fn send_attachment_sync(&self, message: &attachment_sync::Message) {
if !self.reads_attachment_sync() {
return;
}
match attachment_sync::encode(message) {
Ok(data) => self.send(&play::Clientbound::CustomPayload(CustomPayload {
channel: attachment_sync::CHANNEL.into(),
data,
})),
Err(e) => log::debug!("Horizon: attachment sync not sent: {e}"),
}
}
/// What `packet` changes about how the client moves, for the prediction's replay: kept until
/// the tick's ping places it (`Hub::anticheat_ping`).
fn note_change(&self, packet: &play::Clientbound) {
+94 -11
View File
@@ -29,6 +29,7 @@ use horizon_protocol::{
text::{literal, translatable},
};
use horizon_world::{
attachments::Holder,
block_collision::BlockCollisionRegistry,
block_collision_scan::BlockCollisionScan,
collision_context::CollisionContext,
@@ -216,10 +217,44 @@ pub struct TrackingBatch {
pub events: Vec<super::entity::TrackEvent>,
}
/// Moves a guest's tracking events into `out`, one batch.
fn take_tracking(g: &mut Guest, out: &mut Vec<TrackingBatch>) {
/// A holder whose synced attachments one player is sent whole, because the
/// player started seeing it.
pub struct AttachmentSnapshot {
pub player: u128,
pub holder: Holder,
}
/// Queues `holders` as snapshots for `player`.
fn queue_snapshots(
snapshots: &mut Vec<AttachmentSnapshot>,
player: u128,
holders: impl IntoIterator<Item = Holder>,
) {
snapshots.extend(
holders
.into_iter()
.map(|holder| AttachmentSnapshot { player, holder }),
);
}
/// Moves a guest's tracking events into `out`, one batch, and the entities it
/// started tracking into `snapshots`.
fn take_tracking(
g: &mut Guest,
out: &mut Vec<TrackingBatch>,
snapshots: &mut Vec<AttachmentSnapshot>,
sync_attachments: bool,
) {
let events = g.tracker.take_events();
if !events.is_empty() {
if sync_attachments {
let started = events.iter().filter(|e| e.start);
queue_snapshots(
snapshots,
g.uuid,
started.map(|e| Holder::Entity(e.entity as u32)),
);
}
out.push(TrackingBatch {
player: g.uuid,
name: g.name.clone(),
@@ -236,6 +271,11 @@ pub struct Hub {
/// The entities players started and stopped tracking, collected by
/// `collect_tracking`, for the game to tell the mods.
pub native_tracking: Vec<TrackingBatch>,
/// The holders players started seeing, for the game to send their synced
/// attachments (`flush_attachment_sync`).
pub attachment_snapshots: Vec<AttachmentSnapshot>,
/// Whether native mods can produce synced attachments.
pub sync_attachments: bool,
/// Criteria `CriteriaTriggers.TICK` granted to players, for the game to
/// tell the mods.
pub native_advancement_grants: Vec<TickGrants>,
@@ -602,6 +642,8 @@ impl Hub {
native_menu_events: Vec::new(),
pending_kicks: Vec::new(),
native_tracking: Vec::new(),
attachment_snapshots: Vec::new(),
sync_attachments: false,
native_advancement_grants: Vec::new(),
mods_loaded: false,
hidden: Default::default(),
@@ -1459,6 +1501,13 @@ impl Hub {
fn send_level_info(&mut self, i: usize, ctx: &TickCtx, load_start: bool) {
let spawn = ctx.spawn;
let g = &self.guests[i];
if self.sync_attachments {
queue_snapshots(
&mut self.attachment_snapshots,
g.uuid,
[Holder::Level, Holder::Player(g.uuid)],
);
}
g.send(&C::InitializeBorder(super::border::initialize(ctx.border)));
g.send(&self.time_packet(ctx.world, ctx.clock_rate));
g.send(&self.default_spawn(spawn));
@@ -1754,7 +1803,12 @@ impl Hub {
/// are kept in `native_tracking`.
pub fn collect_tracking(&mut self) {
for g in &mut self.guests {
take_tracking(g, &mut self.native_tracking);
take_tracking(
g,
&mut self.native_tracking,
&mut self.attachment_snapshots,
self.sync_attachments,
);
}
}
@@ -1782,7 +1836,12 @@ impl Hub {
}
// `PlayerList.remove`: the entities it tracked stop (`removePairing`).
g.tracker.clear();
take_tracking(&mut g, &mut self.native_tracking);
take_tracking(
&mut g,
&mut self.native_tracking,
&mut self.attachment_snapshots,
self.sync_attachments,
);
self.broadcast(&C::PlayerInfoRemove(play::PlayerInfoRemove {
uuids: vec![g.uuid],
}));
@@ -1971,17 +2030,27 @@ impl Hub {
}));
g.unacknowledged += 1;
}
for idx in taken.into_iter().rev() {
let key = g.chunk_queue.remove(idx);
g.sent_chunks.insert(key);
let mut seen: Vec<_> = taken
.into_iter()
.rev()
.map(|idx| g.chunk_queue.remove(idx))
.collect();
if self.sync_attachments {
queue_snapshots(
&mut self.attachment_snapshots,
g.uuid,
seen.iter().map(|&(x, z)| Holder::Chunk([x, z])),
);
}
// A far chunk the save doesn't have leaves the queue unsent.
for (idx, sent) in far.into_iter().rev() {
seen.extend(far.into_iter().rev().filter_map(|(idx, sent)| {
let key = g.far_queue.remove(idx);
if sent {
g.sent_chunks.insert(key);
if sent && self.sync_attachments && g.brand == "horizon" {
g.far_sent_chunks.insert(key);
}
}
sent.then_some(key)
}));
g.sent_chunks.extend(seen);
}
}
@@ -2012,8 +2081,19 @@ impl Hub {
.collect();
for (x, z) in forget {
g.sent_chunks.remove(&(x, z));
g.far_sent_chunks.remove(&(x, z));
g.send(&C::ForgetLevelChunk(play::ForgetLevelChunk { pos: [x, z] }));
}
// Far columns have no attachment snapshots. Resend them as near chunks
// after they enter the view, once the level has loaded them.
g.far_sent_chunks.retain(|&(x, z)| {
if tracked(cx, cz, r, x, z) {
g.sent_chunks.remove(&(x, z));
false
} else {
true
}
});
requeue(&mut g.chunk_queue, &g.sent_chunks, (cx, cz), r);
if reach > r {
requeue_ring(&mut g.far_queue, &g.sent_chunks, (cx, cz), r, reach);
@@ -3752,6 +3832,7 @@ impl Hub {
}
g.tracker.clear();
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
let id = g.entity_id;
@@ -3778,6 +3859,7 @@ impl Hub {
g.sent_effects.clear();
g.center = None;
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
g.tracker.clear();
@@ -3869,6 +3951,7 @@ impl Hub {
// respawn only, and covers a level change with its separate `isChangingDimension` term.
g.tracker.clear();
g.sent_chunks.clear();
g.far_sent_chunks.clear();
g.chunk_queue.clear();
g.far_queue.clear();
g.center = None;
@@ -429,6 +429,7 @@ mod tests {
},
}),
copy_on_death: true,
sync: false,
};
let dir = world("attachments");
let mut original = guest();
+33
View File
@@ -25,6 +25,20 @@ use horizon_world::{
};
use std::{collections::BTreeMap, fs, path::Path};
/// A living entity is about to handle a fall (`LivingFallEvent`). Mods may
/// change the distance and damage multiplier, or cancel fall handling.
/// 357.
#[inline(never)]
pub fn native_living_fall(
entity: u32,
kind: &str,
distance: &mut f64,
multiplier: &mut f32,
cancel: &mut bool,
) {
crate::native_client_hooks::living_fall(entity, kind, distance, multiplier, cancel);
}
pub fn load(root: &Path, properties: &MovementProperties<'_>) -> Result<BlockLandingRegistry> {
let raw: BTreeMap<String, serde_json::Value> =
serde_json::from_slice(&fs::read(root.join("landing-definitions.json"))?)?;
@@ -290,6 +304,25 @@ impl MotionWorld<'_, '_, '_> {
}) as i64;
self.fall_event(FallEvent::Statistic(rounded as i32));
}
let mut distance = distance;
let mut multiplier = multiplier;
let mut cancel = false;
if !self.player.replaying {
native_living_fall(
self.player.entity_id as u32,
"minecraft:player",
&mut distance,
&mut multiplier,
&mut cancel,
);
}
// A canceled fall still lands on the block (`Block.fallOn`).
if cancel {
if let Some((state, position, original_distance)) = contact {
self.mod_block_fall(state, position, original_distance, 0.0);
}
return Ok(false);
}
living_fall::cause_living_fall_damage(
&mut FallCall {
world: self,
+4
View File
@@ -3540,7 +3540,11 @@ pub(crate) mod tests {
*max_stack = 7;
}
},
living_heal: |_, _, _| {},
living_breathe: |_, _, _, _, _| {},
living_drown: |_, _, _, _, _, _| {},
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
ingredient: |_, _, _| {},
});
assert!(installed.is_ok());
+9 -1
View File
@@ -3341,6 +3341,14 @@ impl Mob {
});
true
}
/// `LivingEntity.heal`, with the native heal hook.
pub fn heal(&mut self, mut amount: f32) {
let mut cancel = false;
crate::hunger::native_living_heal(self.id, &mut amount, &mut cancel);
if !cancel && self.health > 0.0 {
self.health = (self.health + amount).clamp(0.0, self.attrs.max_health);
}
}
/// `HealOrHarmMobEffect.applyInstantenousEffect`: a harming effect hurts a
/// living mob (6 << amplifier, magic) and heals an undead one, a healing effect
/// the reverse (4 << amplifier), both scaled by `scale`.
@@ -3353,7 +3361,7 @@ impl Mob {
);
} else {
let heal = (scale * f64::from(4i32 << amp)) as i32 as f32;
self.health = (self.health + heal).min(self.attrs.max_health);
self.heal(heal);
}
}
pub fn add_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
+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 {
+1
View File
@@ -433,6 +433,7 @@ pub fn dash(m: &mut Mob, amount: f32, events: &mut Vec<MobEvent>) -> bool {
m.velocity.y += 1.4285 * a * 0.42;
m.extra.anim_ticks = DASH_COOLDOWN;
m.extra.flag2 = true;
crate::motion::native_living_jump(m.id, &m.kind.id);
sound(m, voice(m, 3), events);
true
}
+2 -1
View File
@@ -718,6 +718,7 @@ pub fn ridden_step(m: &mut Mob) {
m.velocity.x += f64::from(-0.4 * s * r.jump);
m.velocity.z += f64::from(0.4 * c * r.jump);
}
crate::motion::native_living_jump(m.id, &m.kind.id);
if let Some(rider) = m.rider.as_mut() {
rider.jump = 0.0;
}
@@ -751,7 +752,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
return super::llama::ai_step(m, w);
}
if m.alive() && m.random.next_int_bound(900).unwrap_or(1) == 0 && m.death_time == 0 {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
// Grazing (canEatGrass: horses and donkeys alike).
if m.alive() && m.rider.is_none() {
@@ -574,7 +574,7 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld) {
let wet = w.precipitation_at(x, y, z).is_some();
let interval = if wet { 20 } else { 600 };
if m.age.is_multiple_of(interval) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
}
still_tick(m, w);
+23 -2
View File
@@ -1029,10 +1029,28 @@ impl Mob {
// AbstractHorse.causeFallDamage hurts the horse first and passes the fall
// on to its riders only when it was hurt.
let horse = super::equine::is_equine(self.species());
// `Block.fallOn` is told the fall as it was before the mods' changes.
let landed = distance;
let mut distance = distance;
let mut modifier = modifier;
if !horse {
let mut cancel = false;
crate::landing::native_living_fall(
self.id,
&self.kind.id,
&mut distance,
&mut modifier,
&mut cancel,
);
if cancel {
self.mod_block_fall(contact, landed, 0.0);
return false;
}
}
// Entity.causeFallDamage: nothing happens to a fall immune entity, nor
// to those on it.
if self.kind.flags.fall_immune {
self.mod_block_fall(contact, distance, 0.0);
self.mod_block_fall(contact, landed, 0.0);
return false;
}
let power = distance + 1.0e-6 - self.kind.safe_fall_distance;
@@ -1044,7 +1062,7 @@ impl Mob {
super::Species::Goat => damage -= 10.0,
_ => {}
}
let damage = f64::from(self.mod_block_fall(contact, distance, damage as f32));
let damage = f64::from(self.mod_block_fall(contact, landed, damage as f32));
// LivingEntity.causeFallDamage lets the riders fall first.
if !horse {
self.fall_to_passengers(distance, modifier, events);
@@ -1425,6 +1443,7 @@ impl Mob {
self.needs_sync = true;
if self.species() == super::Species::Rabbit {
super::rabbit::jump(self, w);
crate::motion::native_living_jump(self.id, &self.kind.id);
return;
}
if matches!(
@@ -1432,6 +1451,7 @@ impl Mob {
super::Species::Slime | super::Species::MagmaCube | super::Species::SulfurCube
) {
super::cube::jump(self);
crate::motion::native_living_jump(self.id, &self.kind.id);
return;
}
let power = self.jump_power();
@@ -1444,6 +1464,7 @@ impl Mob {
self.velocity.z += f64::from(trig::cos(angle)) * 0.2;
}
}
crate::motion::native_living_jump(self.id, &self.kind.id);
}
pub fn jump_power(&self) -> f32 {
self.kind.jump_strength as f32
+1 -1
View File
@@ -245,7 +245,7 @@ fn spit(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
/// `AbstractHorse.aiStep` for llamas, which never graze: the slow healing.
pub fn ai_step(m: &mut Mob, w: &MobWorld) {
if m.alive() && m.random.next_int_bound(900).unwrap_or(1) == 0 && m.death_time == 0 {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
if is_trader(m) {
super::caravan::trader_llama_tick(m, w);
+38 -2
View File
@@ -80,6 +80,25 @@ fn translucent_layer(m: &Mob, layer: &Layer) -> bool {
|| layer.texture.starts_with("entity/horse/horse_markings")
}
/// Whether a layer is the eyes render type's (`RenderTypes.eyes`: full bright, no
/// normal, at `EYES_TIER`) on a mob whose other draws are plain: spiders',
/// endermen's, phantoms', the creaking's and the copper golem's. The GPU path
/// poses them as [`super::skin::Skin`] `FLAT` draws when improved transparency
/// is off ([`super::skin::SkinModels::eyes`]).
fn eyes_layer(m: &Mob, layer: &Layer) -> bool {
layer.full_bright
&& layer.scroll == 0.0
&& matches!(
m.species(),
Species::Spider
| Species::CaveSpider
| Species::Enderman
| Species::Phantom
| Species::Creaking
| Species::CopperGolem
)
}
/// One draw of a model with a texture.
pub struct Layer {
pub model: String,
@@ -1901,8 +1920,8 @@ fn render_visible(
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
(skin.models.eyes && eyes_layer(m, layer)
|| !layer.full_bright && !translucent_layer(m, layer))
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
@@ -2021,6 +2040,7 @@ fn render_visible(
None => overlay,
};
let start = out.len();
let first_job = skin.as_deref().map(|skin| skin.frame.jobs.len());
put(
&mut skin,
&part,
@@ -2050,6 +2070,22 @@ fn render_visible(
if translucent_layer(m, layer) {
mark_oit(&mut out[start..]);
}
// The same marks on what the GPU path posed of the layer (only eyes
// layers get there among the marked ones): the tags in the overlay word,
// no normal.
if let (Some(skin), Some(first)) = (skin.as_deref_mut(), first_job) {
let tagged = translucent_layer(m, layer);
let eyes = layer.full_bright && m.species() != Species::Warden;
for job in &mut skin.frame.jobs[first..] {
if tagged {
job.overlay |= crate::gfx::OIT_ENTITY;
}
if eyes {
job.overlay |= u32::from(EYES_TIER) << 16;
job.mode = crate::gfx::entity_skin::SkinJob::FLAT;
}
}
}
// The eyes render type's glowing eyes also shine in HDR output, at
// `EYES_TIER` in the overlay's high bits (entity.wgsl fs_hdr_mark).
if layer.full_bright && m.species() != Species::Warden {
+82 -56
View File
@@ -44,6 +44,10 @@ pub struct SkinModels {
/// Item models, the same way (`item_render::ItemRenderState::submit_skinned`).
items: FxHashMap<usize, (Arc<BakedItemModel>, Option<StoredItem>)>,
vertices: u32,
/// Whether the eyes render type's layers (spiders', endermen's...) are posed
/// here too, as [`SkinJob::FLAT`] draws (`gfx::Renderer::skins_eyes`); set
/// each frame.
pub eyes: bool,
}
/// A stored item model: its quads' vertices in order, by runs of quads that
@@ -422,7 +426,7 @@ impl Walk<'_> {
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
item: 0,
mode: SkinJob::MODEL,
cull: u32::from(self.stored.cull),
spare: 0,
});
@@ -440,7 +444,9 @@ mod tests {
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
/// leaves to the CPU draw exactly as before. Twice: with the eyes render
/// type's layers left to the CPU (improved transparency on), then posed
/// too (`SkinModels::eyes`). Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -481,65 +487,85 @@ mod tests {
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
// The mobs posed at least once, without and with the eyes layers.
let mut taken = [std::collections::BTreeSet::new(), Default::default()];
for eyes in [false, true] {
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
models.eyes = eyes;
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(
&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin,
);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
taken[usize::from(eyes)].insert(id.as_str());
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
assert!(posed > 2 * 3 * 40, "only {posed} draws took the GPU path");
let added: Vec<_> = taken[1].difference(&taken[0]).copied().collect();
eprintln!("posed only with the eyes layers: {added:?}");
for id in [
"minecraft:spider",
"minecraft:cave_spider",
"minecraft:enderman",
"minecraft:phantom",
"minecraft:copper_golem",
] {
assert!(added.contains(&id), "{id} not posed with its eyes");
}
assert!(taken[0].is_subset(&taken[1]));
}
/// `BatModel` draws with `entityCutoutCull`: its zero-depth ears, wings and
+1 -1
View File
@@ -287,7 +287,7 @@ fn drink(m: &mut Mob, cx_events: &mut Vec<MobEvent>, target_far: bool) {
"minecraft:fire_resistance" => {
m.add_effect("minecraft:fire_resistance", 3600, 0)
}
"minecraft:healing" => m.health = (m.health + 4.0).min(m.attrs.max_health),
"minecraft:healing" => m.heal(4.0),
"minecraft:swiftness" => m.add_effect("minecraft:speed", 3600, 0),
_ => {}
}
+2 -2
View File
@@ -278,7 +278,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
}
m.extra.counter = left;
if m.age.is_multiple_of(10) {
m.health = (m.health + 10.0).min(m.attrs.max_health);
m.heal(10.0);
}
return;
}
@@ -404,7 +404,7 @@ pub fn custom_server_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEven
}
}
if m.age.is_multiple_of(20) {
m.health = (m.health + 1.0).min(m.attrs.max_health);
m.heal(1.0);
}
}
/// `WitherBoss.hurtServer` filter: not while charging, no arrows once
+88 -1
View File
@@ -110,7 +110,7 @@ pub struct NativeScreenView<'a> {
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`. The title's own screens report theirs (API @API:native_instance_dir@): `title`,
/// `slots`. The title's own screens report theirs (API 326): `title`,
/// `world_select`, `world_create`, `world_create_flat`, `world_edit`,
/// `multiplayer`, `add_server`, `direct_connect`…, with no menu, no title
/// and no slots.
@@ -403,6 +403,51 @@ pub fn mods() -> &'static [Box<dyn Hooks>] {
MODS.get().map_or(&[], |mods| mods.as_ref())
}
/// The bundles' `[mod] id`s, one a [`mods`] entry in the same order.
static MOD_IDS: OnceLock<&'static [&'static str]> = OnceLock::new();
/// Names the entries of [`mods`] by their bundles' `[mod] id`s, so a panic in a hook names its mod.
/// The launcher's generated executable calls it before `entry::main_with`.
pub fn name_mods(ids: &'static [&'static str]) {
if MOD_IDS.set(ids).is_err() {
log::warn!("Trait mods already named; ignoring the second list");
}
}
/// `mods` in order, each marked as running `hook` until the next one is (or the loop ends), so a
/// panic in it names the mod. The generated dispatch loops over it.
fn entered<'a>(mods: &'a [Box<dyn Hooks>], hook: &'static str) -> Entered<'a> {
Entered {
mods: mods.iter().enumerate(),
hook,
guard: None,
}
}
struct Entered<'a> {
mods: std::iter::Enumerate<std::slice::Iter<'a, Box<dyn Hooks>>>,
hook: &'static str,
guard: Option<crate::crash::NativeModGuard>,
}
impl<'a> Iterator for Entered<'a> {
type Item = &'a Box<dyn Hooks>;
fn next(&mut self) -> Option<Self::Item> {
// The last mod's mark goes first, so the next one's puts back what was there before.
drop(self.guard.take());
let (index, hooks) = self.mods.next()?;
self.guard = Some(crate::crash::enter_native_mod(
crate::crash::NativeModCall {
id: MOD_IDS.get().and_then(|ids| ids.get(index)).copied(),
index,
hook: self.hook,
},
));
Some(hooks)
}
}
/// Whether a mod claims a cancellable hook.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flow {
@@ -499,6 +544,17 @@ pub use generated::*;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct Quiet;
impl Hooks for Quiet {}
struct Broken;
impl Hooks for Broken {
fn component_types(&self, _event: &mut ComponentTypesEvent<'_>) {
panic!("a broken mod");
}
}
/// A double with the three `Host` methods the probe calls.
struct Reader {
@@ -564,6 +620,37 @@ mod tests {
}
}
#[test]
fn a_panic_in_a_hook_names_the_mod_and_the_hook() {
name_mods(&["quiet", "broken"]);
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Quiet), Box::new(Broken)];
let caught = Arc::new(Mutex::new(Vec::new()));
let sink = caught.clone();
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let error = crate::crash::panic_report(info, "client").error;
if error.contains("a broken mod") {
sink.lock().unwrap().push(error);
}
}));
let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dispatch_component_types(&mods, &mut Vec::new())
}));
// Once the dispatch has unwound, the thread is in no mod.
let _ = std::panic::catch_unwind(|| panic!("a broken mod's caller"));
std::panic::set_hook(previous);
// The game stops as before: the panic goes on to the dispatch's caller.
assert!(unwound.is_err());
let errors = caught.lock().unwrap();
assert_eq!(errors.len(), 2);
assert!(
errors[0].ends_with("\nin native mod broken, hook component_types"),
"{}",
errors[0]
);
assert!(!errors[1].contains("in native mod"), "{}", errors[1]);
}
#[test]
fn a_test_host_implements_only_what_its_hook_calls() {
let mods = [Box::new(Probe) as Box<dyn Hooks>];
File diff suppressed because it is too large. Load diff
+6 -5
View File
@@ -93,7 +93,8 @@ pub trait Host {
let _ = seed;
unimplemented!("Host::draw_item")
}
/// Access a server-owned holder's attachment map.
/// Access a server-owned holder's attachment map. On a client it is the
/// copy of what the server synced: `set` and `remove` change only the copy.
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> {
let _ = holder;
unimplemented!("Host::attachments")
@@ -1163,7 +1164,7 @@ pub trait Host {
/// for a player who is off (who joins hidden), until a mod sets it again
/// or the server stops. Works anywhere on a server, inside a player's
/// hook too; false on a client and in a world nobody else can join.
/// API @API:hide_player@.
/// API 314.
fn hide_player(&mut self, uuid: u128, level: Option<u8>) -> bool {
let _ = uuid;
let _ = level;
@@ -1172,7 +1173,7 @@ pub trait Host {
/// Draw a stack and its slot decorations, except the count, at `at`,
/// `size` GUI pixels square. False when nothing was drawn: an unknown item
/// or an empty stack (air, or a count of 0, as `ItemStack.isEmpty`).
/// API @API:item_stack@.
/// API 313.
fn item_stack(&self, batch: &mut HudBatch, stack: &ItemStack, at: [f32; 2], size: f32) -> bool {
let _ = batch;
let _ = stack;
@@ -1186,7 +1187,7 @@ pub trait Host {
/// is `width * height` pixels of four bytes, each side at most
/// [`MOD_IMAGE_SIZE`](crate::hud::MOD_IMAGE_SIZE); false when it is not that
/// size. The upload costs a texture patch of the image, so send one when the
/// image changed, not every frame. API @API:mod_image@.
/// image changed, not every frame. API 355.
fn mod_image(&mut self, width: u32, height: u32, rgba: &[u8]) -> bool {
let _ = width;
let _ = height;
@@ -1198,7 +1199,7 @@ pub trait Host {
/// title's screens create worlds and read packs there. A world's own
/// `datapacks/` is in that world's folder (`saves/<world>/datapacks`).
/// The folder the game runs in (`--player-dir` moves a player's options
/// and servers only, not the instance). Nothing is created. API @API:native_instance_dir@.
/// and servers only, not the instance). Nothing is created. API 326.
fn instance_dir(&self) -> PathBuf {
unimplemented!("Host::instance_dir")
}
+18 -1
View File
@@ -2033,9 +2033,26 @@ fn link(target: &Path, at: &Path) -> std::io::Result<()> {
}
Ok(())
} else {
fs::copy(target, at).map(|_| ())
link_file(target, at, |target, at| fs::hard_link(target, at))
}
}
/// A file of the overlay where symlinks need a privilege: a hard link to the base's file, which costs a name where
/// a copy costs the file's bytes (the base is ~28,000 files, 0.6 GiB), or a copy when no link can be made (another
/// volume, a filesystem without links, the NTFS limit on links to one file). A link shares the base file's data,
/// which is safe because the overlay is only ever replaced name by name (`Overlay::write` removes the name before it
/// writes), never written in place. A symlink is copied through, as before: linked, it would stay a symlink.
#[cfg(any(not(unix), test))]
fn link_file(
target: &Path,
at: &Path,
hard_link: impl FnOnce(&Path, &Path) -> std::io::Result<()>,
) -> std::io::Result<()> {
let symlink = fs::symlink_metadata(target).is_ok_and(|m| m.file_type().is_symlink());
if !symlink && hard_link(target, at).is_ok() {
return Ok(());
}
fs::copy(target, at).map(|_| ())
}
/// A tag file's JSON text with `more` appended to its values (no repeats).
fn merge_tag(text: Option<&str>, more: &[J], what: &str) -> Result<String> {
@@ -3774,3 +3774,168 @@ fn a_feature_gives_a_mods_sign_its_block_entity() {
assert_eq!(generate(true), (placed, placed));
let _ = fs::remove_dir_all(&scratch);
}
/// A small stand-in for the base root: nested folders, an empty file, an empty folder.
fn small_base(base: &Path) {
for (name, bytes) in [
("items.json", &b"{}"[..]),
("empty.log", &b""[..]),
("assets/minecraft/lang/en_us.json", &b"{\"a\": 1}"[..]),
(
"data/minecraft/tags/block/logs.json",
&b"{\"values\": []}"[..],
),
] {
let path = base.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
fs::create_dir_all(base.join("worldgen/empty-folder")).unwrap();
}
/// Every folder (`None`) and file (`Some(bytes)`) under `root`, by relative path.
fn tree(root: &Path) -> BTreeMap<String, Option<Vec<u8>>> {
fn walk(root: &Path, dir: &Path, out: &mut BTreeMap<String, Option<Vec<u8>>>) {
for entry in fs::read_dir(dir).unwrap().flatten() {
let path = entry.path();
let rel = path
.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/");
if path.is_dir() {
out.insert(rel, None);
walk(root, &path, out);
} else {
out.insert(rel, Some(fs::read(&path).unwrap()));
}
}
}
let mut out = BTreeMap::new();
walk(root, root, &mut out);
out
}
/// Writes into a file where it is (same data), not by replacing it: what a link shares and a copy does not.
fn edit_in_place(path: &Path, bytes: &[u8]) {
use std::io::Write;
let mut file = fs::File::options()
.write(true)
.truncate(true)
.open(path)
.unwrap();
file.write_all(bytes).unwrap();
}
/// The overlay looks like the base, and shares its files (a hard link on Windows, a symlink elsewhere) rather than
/// holding copies of them.
#[test]
fn the_overlay_shares_the_base_files() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-share-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let (base, out) = (scratch.join("base"), scratch.join("root"));
small_base(&base);
Overlay::new(&base, &out).unwrap();
assert_eq!(tree(&out), tree(&base));
edit_in_place(
&base.join("assets/minecraft/lang/en_us.json"),
b"{\"a\": 2}",
);
assert_eq!(
fs::read(out.join("assets/minecraft/lang/en_us.json")).unwrap(),
b"{\"a\": 2}",
"the overlay copied the base file instead of linking it"
);
let _ = fs::remove_dir_all(&scratch);
}
/// Writing a file of the overlay replaces the name, so the base file it was linked to keeps its bytes and the other
/// files of the overlay still follow the base.
#[test]
fn writing_into_the_overlay_leaves_the_base_alone() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-write-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
let (base, out) = (scratch.join("base"), scratch.join("root"));
small_base(&base);
let overlay = Overlay::new(&base, &out).unwrap();
let before = tree(&base);
overlay
.write(
"data/minecraft/tags/block/logs.json",
b"{\"values\": [\"mod:log\"]}",
)
.unwrap();
overlay.write("items.json", b"{\"mod\": 1}").unwrap();
overlay.write("assets/mod/new.json", b"{}").unwrap();
assert_eq!(tree(&base), before, "a write reached the base");
assert_eq!(
fs::read(out.join("data/minecraft/tags/block/logs.json")).unwrap(),
b"{\"values\": [\"mod:log\"]}"
);
assert_eq!(fs::read(out.join("items.json")).unwrap(), b"{\"mod\": 1}");
edit_in_place(
&base.join("assets/minecraft/lang/en_us.json"),
b"{\"a\": 3}",
);
assert_eq!(
fs::read(out.join("assets/minecraft/lang/en_us.json")).unwrap(),
b"{\"a\": 3}"
);
// A rebuild starts by removing the overlay: that removes names, not the base's data.
fs::remove_dir_all(&out).unwrap();
assert_eq!(fs::read(base.join("items.json")).unwrap(), b"{}");
let _ = fs::remove_dir_all(&scratch);
}
/// Where a hard link cannot be made (another volume, a filesystem without links) a file is copied, as it always was
/// on Windows; where it can, the file is linked.
#[test]
fn a_file_is_linked_or_else_copied() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-linkfile-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
let target = scratch.join("base.json");
fs::write(&target, b"original").unwrap();
let linked = scratch.join("linked.json");
link_file(&target, &linked, |from, to| fs::hard_link(from, to)).unwrap();
edit_in_place(&target, b"edited");
assert_eq!(fs::read(&linked).unwrap(), b"edited", "not a link");
let copied = scratch.join("copied.json");
fs::write(&target, b"original").unwrap();
link_file(&target, &copied, |_, _| {
Err(std::io::Error::other("no links here"))
})
.unwrap();
edit_in_place(&target, b"edited");
assert_eq!(fs::read(&copied).unwrap(), b"original", "not a copy");
// A target that is not there is an error whichever way the file is made.
let missing = link_file(
&scratch.join("missing.json"),
&scratch.join("x.json"),
|from, to| fs::hard_link(from, to),
);
assert!(missing.is_err());
let _ = fs::remove_dir_all(&scratch);
}
/// A symlink in the base is copied through, as before; linked, the overlay's file would be a symlink too.
#[cfg(unix)]
#[test]
fn a_symlink_file_is_copied_through() {
let scratch = std::env::temp_dir().join(format!("horizon-mod-symfile-{}", std::process::id()));
let _ = fs::remove_dir_all(&scratch);
fs::create_dir_all(&scratch).unwrap();
fs::write(scratch.join("real.json"), b"real").unwrap();
std::os::unix::fs::symlink("real.json", scratch.join("alias.json")).unwrap();
let at = scratch.join("copy.json");
link_file(&scratch.join("alias.json"), &at, |from, to| {
fs::hard_link(from, to)
})
.unwrap();
assert!(!fs::symlink_metadata(&at).unwrap().file_type().is_symlink());
assert_eq!(fs::read(&at).unwrap(), b"real");
let _ = fs::remove_dir_all(&scratch);
}
+17 -2
View File
@@ -43,6 +43,12 @@ use std::{
sync::Arc,
};
/// A living entity jumped (`LivingEvent.LivingJumpEvent`). 359.
#[inline(never)]
pub fn native_living_jump(entity: u32, kind: &str) {
crate::native_client_hooks::living_jump(entity, kind);
}
pub(crate) mod environment;
mod fluids;
pub use fluids::{
@@ -400,6 +406,8 @@ pub struct PlayerMotion {
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
/// its moves check no fall damage.
pub server_copy: bool,
/// A speculative anticheat replay, whose native callbacks must not escape.
pub(crate) replaying: bool,
/// The span of `events` a wall hit on the server's copy pushed (sound and
/// damage): `server_travel` keeps them while it drops the simulated moves.
pub wall_hit_events: Option<std::ops::Range<usize>>,
@@ -604,6 +612,7 @@ impl PlayerMotion {
burn_from_stepping_immune: false,
old_position: position,
server_copy: false,
replaying: false,
wall_hit_events: None,
client_side: false,
entity_id: 0,
@@ -1202,13 +1211,16 @@ impl MotionWorld<'_, '_, '_> {
/// client's own keys, and the sprinting it said it had (its
/// ServerboundPlayerCommandPacket comes before the tick's move).
pub fn replayed_tick(&mut self, keys: crate::sprint::Keys, sprinting: bool) -> Result<()> {
self.local_tick(keys, |player, _| {
self.player.replaying = true;
let result = self.local_tick(keys, |player, _| {
if player.sprinting == sprinting {
Ok(())
} else {
player.set_sprinting(sprinting)
}
})
});
self.player.replaying = false;
result
}
/// `LocalPlayer.aiStep` and the travel it ends with; `sprint` is the
/// sprint phase.
@@ -1953,6 +1965,9 @@ impl MotionWorld<'_, '_, '_> {
}
pub fn jump_from_ground(&mut self) -> Result<()> {
living_motion::jump_from_ground(self)?;
if !self.player.replaying {
native_living_jump(self.player.entity_id as u32, "minecraft:player");
}
self.player
.events
.push(MotionEvent::Health(crate::health::HealthEvent::Statistic {
@@ -1,6 +1,6 @@
//! Player liquid travel and environmental damage over the shared fluid state.
use super::*;
use crate::breathing::{Breath, apply, breathe};
use crate::breathing::{Breath, breathe, drown};
use horizon_core::random::RandomSource;
use horizon_world::{
fluid::FluidKind,
@@ -98,14 +98,21 @@ impl MotionWorld<'_, '_, '_> {
} else {
breathe(self.player.entity_id as u32, "minecraft:player", vanilla)
};
let (air, drowned) = apply(self.player.air, 300, breath);
self.player.air = air;
if drowned {
// LivingDrownEvent, likewise.
if let Some(drowning) = drown(
self.player.entity_id as u32,
"minecraft:player",
&mut self.player.air,
breath,
!self.player.client_side,
) {
self.player.events.push(MotionEvent::Drown);
self.player.events.push(MotionEvent::Health(
crate::health::HealthEvent::DrownDamage(2.0),
));
self.environment_hurt("minecraft:drown", 2.0)?;
if let Some(damage) = drowning.damage {
self.player.events.push(MotionEvent::Health(
crate::health::HealthEvent::DrownDamage(damage),
));
self.environment_hurt("minecraft:drown", damage)?;
}
}
if self.player.is_in_lava() {
self.player.ignite_for_ticks(300)?;
@@ -6,7 +6,11 @@ use std::sync::OnceLock;
/// Client-library hooks, installed before any menu, entity or recipe is used.
pub struct NativeClientHooks {
pub menu_slot_rules: fn(&str, usize, &ItemStack, &mut bool, &mut u32),
pub living_heal: fn(u32, &mut f32, &mut bool),
pub living_breathe: fn(u32, &str, &mut bool, &mut i32, &mut i32),
pub living_drown: fn(u32, &str, &mut bool, &mut f32, i32, &mut bool),
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
pub living_jump: fn(u32, &str),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
}
@@ -29,6 +33,12 @@ pub(crate) fn menu_slot_rules(
}
}
pub(crate) fn living_heal(entity: u32, amount: &mut f32, cancel: &mut bool) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_heal)(entity, amount, cancel);
}
}
pub(crate) fn living_breathe(
entity: u32,
kind: &str,
@@ -41,6 +51,44 @@ pub(crate) fn living_breathe(
}
}
pub(crate) fn living_drown(
entity: u32,
kind: &str,
is_drowning: &mut bool,
damage_amount: &mut f32,
bubble_count: i32,
cancel: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_drown)(
entity,
kind,
is_drowning,
damage_amount,
bubble_count,
cancel,
);
}
}
pub(crate) fn living_fall(
entity: u32,
kind: &str,
distance: &mut f64,
multiplier: &mut f32,
cancel: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_fall)(entity, kind, distance, multiplier, cancel);
}
}
pub(crate) fn living_jump(entity: u32, kind: &str) {
if let Some(hooks) = HOOKS.get() {
(hooks.living_jump)(entity, kind);
}
}
pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<String>) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient)(kind, data, items);
+57 -7
View File
@@ -94,9 +94,10 @@ pub enum ProjectileKind {
/// flight that bursts on whatever it meets.
WindCharge,
BreezeWindCharge,
/// `ShulkerBullet`: homing on `target` in axis-aligned steps.
/// `ShulkerBullet`: homing on `target` in axis-aligned steps; one with
/// no target (a summoned one) falls like any projectile.
ShulkerBullet {
target: Who,
target: Option<Who>,
/// The current step direction (3D data value), if any.
dir: Option<u8>,
steps: i32,
@@ -116,6 +117,8 @@ pub enum ProjectileKind {
/// type is the entity's own and `/summon` can pair either with any `Item`,
/// so it is kept next to the stack (a thrown potion's follows its item).
Potion(Box<crate::items::ItemStack>, bool),
/// `ThrownExperienceBottle`: its own entity type, gravity 0.07.
ExperienceBottle,
/// a content mod's type.
Thrown(&'static ThrownType),
}
@@ -148,6 +151,7 @@ impl ProjectileKind {
| Self::EnderPearl
| Self::LlamaSpit
| Self::Potion(..)
| Self::ExperienceBottle
| Self::Thrown(_) => 0.25,
Self::SmallFireball
| Self::WindCharge
@@ -175,6 +179,7 @@ impl ProjectileKind {
// `ThrownSplashPotion`/`ThrownLingeringPotion`: the item names it.
Self::Potion(_, true) => "minecraft:lingering_potion",
Self::Potion(_, false) => "minecraft:splash_potion",
Self::ExperienceBottle => "minecraft:experience_bottle",
Self::Thrown(t) => t.id,
}
}
@@ -314,13 +319,16 @@ impl crate::entity_rules::EntityRules for Projectile {
}
/// Each projectile class's `getDefaultGravity`.
fn get_default_gravity(&self) -> f64 {
match self.kind {
match &self.kind {
ProjectileKind::Arrow { .. } | ProjectileKind::Trident => 0.05,
ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
| ProjectileKind::WitherSkull { .. } => 0.0,
ProjectileKind::LlamaSpit => 0.06,
// `ThrownExperienceBottle.getDefaultGravity` is 0.07.
ProjectileKind::ExperienceBottle => 0.07,
ProjectileKind::Potion { .. } => 0.05,
ProjectileKind::ShulkerBullet { .. } => 0.04,
_ => 0.03,
}
}
@@ -465,7 +473,7 @@ impl Projectile {
|| self
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow")
.is_some_and(|a| a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow")
}
/// `Arrow.ID_EFFECT_COLOR` (`updateColor`): the opaque colour of the
/// potion the arrow carries, from its tipped pickup item or the effect a
@@ -598,7 +606,7 @@ impl Projectile {
);
let mut p = Self::new(
ProjectileKind::ShulkerBullet {
target,
target: Some(target),
dir: Some(1),
steps: 0,
delta: [0.0; 3],
@@ -629,7 +637,11 @@ impl Projectile {
]
}
fn target_box(&self, w: &impl ProjectileWorld) -> Option<Aabb> {
let ProjectileKind::ShulkerBullet { target, .. } = self.kind else {
let ProjectileKind::ShulkerBullet {
target: Some(target),
..
} = self.kind
else {
return None;
};
w.targets()
@@ -743,7 +755,8 @@ impl Projectile {
v.z + (dl[2] - v.z) * 0.2,
);
} else {
self.velocity.y -= 0.04;
// `Entity.applyGravity`: `getGravity` is 0 under NoGravity.
self.velocity.y -= crate::entity_rules::EntityRules::get_gravity(self);
}
let from = self.position;
let to = from.add_vector(self.velocity);
@@ -1004,6 +1017,10 @@ impl Projectile {
pub fn drawn_item(&self) -> Option<crate::items::ItemStack> {
match &self.kind {
ProjectileKind::Potion(stack, _) => Some((**stack).clone()),
ProjectileKind::ExperienceBottle => Some(crate::items::ItemStack::new(
"minecraft:experience_bottle",
1,
)),
ProjectileKind::Thrown(t) => Some(crate::items::ItemStack::new(t.item, 1)),
_ => None,
}
@@ -1018,6 +1035,7 @@ impl Projectile {
"item/fire_charge"
}
ProjectileKind::Potion(stack, _) => potion_sprite(&stack.item),
ProjectileKind::ExperienceBottle => "item/experience_bottle",
ProjectileKind::LlamaSpit => "entity/llama/llama_spit",
// Drawn as `WindChargeModel` and `ShulkerBulletModel` by the game.
ProjectileKind::WindCharge
@@ -1298,6 +1316,38 @@ mod tests {
assert_eq!(settings("ruby:late"), (5, 3, true));
}
/// `ShulkerBullet.tick` with no target: `applyGravity` (0.04, none under
/// NoGravity), then the move, no drag. 20 ticks from y 110 fall 8.4.
#[test]
fn a_shulker_bullet_without_a_target_falls_unless_no_gravity() {
let bullet = || {
Projectile::new(
ProjectileKind::ShulkerBullet {
target: None,
dir: None,
steps: 0,
delta: [0.0; 3],
seed: 0,
},
Vec3::new(0.5, 110.0, 0.5),
None,
)
};
let w = Empty(vec![]);
let mut falling = bullet();
let mut held = bullet();
held.no_gravity = true;
for _ in 0..20 {
falling.tick(&w);
held.tick(&w);
}
assert!(
(falling.position.y - 101.6).abs() < 1e-6,
"{}",
falling.position.y
);
assert_eq!(held.position, Vec3::new(0.5, 110.0, 0.5));
}
#[test]
fn llama_spit_moves_first_then_drags_and_falls() {
let mut p = Projectile::new(ProjectileKind::LlamaSpit, Vec3::new(0.0, 100.0, 0.0), None);
+2
View File
@@ -5242,6 +5242,7 @@ mod tests {
},
}),
copy_on_death: false,
sync: false,
};
let mut map = Attachments::default();
map.set(&key, 7).unwrap();
@@ -5271,6 +5272,7 @@ mod tests {
},
}),
copy_on_death: false,
sync: false,
};
let mut chunk = Chunk::empty(2, 3);
let mut extras = ChunkExtras::default();
+29 -1
View File
@@ -1,6 +1,8 @@
//! A mod crate's view of the mod API: `Hooks` is implemented with the names `mod_api` exports.
//! The test is that this file builds; #7044's mod could not name `key`'s event.
use horizon_client::mod_api::{Hooks, Host, InputKeyEvent, KeyEvent, winit};
use horizon_client::mod_api::{
Hooks, Host, InputKeyEvent, KeyEvent, MovementInputEvent, dispatch_movement_input, winit,
};
struct Keys;
@@ -18,3 +20,29 @@ impl Hooks for Keys {
fn a_mod_implements_the_key_hooks() {
let _: Box<dyn Hooks> = Box::new(Keys);
}
#[test]
fn movement_input_writes_back_all_fields() {
struct TestHost;
impl Host for TestHost {}
struct Movement;
impl Hooks for Movement {
fn movement_input(&self, _host: &dyn Host, event: &mut MovementInputEvent) {
event.forward = -0.5;
event.strafe = 0.25;
event.jump = true;
event.sneak = true;
}
}
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Movement)];
let (mut forward, mut strafe, mut jump, mut sneak) = (1.0, 0.0, false, false);
dispatch_movement_input(
&TestHost,
&mods,
&mut forward,
&mut strafe,
&mut jump,
&mut sneak,
);
assert_eq!((forward, strafe, jump, sneak), (-0.5, 0.25, true, true));
}
+171
View File
@@ -467,6 +467,124 @@ fn upgrade_condition(c: &mut Value) {
{
*c = json!(named);
}
match c["type"].as_str() {
Some("minecraft:entity_properties") => {
if let Some(p) = c.get_mut("predicate") {
upgrade_entity_predicate(p);
}
}
Some("minecraft:damage_source_properties") => {
for key in ["direct_entity", "source_entity"] {
if let Some(p) = c["predicate"].get_mut(key) {
upgrade_entity_predicate(p);
}
}
}
_ => {}
}
}
/// The fields of a 1.21 entity predicate that are, in 26.3, sub-predicates
/// keyed by their registry name (`minecraft:<field>`), as 26.3's own data
/// writes them (`"minecraft:flags": {"is_on_fire": true}`).
const ENTITY_FIELDS: [&str; 15] = [
"distance",
"movement",
"location",
"stepping_on",
"movement_affected_by",
"effects",
"nbt",
"flags",
"equipment",
"periodic_tick",
"team",
"slots",
"components",
"predicates",
"type_specific",
];
/// Entity predicates nested in an entity predicate (1.21 field names).
const ENTITY_NESTED: [&str; 3] = ["vehicle", "passenger", "targeted_entity"];
/// Kinds of 1.21's `type_specific` whose `variant` 26.3 matches as a component
/// (`"minecraft:components": {"minecraft:cat/variant": ...}`).
const VARIANT_KINDS: [&str; 14] = [
"axolotl",
"cat",
"chicken",
"cow",
"fox",
"frog",
"horse",
"llama",
"mooshroom",
"painting",
"parrot",
"pig",
"rabbit",
"wolf",
];
/// A 1.21 entity predicate in 26.3's shape, in place: `type` is
/// `minecraft:entity_type`, every other field `minecraft:<field>`, `vehicle`,
/// `passenger` and `targeted_entity` recursively; `type_specific {"type": k,
/// ...}` is `minecraft:type_specific/<k>` (`slime` is `cube_mob`), a variant
/// kind's `variant` and a sheep's `color` go to `minecraft:components`. The
/// game's loot code reads only these keys, so an old-shaped predicate never
/// matched. A predicate already in 26.3's shape stays as it is.
pub(crate) fn upgrade_entity_predicate(p: &mut Value) {
let Some(o) = p.as_object_mut() else {
return;
};
let key = |f: &str| format!("minecraft:{f}");
if let Some(t) = o.remove("type") {
o.insert(key("entity_type"), t);
}
for f in ENTITY_NESTED {
if let Some(mut inner) = o.remove(f) {
upgrade_entity_predicate(&mut inner);
o.insert(key(f), inner);
}
}
for f in ENTITY_FIELDS {
if let Some(v) = o.remove(f) {
o.insert(key(f), v);
}
}
let Some(Value::Object(mut ts)) = o.remove("minecraft:type_specific") else {
return;
};
let kind = ts.remove("type");
let kind = kind.as_ref().and_then(Value::as_str).unwrap_or("any");
let kind = match kind.trim_start_matches("minecraft:") {
"slime" => "cube_mob",
k => k,
};
let component = match kind {
k if VARIANT_KINDS.contains(&k) => {
ts.remove("variant").map(|v| (format!("{k}/variant"), v))
}
"sheep" => ts.remove("color").map(|v| ("sheep/color".to_owned(), v)),
_ => None,
};
if let Some((name, v)) = component {
let components = o.entry(key("components")).or_insert_with(|| json!({}));
if let Some(c) = components.as_object_mut() {
c.insert(key(&name), v);
}
}
// A player's `looking_at`, a lightning bolt's `entity_struck`.
for f in ["looking_at", "entity_struck"] {
if let Some(v) = ts.get_mut(f) {
upgrade_entity_predicate(v);
}
}
// A variant kind with nothing else to match is the component alone.
if !ts.is_empty() || !(kind == "any" || VARIANT_KINDS.contains(&kind)) {
o.insert(key(&format!("type_specific/{kind}")), Value::Object(ts));
}
}
/// 26.3's named tool conditions for the 1.21.1 predicates they replace.
@@ -1424,6 +1542,59 @@ mod tests {
assert_eq!(new, glass);
}
/// Entity predicates in 26.3's keys, as its own tables write them; the
/// 1.21 shape never matched in the game's loot code.
#[test]
fn entity_predicates_get_26_3_keys() {
let mut t = json!({"pools": [{"rolls": 1, "entries": [{"type": "minecraft:item", "name": "m:hide",
"functions": [{"function": "minecraft:furnace_smelt", "conditions": [{
"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"flags": {"is_on_fire": true}}}]}],
"conditions": [
{"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"type": "minecraft:cow", "vehicle": {"type": "#minecraft:boats"},
"type_specific": {"type": "minecraft:frog", "variant": "minecraft:warm"}}},
{"condition": "minecraft:damage_source_properties", "predicate": {
"source_entity": {"type": "minecraft:player", "equipment": {"mainhand": {"items": "minecraft:shears"}}},
"is_direct": true}},
{"condition": "minecraft:entity_properties", "entity": "attacker",
"predicate": {"type_specific": {"type": "minecraft:slime", "size": {"min": 2}}}},
{"condition": "minecraft:entity_properties", "entity": "this",
"predicate": {"type_specific": {"type": "sheep", "sheared": false, "color": "black"}}}
]}]}]});
upgrade_loot(&mut t);
let e = &t["pools"][0]["entries"][0];
assert_eq!(
e["modifier"][0]["condition"]["predicate"],
json!({"minecraft:flags": {"is_on_fire": true}})
);
let c = &e["condition"]["terms"];
assert_eq!(
c[0]["predicate"],
json!({"minecraft:entity_type": "minecraft:cow",
"minecraft:vehicle": {"minecraft:entity_type": "#minecraft:boats"},
"minecraft:components": {"minecraft:frog/variant": "minecraft:warm"}})
);
assert_eq!(
c[1]["predicate"],
json!({"source_entity": {"minecraft:entity_type": "minecraft:player",
"minecraft:equipment": {"mainhand": {"items": "minecraft:shears"}}}, "is_direct": true})
);
assert_eq!(
c[2]["predicate"],
json!({"minecraft:type_specific/cube_mob": {"size": {"min": 2}}})
);
assert_eq!(
c[3]["predicate"],
json!({"minecraft:type_specific/sheep": {"sheared": false},
"minecraft:components": {"minecraft:sheep/color": "black"}})
);
// Already 26.3's shape: unchanged.
let again = t.clone();
upgrade_loot(&mut t);
assert_eq!(t, again);
}
#[test]
fn types_bare_number_ranges() {
// When Dungeons Arise's and Structory's chests: rolls and counts as
+99 -4
View File
@@ -42,10 +42,12 @@ pub fn read(path: &Path) -> Result<JarInfo, String> {
zip.by_name(name).ok()?.read_to_string(&mut s).ok()?;
Some(s)
};
let mut info = if let Some(t) = text(&mut zip, "META-INF/neoforge.mods.toml")
.or_else(|| text(&mut zip, "META-INF/mods.toml"))
{
mods_toml(&t)?
let tomls: Vec<String> = ["META-INF/neoforge.mods.toml", "META-INF/mods.toml"]
.iter()
.filter_map(|n| text(&mut zip, n))
.collect();
let mut info = if !tomls.is_empty() {
metadata(&tomls)?
} else if let Some(t) = text(&mut zip, "fabric.mod.json") {
fabric(&t)?
} else if let Some(t) = text(&mut zip, "mcmod.info") {
@@ -84,6 +86,71 @@ fn manifest_value(manifest: &str, key: &str) -> Option<String> {
.filter(|v| !v.is_empty())
}
/// The jar's mod from its metadata files (`neoforge.mods.toml` first, then
/// `mods.toml`). A jar built for one loader may carry the other loader's file
/// as an unfilled template (`modId = "${id}"`, `[[dependencies.${id}]]`), which
/// its own loader never reads: the first file that parses and names a real mod
/// id is the one. When none parses, the first file is read line by line
/// ([`mods_toml_lines`]), as a loader that reads past what it does not need.
pub fn metadata(tomls: &[String]) -> Result<JarInfo, String> {
let real = |i: &JarInfo| !i.mod_id.is_empty() && !i.mod_id.contains("${");
let mut first_error = None;
for t in tomls {
match mods_toml(t) {
Ok(i) if real(&i) => return Ok(i),
Ok(_) => {}
Err(e) => {
first_error.get_or_insert(e);
}
}
}
tomls
.iter()
.map(|t| mods_toml_lines(t))
.find(real)
.ok_or_else(|| first_error.unwrap_or_else(|| "mods.toml: no [[mods]] modId".into()))
}
/// The first `[[mods]]` table's `modId`, `displayName` and `version` and the
/// top-level `license`, read line by line (`key = "value"`, `#` comments), for
/// a metadata file a TOML parser refuses. Dependencies are not read.
pub fn mods_toml_lines(text: &str) -> JarInfo {
let mut info = JarInfo::default();
// 0: top level, 1: the first [[mods]], 2: past it.
let mut section = 0;
for line in text.lines() {
let line = line.trim();
if line.starts_with('[') {
let name = line.trim_matches(['[', ']']).trim();
section = match (section, name) {
(0, "mods") => 1,
_ => 2,
};
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let value = value.trim();
let Some(value) = value
.strip_prefix('"')
.and_then(|v| v.split_once('"'))
.or_else(|| value.strip_prefix('\'').and_then(|v| v.split_once('\'')))
.map(|(v, _)| v.to_owned())
else {
continue;
};
match (section, key.trim()) {
(0, "license") => info.license = value,
(1, "modId") if info.mod_id.is_empty() => info.mod_id = value,
(1, "displayName") => info.name = value,
(1, "version") => info.version = value,
_ => {}
}
}
info
}
/// `[[mods]]` (the first one is the jar's mod), the top-level `license`, and
/// `[[dependencies.<id>]]` with `type = "required"` (or the older
/// `mandatory = true`).
@@ -295,6 +362,34 @@ mandatory = true
assert_eq!(info.depends, ["geckolib", "curios"]);
}
/// Drip Sounds' Forge jar carries an unfilled NeoForge template beside its
/// `mods.toml`; a file no parser reads still gives its id line by line.
#[test]
fn templates_and_broken_files_are_read_tolerantly() {
let template = "license = \"LGPL\"\n[[mods]]\nmodId = \"${id}\"\nversion = \"${version}\"\n\n[[dependencies.${id}]]\nmodId = \"minecraft\"\n";
let forge = "license = \"LGPL-3.0-only\"\n[[mods]]\nmodId = \"waterdripsound\"\ndisplayName = \"Drip Sounds\"\nversion = \"0.6.3\"\n[[dependencies.waterdripsound]]\nmodId = \"cloth_config\"\nmandatory = true\n";
assert!(mods_toml(template).is_err());
let info = metadata(&[template.to_owned(), forge.to_owned()]).unwrap();
assert_eq!(
(info.mod_id.as_str(), info.version.as_str()),
("waterdripsound", "0.6.3")
);
assert_eq!(info.depends, ["cloth_config"]);
// Nothing parses: the lines still name the mod (a duplicate key is a TOML error).
let broken = "license = 'MIT' # x\n[[mods]]\nmodId = \"ruby\"\nmodId = \"ruby\"\nversion = \"2\"\ndisplayName = \"Ruby\"\n[[mods]]\nmodId = \"other\"\n";
assert!(mods_toml(broken).is_err());
let info = metadata(&[template.to_owned(), broken.to_owned()]).unwrap();
assert_eq!(
(
info.mod_id.as_str(),
info.name.as_str(),
info.license.as_str()
),
("ruby", "Ruby", "MIT")
);
assert!(metadata(&[template.to_owned()]).is_err());
}
#[test]
fn reads_fabric_metadata() {
let info = fabric(
@@ -0,0 +1,212 @@
//! Attachment sync: the values of a native mod's synced attachments, sent by a
//! Horizon server to the Horizon clients that see their holder
//! (docs/native-mods.md, "Server attachments").
//!
//! A payload is the holder (a varint kind and its fields), whether the
//! client's copy of the holder is replaced, then each change: the attachment's
//! namespace and name and its value as an NBT compound, or no tag for a
//! removal.
use horizon_world::{
attachments::{AttachmentSync, Holder},
nbt::{self, Tag},
};
use crate::{
Error, Result,
buf::{Reader, Writer},
};
/// The custom payload channel, clientbound only: a client sends nothing back.
pub const CHANNEL: &str = "horizon:attachment_sync";
/// Characters in a namespace or a name at most.
pub const MAX_NAME: usize = 256;
/// Bytes of a payload at most
/// (ClientboundCustomPayloadPacket.MAX_PAYLOAD_SIZE).
pub const MAX_PAYLOAD: usize = 1_048_576;
/// A holder's changes as they travel.
#[derive(Clone, Debug, PartialEq)]
pub struct Message {
pub holder: Holder,
/// The changes are the holder's whole synced state: drop what the client
/// holds first.
pub replace: bool,
pub changes: Vec<AttachmentSync>,
}
/// A message's payload; an error for a holder it cannot write, a name past
/// [`MAX_NAME`], a value that is not an encodable compound, or a payload past
/// [`MAX_PAYLOAD`].
pub fn encode(message: &Message) -> Result<Vec<u8>> {
let mut w = Writer::new();
match message.holder {
Holder::Entity(id) => {
w.varint(0);
w.varint(id as i32);
}
Holder::Player(uuid) => {
w.varint(1);
w.uuid(uuid);
}
Holder::BlockEntity(pos) => {
w.varint(2);
pos.iter().for_each(|&axis| w.i32(axis));
}
Holder::Chunk(pos) => {
w.varint(3);
pos.iter().for_each(|&axis| w.i32(axis));
}
Holder::Level => w.varint(4),
_ => return Err(Error::new("attachment sync: unknown holder")),
}
w.bool(message.replace);
w.varint(message.changes.len() as i32);
for change in &message.changes {
for name in [&change.namespace, &change.name] {
let name = name
.to_string()
.map_err(|_| Error::new("attachment sync: name is not UTF-16"))?;
if name.encode_utf16().count() > MAX_NAME {
return Err(Error::new(format!(
"attachment sync: name {name} is too long"
)));
}
w.string(&name);
}
match &change.value {
Some(value) => {
let tag = Tag::Compound(value.clone());
let bytes = nbt::write_network(&tag, nbt::Limits::default())
.map_err(|e| Error::new(format!("attachment sync: value: {e}")))?;
w.bytes(&bytes);
}
None => w.bytes(&[0]),
}
}
if w.data.len() > MAX_PAYLOAD {
return Err(Error::new("attachment sync: payload too big"));
}
Ok(w.data)
}
/// A payload's message; an error for one that breaks a cap.
pub fn decode(data: &[u8]) -> Result<Message> {
if data.len() > MAX_PAYLOAD {
return Err(Error::new("attachment sync: payload too big"));
}
let mut r = Reader::new(data);
let holder = match r.varint()? {
0 => Holder::Entity(r.varint()? as u32),
1 => Holder::Player(r.uuid()?),
2 => Holder::BlockEntity([r.i32()?, r.i32()?, r.i32()?]),
3 => Holder::Chunk([r.i32()?, r.i32()?]),
4 => Holder::Level,
kind => return Err(Error::new(format!("attachment sync: holder kind {kind}"))),
};
let replace = r.bool()?;
let max = r.remaining();
let changes = (0..r.count(max)?)
.map(|_| {
let namespace = r.string(MAX_NAME)?;
let name = r.string(MAX_NAME)?;
let value = match r.nbt()? {
Tag::Compound(value) => Some(value),
Tag::End => None,
_ => return Err(Error::new("attachment sync: value is not a compound")),
};
Ok(AttachmentSync {
namespace: namespace.as_str().into(),
name: name.as_str().into(),
value,
})
})
.collect::<Result<Vec<_>>>()?;
if r.remaining() > 0 {
return Err(Error::new("attachment sync: trailing bytes"));
}
Ok(Message {
holder,
replace,
changes,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn changes() -> Vec<AttachmentSync> {
vec![
AttachmentSync {
namespace: "mana".into(),
name: "pool".into(),
value: Some([("value".into(), Tag::Int(12))].into()),
},
AttachmentSync {
namespace: "mana".into(),
name: "gone".into(),
value: None,
},
]
}
#[test]
fn every_holder_round_trips_with_either_replace_and_a_removal() {
let holders = [
Holder::Entity(4_000_000_000),
Holder::Player(0x0123_4567_89ab_cdef_0011_2233_4455_6677),
Holder::BlockEntity([-30_000_000, -64, 29_999_999]),
Holder::Chunk([-5, 7]),
Holder::Level,
];
for holder in holders {
for replace in [true, false] {
let message = Message {
holder,
replace,
changes: changes(),
};
assert_eq!(decode(&encode(&message).unwrap()).unwrap(), message);
}
}
}
#[test]
fn decode_refuses_trailing_bytes_and_an_unknown_holder_kind() {
let message = Message {
holder: Holder::Level,
replace: false,
changes: changes(),
};
let mut payload = encode(&message).unwrap();
payload.push(0);
assert!(decode(&payload).is_err(), "trailing bytes");
assert!(decode(&[5, 0, 0]).is_err(), "unknown holder kind");
}
#[test]
fn encode_refuses_a_change_the_client_would_not_read_as_sent() {
let mut message = Message {
holder: Holder::Level,
replace: false,
changes: changes(),
};
message.changes[0].name = "n".repeat(MAX_NAME).as_str().into();
assert!(decode(&encode(&message).unwrap()).is_ok());
message.changes[0].name = "n".repeat(MAX_NAME + 1).as_str().into();
assert!(encode(&message).is_err(), "name");
message.changes[0].name = "pool".into();
let mut deep = Tag::Int(0);
for _ in 0..600 {
deep = Tag::Compound([("v".into(), deep)].into());
}
let Tag::Compound(deep) = deep else {
unreachable!()
};
message.changes[0].value = Some(deep);
assert!(encode(&message).is_err(), "unwritable value");
let big = Tag::ByteArray(vec![0; MAX_PAYLOAD]);
message.changes[0].value = Some([("big".into(), big)].into());
assert!(encode(&message).is_err(), "payload");
}
}
+1
View File
@@ -4,6 +4,7 @@
// `deny`, not `forbid`: crypto/blocks.rs allows the ARMv8 AES intrinsics, nothing else.
#![deny(unsafe_code)]
pub mod adapter;
pub mod attachment_sync;
pub mod buf;
pub mod chunk;
pub mod connection;
+100 -1
View File
@@ -21,7 +21,7 @@ use std::{
atomic::{AtomicBool, Ordering},
mpsc,
},
time::Duration,
time::{Duration, Instant},
};
/// The connections that have upgraded, and the queue that carries notifications to them, which
@@ -34,8 +34,13 @@ pub struct Clients {
/// `server/stopping` went out for this run of the game: a stop comes from the supervisor and from the
/// game's own `stop`, and one run says it once.
stopping_told: AtomicBool,
/// When `server/activity` last went out.
activity_told: Mutex<Option<Instant>>,
}
/// `MinecraftServer.SERVER_ACTIVITY_MONITOR_SECONDS_BETWEEN_NOTIFICATIONS`.
const ACTIVITY_EVERY: Duration = Duration::from_secs(30);
/// A connection in `Clients` for as long as this lives.
struct Registered<'a>(&'a Shared, u32);
@@ -83,6 +88,13 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
"leave" => ("notification/players/left", player()),
"saving" => ("notification/server/saving", None),
"saved" => ("notification/server/saved", None),
// `ServerActivityMonitor.reportLoginActivity`: a login attempt, told at most once in thirty seconds.
"login_activity" => {
if activity_due(shared, Instant::now()) {
notify(shared, "notification/server/activity", None);
}
return;
}
// A change of a list, whoever made it: the console, a command in the game, the protocol's own methods.
_ => match crate::management_methods::list_notification(kind, event) {
Some((method, params)) => (method, Some(params)),
@@ -92,6 +104,35 @@ pub fn game_event(shared: &Shared, kind: &str, event: &Value) {
notify(shared, method, params);
}
/// `ServerActivityMonitor.processWithRateLimit`: an activity is told when thirty seconds have gone by since the
/// last one told, and is lost otherwise (the monitor clears its flag whatever it did).
fn activity_due(shared: &Shared, now: Instant) -> bool {
let mut told = shared.management.activity_told.lock().unwrap();
let due = told.is_none_or(|last| now.saturating_duration_since(last) >= ACTIVITY_EVERY);
if due {
*told = Some(now);
}
due
}
/// `ManagementServer.scheduleHeartbeat`: `NotificationService.statusHeartbeat` every `seconds` (the first
/// after as many), with the server's status as `server/status` answers it, up or not. Nothing is computed
/// while no client is connected.
fn heartbeat(shared: Arc<Shared>, seconds: u64) -> std::io::Result<()> {
std::thread::Builder::new()
.name("management-heartbeat".into())
.spawn(move || {
loop {
std::thread::sleep(Duration::from_secs(seconds));
if !shared.management.sockets.lock().unwrap().is_empty() {
let status = crate::management_methods::status(&shared);
notify(&shared, "notification/server/status", Some(status));
}
}
})
.map(drop)
}
/// `NotificationService.serverShuttingDown`, which `DedicatedServer.stopServer` sends first of all.
pub fn stopping(shared: &Shared) {
if !shared.management.stopping_told.swap(true, Ordering::SeqCst) {
@@ -107,6 +148,8 @@ struct Settings {
allowed_origins: Vec<String>,
/// TLS, unless `management-server-tls-enabled=false`.
tls: Option<management_tls::Tls>,
/// `status-heartbeat-interval`: seconds between the status notifications, none when 0.
heartbeat: u64,
}
/// `SecurityConfig.isValid`: 40 letters and digits.
@@ -127,6 +170,7 @@ fn settings(dir: &Path) -> Result<Option<Settings>, String> {
if !p.management_server_enabled {
return Ok(None);
}
let heartbeat = p.status_heartbeat_interval.max(0) as u64;
let mut secret = p.management_server_secret;
if generated {
secret = generate_secret_key(&mut SystemEntropy::default());
@@ -184,6 +228,7 @@ fn settings(dir: &Path) -> Result<Option<Settings>, String> {
.map(str::to_owned)
.collect(),
tls,
heartbeat,
}))
}
@@ -231,6 +276,9 @@ fn serve(
) -> std::io::Result<()> {
let (outbox, queue) = mpsc::channel::<String>();
*shared.management.outbox.lock().unwrap() = Some(outbox);
if settings.heartbeat > 0 {
heartbeat(shared.clone(), settings.heartbeat)?;
}
let carrier = shared.clone();
std::thread::Builder::new()
.name("management-notify".into())
@@ -556,6 +604,7 @@ mod tests {
secret: SECRET.into(),
allowed_origins: vec!["http://localhost:3000".into()],
tls: None,
heartbeat: 0,
}
}
@@ -910,6 +959,53 @@ mod tests {
);
}
#[test]
fn an_activity_is_told_once_in_thirty_seconds_and_otherwise_lost() {
let shared = shared();
let start = Instant::now();
assert!(activity_due(&shared, start));
assert!(!activity_due(&shared, start + Duration::from_secs(29)));
// The one lost at 29 s is not told later: the next login after thirty seconds is.
assert!(!activity_due(&shared, start + Duration::from_secs(29)));
assert!(activity_due(&shared, start + Duration::from_secs(30)));
assert!(!activity_due(&shared, start + Duration::from_secs(59)));
assert!(activity_due(&shared, start + Duration::from_secs(60)));
}
#[test]
fn a_login_is_an_activity_notification_and_the_heartbeat_carries_the_status() {
let shared = shared();
let address = listen_on(
shared.clone(),
Settings {
heartbeat: 1,
..settings()
},
);
let mut client = upgrade(address);
registered(&shared, 1);
game_event(
&shared,
"login_activity",
&json!({ "type": "login_activity" }),
);
game_event(
&shared,
"login_activity",
&json!({ "type": "login_activity" }),
);
assert_eq!(
next_text(&mut client),
r#"{"jsonrpc":"2.0","method":"minecraft:notification/server/activity"}"#
);
// The second login came inside the thirty seconds, so the next frame is the heartbeat, a second
// after the start: the status of a game that isn't up.
let beat: Value = serde_json::from_str(&next_text(&mut client)).unwrap();
assert_eq!(beat["method"], "minecraft:notification/server/status");
assert_eq!(beat["params"][0]["started"], false);
assert!(beat["params"][0]["version"]["name"].is_string());
}
#[test]
fn every_notification_sent_is_one_discover_lists() {
let listed = schema::discover();
@@ -934,6 +1030,9 @@ mod tests {
"notification/bans/removed",
"notification/ip_bans/added",
"notification/ip_bans/removed",
"notification/gamerules/updated",
"notification/server/activity",
"notification/server/status",
] {
assert!(
names.contains(&format!("minecraft:{sent}").as_str()),
@@ -710,6 +710,11 @@ impl Lists<'_> {
}
}
/// `ServerStateService.status`, as `server/status` answers it (and the heartbeat carries it).
pub fn status(shared: &Shared) -> Value {
Game { shared }.status()
}
/// The notification a change of one of the lists is, from the game's typed event (`operators_added`,
/// `bans_removed`...): `JsonRpcNotificationService`'s `playerOped`/`playerDeoped` (an `OperatorDto`),
/// `playerAddedToAllowlist`/`playerRemovedFromAllowlist` and `playerUnbanned` (a `PlayerDto`), `playerBanned`
+1 -1
View File
@@ -45,7 +45,7 @@ pub use lang::Lang;
pub use macros::{MC_VERSION, Macros, RENDER_STAGES, render_stage};
pub use options::{
PackOption, apply as apply_options, chosen_file, chosen_text, options, parse_chosen,
switches_on, value_options, with_options,
read_properties, switches_on, value_options, with_options,
};
pub use pack::{Pack, PackError};
pub use post::{
+21 -5
View File
@@ -3,11 +3,13 @@
use std::collections::BTreeMap;
/// The Iris feature flags (`iris.features.*`) Horizon has. `SEPARATE_HARDWARE_SAMPLERS`
/// (ShaderDoc, "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth
/// samplers when hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks
/// a comparison sampler by the declared type (`sampler2DShadow`), so they always are.
const FEATURES: [&str; 5] = [
/// The Iris feature flags (`iris.features.*`) Horizon has. `BLOCK_EMISSION_ATTRIBUTE`: terrain's
/// `at_midBlock.w` is the light its block gives. `SEPARATE_HARDWARE_SAMPLERS` (ShaderDoc,
/// "Separate Hardware Shadow Samplers"): `shadowtex0`/`shadowtex1` stay plain depth samplers when
/// hardware filtering is on, `shadowtex0HW`/`shadowtex1HW` compare; Horizon picks a comparison
/// sampler by the declared type (`sampler2DShadow`), so they always are.
const FEATURES: [&str; 6] = [
"BLOCK_EMISSION_ATTRIBUTE",
"COMPUTE_SHADERS",
"CUSTOM_IMAGES",
"ENTITY_TRANSLUCENT",
@@ -414,6 +416,20 @@ mod tests {
translates(fsh, &Macros::standard()).unwrap();
}
/// `BLOCK_EMISSION_ATTRIBUTE` (terrain's `at_midBlock.w` is its block's light) is one
/// Horizon has: defined when optional, no note when required.
#[test]
fn block_emission_attribute_is_a_feature() {
let properties = crate::Properties::parse(
"iris.features.optional = BLOCK_EMISSION_ATTRIBUTE\n\
iris.features.required = BLOCK_EMISSION_ATTRIBUTE\n",
&Macros::standard(),
);
let mut m = Macros::standard();
assert!(m.define_features(&properties).is_empty());
assert!(m.is_defined("IRIS_FEATURE_BLOCK_EMISSION_ATTRIBUTE"));
}
#[test]
fn iris_features_are_defined_or_noted() {
let properties = crate::Properties::parse(
+52
View File
@@ -86,6 +86,21 @@ pub fn with_options(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
out
}
/// A pack's `shaders.properties` as the game reads it before the programs: parsed with the
/// pack's options over `macros` (the standard set and the renderer's), after which `macros` gets
/// the features the pack turns on (`iris.features.*`, `Macros::define_features`). Returns the
/// properties and a note for each required feature Horizon lacks. Whatever reads a pack's
/// programs (the game, the `real_packs` test) reads them with these macros.
pub fn read_properties(
pack: &Pack,
macros: &mut crate::Macros,
) -> (crate::Properties, Vec<String>) {
let text = pack.read_text("shaders.properties").unwrap_or_default();
let properties = crate::Properties::parse(&text, &with_options(pack, macros));
let notes = macros.define_features(&properties);
(properties, notes)
}
/// An option the player can change (8.1): a switch (`values` are `false` and `true`), a value
/// option or a `const` with the list of values it may take.
#[derive(Clone, Debug, PartialEq)]
@@ -372,6 +387,43 @@ fn option_texts(pack: &Pack) -> Vec<String> {
mod tests {
use super::*;
/// `shaders.properties` read as the game reads it: its conditionals see the pack's options
/// and the macros given (the renderer's vendor here), and the features it turns on are then
/// defined for the programs, with a note for a required one Horizon lacks.
#[test]
fn properties_are_read_with_the_options_then_define_the_features() {
let bytes = crate::pack::tests::zip(&[
(
"shaders/options.glsl",
"#define LEVEL 2 // [1 2]
",
),
(
"shaders/shaders.properties",
"#if LEVEL == 2
screen = x
#endif
#ifdef MC_GL_VENDOR_OTHER
vendor = other
#endif
iris.features.optional = SSBO
iris.features.required = NOT_A_FEATURE
",
),
]);
let pack = Pack::from_zip_bytes("p.zip", bytes).unwrap();
let mut macros = crate::Macros::standard();
macros.define_renderer(4);
let (properties, notes) = read_properties(&pack, &mut macros);
assert_eq!(properties.get("screen"), Some("x"));
assert_eq!(properties.get("vendor"), Some("other"));
assert!(macros.is_defined("IRIS_FEATURE_SSBO"));
assert_eq!(
notes,
["the pack requires `NOT_A_FEATURE`, which is not supported yet"]
);
}
#[test]
fn declared_defaults_remain_selectable_and_apply() {
for (source, selected) in [
+1 -1
View File
@@ -1190,7 +1190,7 @@ fn diagnostics(pp: &Preprocessed) -> Vec<String> {
located(pp, &pp.diagnostics)
}
/// `pp`'s warnings (conditions read as false), located.
/// `pp`'s warnings (conditions read as false, unbalanced conditionals), located.
fn warnings(pp: &Preprocessed) -> Vec<String> {
located(pp, &pp.warnings)
}
+36 -3
View File
@@ -39,7 +39,8 @@ pub struct Preprocessed {
pub lines: Vec<Origin>,
/// The pack paths of the files read, the stage file first.
pub files: Vec<String>,
/// `#version` of the stage file: the number and the profile word, if any.
/// The stage's `#version`, the first one read (an included file's too): the number and the
/// profile word, if any.
pub version: Option<(u32, Option<String>)>,
/// `#extension` lines of live code, as written after `#extension`.
pub extensions: Vec<String>,
@@ -70,8 +71,12 @@ pub fn preprocess(pack: &Pack, path: &str, macros: &Macros) -> Preprocessed {
texts: HashMap::new(),
};
run.file(path, 0, Origin::default());
// Iris 1.11.7 draws a program whose `#if` stays open to its end: a warning, as there.
if run.conditionals.open() {
run.diagnose(Origin::default(), "`#if` without `#endif`");
run.out.warnings.push(Diagnostic {
origin: Origin::default(),
message: "`#if` without `#endif`: read to the end of the file".into(),
});
}
run.out.code = run.code.join("\n");
run.out
@@ -169,6 +174,11 @@ impl Run<'_> {
Err(e) if e.ends_with("read as false") => {
return self.out.warnings.push(Diagnostic { origin, message: e });
}
// Iris 1.11.7 logs a stray `#endif` and draws the program: it is ignored.
Err(e) if word == "endif" => {
let message = format!("{e}: ignored");
return self.out.warnings.push(Diagnostic { origin, message });
}
Err(e) => return self.diagnose(origin, &e),
Ok(true) => return,
Ok(false) if !self.conditionals.active() => return,
@@ -191,7 +201,8 @@ impl Run<'_> {
None => self.diagnose(origin, &format!("`{target}` is outside the pack")),
}
}
"version" if depth == 0 && self.out.version.is_none() => {
// The first `#version`, also from an included file (a pack's common header).
"version" if self.out.version.is_none() => {
let mut words = rest.split_whitespace();
let number = words.next().and_then(|v| v.parse().ok()).unwrap_or(110);
self.out.version = Some((number, words.next().map(str::to_owned)));
@@ -411,6 +422,28 @@ mod tests {
assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]);
}
/// A stray `#endif` and an `#if` left open are warnings, as in Iris 1.11.7 (which draws such
/// a program): the `#endif` is ignored, the open `#if` reads on to the end of the file.
#[test]
fn unbalanced_conditionals_warn_and_the_code_goes_on() {
let p = run(&[(
"program/final.fsh",
"#version 330\n#ifdef NEVER\nfloat a;\n#else\nfloat b;\n#endif\n#endif\n\
#if 1\nfloat c;\n",
)]);
assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics);
let warnings: Vec<&str> = p.warnings.iter().map(|w| w.message.as_str()).collect();
assert_eq!(
warnings,
[
"`#endif` without `#if`: ignored",
"`#if` without `#endif`: read to the end of the file"
]
);
let code: Vec<&str> = code_lines(&p).iter().map(|(c, _)| *c).collect();
assert_eq!(code, ["float b;", "float c;"]);
}
/// A condition that can't be evaluated is a warning and reads as false; the code goes on.
#[test]
fn bad_conditions_warn_and_read_false() {
@@ -0,0 +1,44 @@
//! Struct types without a name. GLSL lets a declaration define its struct type in place without
//! naming it (GLSL 4.60, 4.1.8: `shared struct { uint sum; uint tile[64]; } lds;`); naga's
//! frontend wants a name there ("Expected identifier, found LeftBrace"). Each such type gets
//! one, `hz_struct<n>`; nothing else changes.
use super::scan::significant;
use crate::macros::lex;
/// `code` with a name for each struct type declared without one, or `None` when it has none.
pub(super) fn name(code: &str) -> Option<String> {
let tokens = lex(code);
let mut out = String::with_capacity(code.len() + 64);
let mut named = 0;
for (i, t) in tokens.iter().enumerate() {
out.push_str(t);
let opens = (i + 1..tokens.len())
.find(|&j| significant(tokens[j]))
.is_some_and(|j| tokens[j] == "{");
if *t == "struct" && opens {
out.push_str(&format!(" hz_struct{named}"));
named += 1;
}
}
(named > 0).then_some(out)
}
#[cfg(test)]
mod tests {
use super::name;
#[test]
fn struct_types_without_a_name_get_one() {
let code = "shared struct {\n uint sum;\n} lds;\nstruct Light { vec3 c; };\n\
struct\n{ float a; } x, y[2];\n";
assert_eq!(
name(code).as_deref(),
Some(
"shared struct hz_struct0 {\n uint sum;\n} lds;\nstruct Light { vec3 c; };\n\
struct hz_struct1\n{ float a; } x, y[2];\n"
)
);
assert_eq!(name("struct Light { vec3 c; };\n"), None);
}
}
@@ -58,6 +58,15 @@ pub(super) fn flatten_as(code: &str, inputs: &str) -> Option<String> {
}
let mut changed = false;
for b in blocks {
// A redeclared built-in block (GLSL 4.60, 7.5: `out gl_PerVertex { vec4 gl_Position;
// };`) only restates built-ins: it goes, and they stay built-ins.
if b.name == "gl_PerVertex" {
for t in &mut tokens[b.start..=b.end] {
blank(t);
}
changed = true;
continue;
}
let inside = |i: usize| (b.start..=b.end).contains(&i);
let prefix = if b.storage == "in" { inputs } else { "" };
let renamed = |member: &str| match &b.instance {
@@ -69,7 +78,16 @@ pub(super) fn flatten_as(code: &str, inputs: &str) -> Option<String> {
let mut reads = Vec::new();
if let Some((_, instance)) = &b.instance {
let mut whole = false;
for i in (0..tokens.len()).filter(|&i| !inside(i) && tokens[i] == *instance) {
// A local variable or parameter of the same name, or a member (`light.v`), is not
// the instance.
let hidden = hidden(&tokens, instance);
let other = |i: usize| {
hidden.iter().any(|h| h.contains(&i))
|| previous_significant(&tokens, i).is_some_and(|p| tokens[p] == ".")
};
for i in
(0..tokens.len()).filter(|&i| !inside(i) && tokens[i] == *instance && !other(i))
{
let index = match b.array.is_empty() {
true => Some(i),
false => next_significant(&tokens, i + 1)
@@ -132,6 +150,88 @@ fn next_significant(tokens: &[String], from: usize) -> Option<usize> {
(from..tokens.len()).find(|&i| significant(&tokens[i]))
}
fn previous_significant(tokens: &[String], before: usize) -> Option<usize> {
(0..before).rev().find(|&i| significant(&tokens[i]))
}
/// Where a local variable or parameter named `name` hides a global of that name (GLSL 4.60,
/// 4.2): from its declaration to the end of the block it is declared in, or of its function's
/// body for a parameter.
fn hidden(tokens: &[String], name: &str) -> Vec<std::ops::Range<usize>> {
let mut out = Vec::new();
let mut opens: Vec<usize> = Vec::new();
let mut parens = 0usize;
for i in 0..tokens.len() {
match tokens[i].as_str() {
"{" => opens.push(i),
"}" => {
opens.pop();
}
"(" => parens += 1,
")" => parens = parens.saturating_sub(1),
t if t == name => {
// A declaration: a type before the name, and after it what ends a declarator.
let typed = previous_significant(tokens, i).is_some_and(|p| {
let w = tokens[p].as_str();
w.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !["return", "else", "case"].contains(&w)
});
let declares = next_significant(tokens, i + 1)
.is_some_and(|n| matches!(tokens[n].as_str(), "," | ")" | ";" | "=" | "["));
if !(typed && declares) {
continue;
}
let end = match opens.last() {
Some(&open) => closing_brace(tokens, open),
// A parameter: its function's body follows its parameter list.
None if parens > 0 => parameter_list_end(tokens, i)
.and_then(|close| next_significant(tokens, close + 1))
.filter(|&body| tokens[body] == "{")
.and_then(|body| closing_brace(tokens, body)),
None => None,
};
if let Some(end) = end {
out.push(i..end + 1);
}
}
_ => {}
}
}
out
}
/// The `}` that closes the `{` at `open`.
fn closing_brace(tokens: &[String], open: usize) -> Option<usize> {
let mut depth = 0usize;
for (j, t) in tokens.iter().enumerate().skip(open) {
match t.as_str() {
"{" => depth += 1,
"}" => {
depth -= 1;
if depth == 0 {
return Some(j);
}
}
_ => {}
}
}
None
}
/// The `)` that closes the parameter list token `i` is in.
fn parameter_list_end(tokens: &[String], i: usize) -> Option<usize> {
let mut depth = 0usize;
for (j, t) in tokens.iter().enumerate().skip(i) {
match t.as_str() {
"(" => depth += 1,
")" if depth == 0 => return Some(j),
")" => depth -= 1,
_ => {}
}
}
None
}
/// The `]` that closes the `[` at `open`.
fn closing(tokens: &[String], open: usize) -> Option<usize> {
let mut depth = 0usize;
@@ -306,6 +406,39 @@ fn block(tokens: &[String], start: usize, open: usize) -> Option<Block> {
mod tests {
use super::*;
/// A local variable or parameter named like the instance hides it (GLSL 4.60, 4.2), and
/// `light.v` is another struct's member: the block is still flattened, those stay.
#[test]
fn locals_named_like_the_instance_are_not_it() {
let code = "out Data { vec2 uv; } v;\nstruct L { float v; };\n\
float luma(vec3 v) { return dot(v, vec3(0.3)); }\n\
void main() { L light; light.v = 1.0; v.uv = vec2(light.v);\n\
{ vec2 v = vec2(2.0); v.x += 1.0; } v.uv += vec2(1.0); }\n";
let out = flatten(code).expect("flattened");
assert!(
out.contains("float luma(vec3 v) { return dot(v, vec3(0.3)); }"),
"{out}"
);
assert!(
out.contains("light.v = 1.0; hz_Data_uv = vec2(light.v);"),
"{out}"
);
assert!(
out.contains("{ vec2 v = vec2(2.0); v.x += 1.0; } hz_Data_uv += vec2(1.0);"),
"{out}"
);
}
/// A redeclared `gl_PerVertex` (GLSL 4.60, 7.5) goes: its members stay the built-ins.
#[test]
fn a_redeclared_gl_per_vertex_goes() {
let code =
"out gl_PerVertex { vec4 gl_Position; };\nvoid main() { gl_Position = vec4(1.0); }\n";
let out = flatten(code).expect("changed");
assert_eq!(out.lines().next().map(str::trim), Some(""));
assert!(out.contains("gl_Position = vec4(1.0);"));
}
#[test]
fn members_become_varyings_on_their_lines() {
let code = "out VertexData {\n flat int id;\n vec2 uv, st[2];\n} vOut;\n\
+293 -37
View File
@@ -27,6 +27,7 @@ use naga::valid::{Capabilities, ValidationFlags, Validator};
use crate::Preprocessed;
mod anonymous_structs;
mod atomic_vectors;
mod geometry;
mod image_parameters;
@@ -239,9 +240,14 @@ fn translate(
} else {
GBUFFERS_MATRICES.to_owned()
};
// Interface blocks travel as plain varyings (`interface_blocks`).
// Struct types without a name get one (`anonymous_structs`), and interface blocks travel
// as plain varyings (`interface_blocks`).
let flat = |pp: &Preprocessed| {
interface_blocks::flatten(&pp.code).map(|code| Preprocessed { code, ..pp.clone() })
let named = anonymous_structs::name(&pp.code);
let code = named.as_deref().unwrap_or(&pp.code);
interface_blocks::flatten(code)
.or(named)
.map(|code| Preprocessed { code, ..pp.clone() })
};
let (vertex_flat, fragment_flat) = (flat(vertex), flat(fragment));
let vertex = vertex_flat.as_ref().unwrap_or(vertex);
@@ -625,6 +631,11 @@ fn resources(
/// Translates a compute program (`.csh`).
pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<Error>> {
let named = anonymous_structs::name(&compute.code).map(|code| Preprocessed {
code,
..compute.clone()
});
let compute = named.as_ref().unwrap_or(compute);
let split = atomic_vectors::rewrite(compute);
let compute = split.as_ref().unwrap_or(compute);
let mut c = Stage::scan(compute, ShaderStage::Compute);
@@ -746,41 +757,160 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
/// `code` with `gl_WorkGroupSize` written as the constant its `layout(local_size_*)` declares:
/// GLSL defines it so, and naga's HLSL writer has no such built-in.
fn work_group_size(code: &str) -> Result<String, String> {
let tokens = crate::macros::lex(code);
if !tokens.contains(&"gl_WorkGroupSize") {
return Ok(code.to_owned());
}
let sig: Vec<&str> = tokens
.iter()
.copied()
.filter(|t| scan::significant(t))
let mut tokens: Vec<String> = crate::macros::lex(code)
.into_iter()
.map(str::to_owned)
.collect();
// A size is one number, the whole of its `local_size_* = N`; 1 when not declared.
let size = |axis: &str| match sig.windows(4).find(|w| w[0] == axis && w[1] == "=") {
None => Ok(1),
Some(w) => w[2]
.trim_end_matches(['u', 'U'])
.parse::<u32>()
.ok()
.filter(|_| matches!(w[3], "," | ")"))
.ok_or_else(|| format!("`{axis}` is not a number gl_WorkGroupSize can take")),
};
let value = format!(
"uvec3({}u, {}u, {}u)",
size("local_size_x")?,
size("local_size_y")?,
size("local_size_z")?
);
Ok(tokens
.iter()
.map(|&t| {
if t == "gl_WorkGroupSize" {
value.as_str()
} else {
t
let mut sizes = [1u32; 3];
let axes = ["local_size_x", "local_size_y", "local_size_z"];
let sig: Vec<usize> = (0..tokens.len())
.filter(|&i| scan::significant(&tokens[i]))
.collect();
for (k, &at) in sig.iter().enumerate() {
let Some(axis) = axes.iter().position(|a| tokens[at] == *a) else {
continue;
};
if sig.get(k + 1).is_none_or(|&e| tokens[e] != "=") {
continue;
}
// The size's expression runs to the `,` or `)` that ends the qualifier.
let mut depth = 0usize;
let expr: Vec<usize> = sig[k + 2..]
.iter()
.copied()
.take_while(|&i| match tokens[i].as_str() {
"(" => {
depth += 1;
true
}
")" if depth > 0 => {
depth -= 1;
true
}
"," | ")" if depth == 0 => false,
_ => true,
})
.collect();
let words: Vec<&str> = expr.iter().map(|&i| tokens[i].as_str()).collect();
let size = ConstantSize::eval(&words)
.ok_or_else(|| format!("`{}` is not a size Horizon can work out", axes[axis]))?;
sizes[axis] = size;
// naga takes a number there: the expression becomes its value.
if let (Some(&first), Some(&last)) = (expr.first(), expr.last()) {
tokens[first] = size.to_string();
for t in &mut tokens[first + 1..=last] {
t.retain(|c| c == '\n');
}
})
.collect())
}
}
let value = format!("uvec3({}u, {}u, {}u)", sizes[0], sizes[1], sizes[2]);
for t in &mut tokens {
if t == "gl_WorkGroupSize" {
*t = value.clone();
}
}
Ok(tokens.concat())
}
/// A work group size given as an integer constant expression (GLSL 4.60, 4.4 and 4.4.1):
/// numbers, `+ - * / %`, brackets, `min`, `max` and `gl_MaxComputeWorkGroupSize`, the minimum
/// GLSL requires (`ivec3(1024, 1024, 64)`, 7.3); the translation knows no device.
struct ConstantSize<'w> {
words: &'w [&'w str],
at: usize,
}
impl<'w> ConstantSize<'w> {
fn eval(words: &'w [&'w str]) -> Option<u32> {
let mut p = Self { words, at: 0 };
let v = p.sum()?;
(p.at == words.len())
.then_some(v)
.and_then(|v| u32::try_from(v).ok())
}
fn peek(&self) -> Option<&'w str> {
self.words.get(self.at).copied()
}
fn eat(&mut self, word: &str) -> bool {
let found = self.peek() == Some(word);
self.at += usize::from(found);
found
}
fn sum(&mut self) -> Option<i64> {
let mut v = self.product()?;
loop {
if self.eat("+") {
v = v.checked_add(self.product()?)?;
} else if self.eat("-") {
v = v.checked_sub(self.product()?)?;
} else {
return Some(v);
}
}
}
fn product(&mut self) -> Option<i64> {
let mut v = self.unary()?;
loop {
if self.eat("*") {
v = v.checked_mul(self.unary()?)?;
} else if self.eat("/") {
v = v.checked_div(self.unary()?)?;
} else if self.eat("%") {
v = v.checked_rem(self.unary()?)?;
} else {
return Some(v);
}
}
}
fn unary(&mut self) -> Option<i64> {
if self.eat("-") {
return self.unary()?.checked_neg();
}
if self.eat("+") {
return self.unary();
}
if self.eat("(") {
let v = self.sum()?;
return self.eat(")").then_some(v);
}
let word = self.peek()?;
self.at += 1;
match word {
"min" | "max" => {
if !self.eat("(") {
return None;
}
let a = self.sum()?;
if !self.eat(",") {
return None;
}
let b = self.sum()?;
if !self.eat(")") {
return None;
}
Some(if word == "min" { a.min(b) } else { a.max(b) })
}
"gl_MaxComputeWorkGroupSize" => {
if !self.eat(".") {
return None;
}
let axis = self.peek()?;
self.at += 1;
match axis {
"x" => Some(1024),
"y" => Some(1024),
"z" => Some(64),
_ => None,
}
}
number => number.trim_end_matches(['u', 'U']).parse().ok(),
}
}
}
/// The numbers of a constant's constructor, `const ivec3 name = ivec3(1, 2, 3);` → 1, 2, 3.
@@ -1832,6 +1962,54 @@ mod tests {
);
}
/// `textureQueryLod` (GLSL 4.60, 8.9.1), which naga lacks, on 2D, 3D and integer samplers
/// and through a pack function that takes the sampler: every form reaches SPIR-V, and a
/// program that doesn't call it gets none of it.
#[test]
fn texture_query_lod_translates() {
let fsh = "#version 430\nuniform sampler2D colortex0;\nuniform sampler3D lut;\n\
uniform usampler2D colortex5;\nout vec4 c;\n\
float level(sampler2D s, vec2 uv) { return textureQueryLod(s, uv).x; }\n\
void main() {\n vec2 uv = gl_FragCoord.xy / 64.0;\n \
vec2 a = textureQueryLod(colortex0, uv);\n \
vec2 b = textureQueryLod(lut, vec3(uv, 0.5));\n \
vec2 d = textureQueryLod(colortex5, uv);\n \
c = vec4(a + b + d, level(colortex0, uv), 1.0);\n}\n";
let p = program(
&[("composite.vsh", VSH_120), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
let info = Validator::new(ValidationFlags::all(), Capabilities::all())
.validate(&p.fragment)
.unwrap();
let entry = &p.fragment.entry_points[0];
let pipeline = naga::back::spv::PipelineOptions {
shader_stage: entry.stage,
entry_point: entry.name.clone(),
};
naga::back::spv::write_vec(&p.fragment, &info, &Default::default(), Some(&pipeline))
.unwrap();
let plain = program(
&[
("composite.vsh", VSH_120),
(
"composite.fsh",
"#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n",
),
],
Target::Buffers,
)
.unwrap();
assert!(
!plain
.fragment
.functions
.iter()
.any(|(_, f)| f.name.as_deref() == Some("hz_textureQueryLod"))
);
}
/// A `return` with code after it in its block (Miniature's hand: `#ifdef GBUFFERS_HAND
/// return 0.5 * diffuse; #endif` and the rest of the function), which naga's frontend
/// leaves half-built.
@@ -1916,6 +2094,25 @@ mod tests {
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// A vertex stage that redeclares `gl_PerVertex` and whose block instance shares its name
/// with a function's parameter: it still writes the position, and the
/// block still reaches the fragment stage.
#[test]
fn a_per_vertex_redeclaration_and_a_parameter_named_like_the_instance() {
let vsh = "#version 460\nout gl_PerVertex { vec4 gl_Position; };\n\
float luma(vec3 v) { return dot(v, vec3(0.3, 0.6, 0.1)); }\n\
out VertexData {\n layout(location = 1, component = 0) vec2 coord;\n} v;\n\
void main() { v.coord = vec2(luma(vec3(1.0))); gl_Position = vec4(0.0, 0.0, 0.5, 1.0); }\n";
let fsh = "#version 460\nin VertexData {\n layout(location = 1, component = 0) vec2 coord;\n} v;\n\
/* RENDERTARGETS: 0 */\nlayout(location = 0) out vec4 c;\n\
void main() { c = vec4(v.coord, 0.0, 1.0); }\n";
program(
&[("composite.vsh", vsh), ("composite.fsh", fsh)],
Target::Buffers,
)
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// Interface blocks between the stages may give members their own interpolation (GLSL 4.60,
/// 4.3.9; Shrimple's gbuffers programs): a `flat` member arrives flat.
#[test]
@@ -2157,6 +2354,20 @@ mod tests {
}));
}
/// A `shared` variable whose struct type has no name (GLSL 4.60, 4.1.8), as compute
/// programs keep their work group's tiles.
#[test]
fn a_shared_struct_without_a_type_name_compiles() {
let csh = "#version 460\nlayout(local_size_x = 8, local_size_y = 8) in;\n\
shared struct {\n uint sum;\n uint tile[gl_WorkGroupSize.x][gl_WorkGroupSize.y];\n} lds;\n\
layout(r32ui) uniform uimage2D colorimg4;\nvoid main() {\n \
lds.tile[gl_LocalInvocationID.x][gl_LocalInvocationID.y] = gl_LocalInvocationIndex;\n \
barrier();\n imageStore(colorimg4, ivec2(gl_GlobalInvocationID.xy), uvec4(lds.tile[1][1]));\n}\n";
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/deferred_a.csh", csh)])).unwrap();
translate_compute(&preprocess(&pack, "deferred_a.csh", &Macros::standard()))
.unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn buffer_blocks_bind_at_their_binding() {
let csh = "#version 430\nlayout(local_size_x = 1) in;\n\
@@ -2190,6 +2401,27 @@ mod tests {
.unwrap_or_else(|e| panic!("{e:?}"));
}
/// A stage's `#version` may come from a header it includes: the program is that version
/// (`shared` is a keyword in GLSL 4.30, not a name to rename).
#[test]
fn the_version_may_come_from_an_included_header() {
let csh = "#include \"/prelude/core.glsl\"\nlayout(local_size_x = 4) in;\n\
shared uint counts[4];\nlayout(r32ui) uniform uimage2D colorimg4;\n\
void main() {\n counts[gl_LocalInvocationIndex] = 1u;\n barrier();\n \
imageStore(colorimg4, ivec2(0), uvec4(counts[1]));\n}\n";
let pack = Pack::from_zip_bytes(
"P.zip",
zip(&[
("shaders/deferred_a.csh", csh),
("shaders/prelude/core.glsl", "#version 460 core\n"),
]),
)
.unwrap();
let pp = preprocess(&pack, "deferred_a.csh", &Macros::standard());
assert_eq!(pp.version, Some((460, Some("core".into()))));
translate_compute(&pp).unwrap_or_else(|e| panic!("{e:?}"));
}
#[test]
fn work_group_size_is_its_layout() {
let csh = "#version 430\nlayout(local_size_x = 8, local_size_y = 4) in;\n\
@@ -2210,9 +2442,18 @@ mod tests {
.iter()
.any(|(_, e)| matches!(e, naga::Expression::Literal(naga::Literal::U32(8))))
);
// A constant expression (GLSL 4.60, 4.4) is worked out, also where naga reads it.
assert_eq!(
work_group_size("layout(local_size_x = 8 * 2) in;\nuvec3 s = gl_WorkGroupSize;"),
Err("`local_size_x` is not a number gl_WorkGroupSize can take".into())
work_group_size(
"layout(local_size_x = min(gl_MaxComputeWorkGroupSize.x, 256 * 2), local_size_y = (3 + 1) / 2) in;\n\
uvec3 s = gl_WorkGroupSize;"
)
.as_deref(),
Ok("layout(local_size_x = 512, local_size_y = 2) in;\nuvec3 s = uvec3(512u, 2u, 1u);")
);
assert_eq!(
work_group_size("layout(local_size_x = SIZE) in;\nvoid main() {}"),
Err("`local_size_x` is not a size Horizon can work out".into())
);
assert!(
work_group_size("layout(local_size_x = 16) in;\nuvec3 s = gl_WorkGroupSize;")
@@ -2220,6 +2461,21 @@ mod tests {
);
}
/// A work group size given as a constant expression (GLSL 4.60, 4.4 and 4.4.1), kept within
/// the implementation's: the program compiles with its value.
#[test]
fn a_work_group_size_may_be_a_constant_expression() {
let csh = "#version 460\nlayout(local_size_x = min(gl_MaxComputeWorkGroupSize.x, 256), \
local_size_y = 1, local_size_z = 1) in;\n\
layout(r32ui) uniform uimage2D colorimg4;\n\
void main() { imageStore(colorimg4, ivec2(gl_GlobalInvocationID.xy), uvec4(1u)); }\n";
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/deferred_a.csh", csh)])).unwrap();
let c = translate_compute(&preprocess(&pack, "deferred_a.csh", &Macros::standard()))
.unwrap_or_else(|e| panic!("{e:?}"));
let entry = &c.module.entry_points[0];
assert_eq!(entry.workgroup_size, [256, 1, 1]);
}
#[test]
fn compute_programs_translate() {
let csh = "#version 430\nlayout(local_size_x = 64) in;\nconst ivec3 workGroups = ivec3(2, 1, 1);\n\
@@ -46,6 +46,9 @@ impl Stage<'_> {
if self.gather_offsets {
p.push_str(&gather_offsets());
}
if self.query_lod {
p.push_str(&query_lod());
}
// GLSL's scalar `dot` and `normalize` (genType includes `float`); naga's are vector only.
if self.scalar_dot {
p.push_str("float dot(float a, float b) { return a * b; }\n");
@@ -372,6 +375,7 @@ impl Stage<'_> {
// GLSL's `modf`, which naga's frontend lacks.
"modf" if called => Some("hz_modf".into()),
"textureGatherOffsets" if called => Some("hz_textureGatherOffsets".into()),
"textureQueryLod" if called => Some("hz_textureQueryLod".into()),
// Matrix inverse, which naga writes for neither Metal nor WGSL.
"inverse" if called && self.inverse => Some("hz_inverse".into()),
"texture2D" | "texture1D" | "texture3D" | "textureCube" if called => {
@@ -689,6 +693,48 @@ fn gather_offsets() -> String {
p
}
/// `textureQueryLod` (GLSL 4.60, 8.9.1), which naga's frontend lacks: `y` is the level of
/// detail relative to the base level, worked out as texturing does (OpenGL 4.6, 8.14.1: the log2 of the
/// larger of the coordinates' texel-space changes from one pixel to the next), and `x` the level
/// that would be read, that clamped to the texture's levels. For a cube map the direction is
/// first taken to its face.
fn query_lod() -> String {
let mut p = String::new();
for prefix in ["", "i", "u"] {
for (dim, coord, texel) in [
("2D", "vec2", "p * vec2(textureSize({smp}(t, s), 0))"),
(
"2DArray",
"vec2",
"p * vec2(textureSize({smp}(t, s), 0).xy)",
),
("3D", "vec3", "p * vec3(textureSize({smp}(t, s), 0))"),
(
"Cube",
"vec3",
"p / max(max(abs(p.x), abs(p.y)), abs(p.z)) * 0.5 * float(textureSize({smp}(t, s), 0).x)",
),
] {
let (tex, smp) = (
format!("{prefix}texture{dim}"),
format!("{prefix}sampler{dim}"),
);
let texel = texel.replace("{smp}", &smp);
write!(
p,
"vec2 hz_textureQueryLod({tex} t, sampler s, {coord} p) {{\n\
\x20 {coord} q = {texel};\n\
\x20 {coord} dx = dFdx(q), dy = dFdy(q);\n\
\x20 float lod = 0.5 * log2(max(dot(dx, dx), dot(dy, dy)));\n\
\x20 float levels = float(textureQueryLevels({smp}(t, s)));\n\
\x20 return vec2(clamp(lod, 0.0, levels - 1.0), lod);\n}}\n"
)
.unwrap();
}
}
p
}
fn is_zero_literal(t: &str) -> bool {
let t = t.trim_end_matches(['f', 'F']);
t.contains('.') && !t.contains(['e', 'E']) && t.chars().all(|c| c == '0' || c == '.')
@@ -59,6 +59,8 @@ pub(super) struct Stage<'p> {
pub(super) inverse: bool,
/// The stage calls `textureGatherOffsets`, which naga lacks: ours is in the prelude.
pub(super) gather_offsets: bool,
/// The stage calls `textureQueryLod`, which naga lacks: ours is in the prelude.
pub(super) query_lod: bool,
/// The stage calls `dot` / `normalize` and defines neither: the scalar overloads GLSL has
/// (`dot(float, float)`, `normalize(float)`) and naga lacks go in the prelude.
pub(super) scalar_dot: bool,
@@ -261,6 +263,7 @@ impl<'p> Stage<'p> {
out_copies: Vec::new(),
inverse: false,
gather_offsets: false,
query_lod: false,
scalar_dot: false,
scalar_normalize: false,
sampler_functions: HashMap::new(),
@@ -311,6 +314,7 @@ impl<'p> Stage<'p> {
}
"gl_FragCoord" => s.frag_coord = true,
"textureGatherOffsets" => s.gather_offsets = true,
"textureQueryLod" => s.query_lod = true,
"dot" | "normalize" => {
// A definition at the top level: `float dot(float a, ...)`.
let defines = depth == 0
@@ -357,6 +361,11 @@ impl<'p> Stage<'p> {
.entry("textureGatherOffsets".into())
.or_insert_with(|| vec![true, false, false, false, false]);
}
if s.query_lod {
s.sampler_functions
.entry("textureQueryLod".into())
.or_insert_with(|| vec![true, false]);
}
s
}
+7 -7
View File
@@ -27,11 +27,11 @@ fn real_packs_read() {
for f in found.iter().filter(|f| f.problem.is_none()) {
let start = Instant::now();
let pack = Pack::open(&f.path).unwrap();
let macros = Macros::standard();
let shaders = Properties::parse(
&pack.read_text("shaders.properties").unwrap_or_default(),
&horizon_shaders::with_options(&pack, &macros),
);
// The macros the game reads the pack's programs with: the standard set, the renderer's
// (at Mipmap Levels 4), then the features the pack turns on.
let mut macros = Macros::standard();
macros.define_renderer(4);
let (shaders, feature_notes) = horizon_shaders::read_properties(&pack, &mut macros);
let ids: Vec<Properties> = ["block", "item", "entity", "dimension"]
.iter()
.filter_map(|n| pack.read_text(&format!("{n}.properties")))
@@ -191,11 +191,11 @@ fn real_packs_read() {
index: "uint(@)".into(),
}),
};
let mut macros = Macros::standard();
let mut macros = macros.clone();
if std::env::var_os("SUBGROUPS").is_some() {
macros.define("MC_GL_KHR_shader_subgroup_arithmetic", "");
}
for note in macros.define_features(&shaders) {
for note in &feature_notes {
println!(" {note}");
}
let post = load_post(&pack, &folder, &shaders, &macros, &inputs);
+255 -32
View File
@@ -1,5 +1,9 @@
//! Typed native-mod data attached to server-owned holders.
use std::{any::Any, collections::BTreeMap, fmt};
use std::{
any::Any,
collections::{BTreeMap, BTreeSet},
fmt,
};
use crate::nbt::{Compound, JavaString, Tag};
@@ -19,6 +23,8 @@ pub enum AttachmentError {
InvalidNamespace,
/// Copy-on-death requires a serializer.
InvalidCopyPolicy,
/// Syncing to clients requires a serializer.
InvalidSyncPolicy,
}
/// NBT key reserved for server-owned attachment maps.
@@ -34,7 +40,7 @@ pub fn attachment_compound(tag: Option<&Tag>) -> Result<Option<&Compound>, Attac
}
/// A server holder in the host's current dimension.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Holder {
Entity(u32),
@@ -62,6 +68,28 @@ pub struct AttachmentType<T: 'static> {
pub default: fn() -> T,
pub codec: Option<AttachmentCodec<T>>,
pub copy_on_death: bool,
/// Send the value to the clients that see the holder; needs a codec.
pub sync: bool,
}
impl<T> AttachmentType<T> {
fn validate_policy(&self) -> Result<(), AttachmentError> {
if self.copy_on_death && self.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
if self.sync && self.codec.is_none() {
return Err(AttachmentError::InvalidSyncPolicy);
}
Ok(())
}
}
/// One attachment's new state for a client; `None` means removed.
#[derive(Clone, Debug, PartialEq)]
pub struct AttachmentSync {
pub namespace: JavaString,
pub name: JavaString,
pub value: Option<Compound>,
}
impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
@@ -74,7 +102,7 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
host.attachments(holder)?.get(self)
}
/// Whether a holder carries a value, like NeoForge's `hasData`; never inserts the default.
/// API @API:attachment_existing@.
/// API 317.
pub fn has<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
@@ -82,7 +110,7 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
) -> Result<bool, AttachmentError> {
Ok(host.attachments(holder)?.has(self))
}
/// Read a value only if present, like `getExistingData`. API @API:attachment_existing@.
/// Read a value only if present, like `getExistingData`. API 317.
pub fn get_existing<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
@@ -107,6 +135,14 @@ impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
) -> Result<Option<T>, AttachmentError> {
host.attachments(holder)?.remove(self)
}
/// Send the current value to the clients that see the holder.
pub fn sync_data<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
holder: Holder,
) -> Result<(), AttachmentError> {
host.attachments(holder)?.mark_sync(self)
}
}
trait Value: Send + Sync {
@@ -114,11 +150,13 @@ trait Value: Send + Sync {
fn clone_box(&self) -> Box<dyn Value>;
fn encoded(&self) -> Option<&Compound>;
fn copy_on_death(&self) -> bool;
fn sync(&self) -> bool;
}
struct TypedValue<T> {
value: T,
encoded: Option<Compound>,
copy_on_death: bool,
sync: bool,
}
impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
fn as_any(&self) -> &dyn Any {
@@ -129,6 +167,7 @@ impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
value: self.value.clone(),
encoded: self.encoded.clone(),
copy_on_death: self.copy_on_death,
sync: self.sync,
})
}
fn encoded(&self) -> Option<&Compound> {
@@ -137,11 +176,51 @@ impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
fn copy_on_death(&self) -> bool {
self.copy_on_death
}
fn sync(&self) -> bool {
self.sync
}
}
/// The saved form of a decoded value: its encoding and the policies it was
/// declared with. `None` when the type has no codec.
fn envelope(value: &dyn Value) -> Option<Compound> {
value.encoded().map(|encoded| {
let mut envelope = Compound::new();
envelope.insert("value".into(), Tag::Compound(encoded.clone()));
if value.copy_on_death() {
envelope.insert("copy_on_death".into(), Tag::Byte(1));
}
if value.sync() {
envelope.insert("sync".into(), Tag::Byte(1));
}
envelope
})
}
fn raw_value(raw: &Compound) -> Option<&Compound> {
match raw.get(&"value".into()) {
Some(Tag::Compound(value)) => Some(value),
_ => None,
}
}
enum Entry {
Raw(Compound),
Decoded(Box<dyn Value>),
}
impl Entry {
/// The encoded value, as clients receive it.
fn synced_value(&self) -> Option<&Compound> {
match self {
Self::Raw(raw) => raw_value(raw),
Self::Decoded(value) => value.encoded(),
}
}
fn is_synced(&self) -> bool {
match self {
Self::Raw(raw) => matches!(raw.get(&"sync".into()), Some(Tag::Byte(1))),
Self::Decoded(value) => value.sync(),
}
}
}
impl Clone for Entry {
fn clone(&self) -> Self {
match self {
@@ -157,6 +236,8 @@ pub struct Attachments {
entries: BTreeMap<(JavaString, JavaString), Entry>,
// Keep malformed or otherwise unrecognized NBT exactly as it was read.
raw: Compound,
// Keys to send to clients at the next flush; never saved.
pending: BTreeSet<(JavaString, JavaString)>,
}
impl fmt::Debug for Attachments {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -172,6 +253,7 @@ impl Attachments {
let mut result = Self {
entries: BTreeMap::new(),
raw,
pending: BTreeSet::new(),
};
for (namespace, tag) in &result.raw {
if let Tag::Compound(names) = tag {
@@ -192,14 +274,7 @@ impl Attachments {
for ((namespace, name), entry) in &self.entries {
let value = match entry {
Entry::Raw(value) => Some(value.clone()),
Entry::Decoded(value) => value.encoded().map(|encoded| {
let mut envelope = Compound::new();
envelope.insert("value".into(), Tag::Compound(encoded.clone()));
if value.copy_on_death() {
envelope.insert("copy_on_death".into(), Tag::Byte(1));
}
envelope
}),
Entry::Decoded(value) => envelope(value.as_ref()),
};
if let Some(value) = value {
let tag = raw
@@ -224,17 +299,7 @@ impl Attachments {
Some(raw.clone())
}
Entry::Decoded(value) if value.copy_on_death() => {
let value = value
.encoded()
.ok_or(AttachmentError::InvalidCopyPolicy)?
.clone();
Some(
[
("value".into(), Tag::Compound(value)),
("copy_on_death".into(), Tag::Byte(1)),
]
.into(),
)
Some(envelope(value.as_ref()).ok_or(AttachmentError::InvalidCopyPolicy)?)
}
_ => None,
};
@@ -254,9 +319,7 @@ impl Attachments {
&mut self,
ty: &AttachmentType<T>,
) -> Result<Option<T>, AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
ty.validate_policy()?;
if self
.raw
.get(&ty.namespace.into())
@@ -266,9 +329,7 @@ impl Attachments {
}
let value = match self.entries.get(&(ty.namespace.into(), ty.name.into())) {
Some(Entry::Raw(raw)) => {
let Some(Tag::Compound(value)) = raw.get(&"value".into()) else {
return Err(AttachmentError::Decode);
};
let value = raw_value(raw).ok_or(AttachmentError::Decode)?;
(ty.codec.as_ref().ok_or(AttachmentError::Decode)?.decode)(value)?
}
Some(Entry::Decoded(value)) => {
@@ -294,6 +355,9 @@ impl Attachments {
}
let value = (ty.default)();
self.insert_decoded(ty, value.clone())?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
Ok(value)
}
/// Replace a value. A key already decoded as a different type is rejected.
@@ -302,9 +366,7 @@ impl Attachments {
ty: &AttachmentType<T>,
value: T,
) -> Result<(), AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
ty.validate_policy()?;
if self
.raw
.get(&ty.namespace.into())
@@ -318,6 +380,9 @@ impl Attachments {
return Err(AttachmentError::TypeMismatch);
}
self.insert_decoded(ty, value)?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
names.remove(&ty.name.into());
}
@@ -332,6 +397,9 @@ impl Attachments {
if !self.has(ty) {
return Ok(None);
}
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
let value = self.get_existing(ty);
self.entries.remove(&(ty.namespace.into(), ty.name.into()));
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
@@ -355,10 +423,65 @@ impl Attachments {
value,
encoded,
copy_on_death: ty.copy_on_death,
sync: ty.sync,
})),
);
Ok(())
}
/// Queue a value to be sent to clients again; nothing for an unsynced type.
pub fn mark_sync<T: 'static>(&mut self, ty: &AttachmentType<T>) -> Result<(), AttachmentError> {
ty.validate_policy()?;
if ty.sync {
self.pending.insert((ty.namespace.into(), ty.name.into()));
}
Ok(())
}
/// The changes to send to clients since the last call: the current value
/// of each queued key, or `None` for one that is gone.
pub fn take_sync_changes(&mut self) -> Vec<AttachmentSync> {
std::mem::take(&mut self.pending)
.into_iter()
.map(|(namespace, name)| {
let value = self
.entries
.get(&(namespace.clone(), name.clone()))
.and_then(Entry::synced_value)
.cloned();
AttachmentSync {
namespace,
name,
value,
}
})
.collect()
}
/// Every synced value, for a client that starts seeing the holder.
pub fn synced(&self) -> Vec<AttachmentSync> {
self.entries
.iter()
.filter(|(_, entry)| entry.is_synced())
.filter_map(|((namespace, name), entry)| {
Some(AttachmentSync {
namespace: namespace.clone(),
name: name.clone(),
value: Some(entry.synced_value()?.clone()),
})
})
.collect()
}
/// Client side: keep what the server sent, decoded by the next `get`.
pub fn apply_sync(&mut self, change: AttachmentSync) {
let key = (change.namespace, change.name);
match change.value {
Some(value) => {
let raw = [("value".into(), Tag::Compound(value))].into();
self.entries.insert(key, Entry::Raw(raw));
}
None => {
self.entries.remove(&key);
}
}
}
}
#[cfg(test)]
@@ -380,13 +503,30 @@ mod tests {
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: false,
sync: false,
};
static SYNCED: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "synced",
default: || 7,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: false,
sync: true,
};
fn change(name: &str, value: Option<i32>) -> AttachmentSync {
AttachmentSync {
namespace: "test".into(),
name: name.into(),
value: value.map(|n| encode(&n).unwrap()),
}
}
static MEMORY: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "memory",
default: || 3,
codec: None,
copy_on_death: false,
sync: false,
};
#[test]
fn saved_values_round_trip_and_memory_values_do_not() {
@@ -408,6 +548,7 @@ mod tests {
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: true,
sync: false,
};
let mut original = Attachments::default();
original.set(&keep, 42).unwrap();
@@ -469,4 +610,86 @@ mod tests {
assert_eq!(map.remove(&COUNTER), Err(AttachmentError::Decode));
assert_eq!(map.remove(&COUNTER), Ok(None), "removed all the same");
}
#[test]
fn synced_set_and_remove_report_the_last_state_once() {
let mut map = Attachments::default();
map.set(&SYNCED, 1).unwrap();
map.set(&SYNCED, 2).unwrap();
map.remove(&SYNCED).unwrap();
assert_eq!(map.take_sync_changes(), [change("synced", None)]);
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn default_creation_is_synced_and_a_later_read_is_not() {
let mut map = Attachments::default();
assert_eq!(map.get(&SYNCED), Ok(7));
assert_eq!(map.take_sync_changes(), [change("synced", Some(7))]);
assert_eq!(map.get(&SYNCED), Ok(7));
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn unsynced_types_report_no_changes() {
let mut map = Attachments::default();
map.set(&COUNTER, 1).unwrap();
map.remove(&COUNTER).unwrap();
map.mark_sync(&COUNTER).unwrap();
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn sync_without_a_codec_is_refused() {
let broken = AttachmentType {
namespace: "test",
name: "broken",
default: || 0,
codec: None,
copy_on_death: false,
sync: true,
};
let mut map = Attachments::default();
assert_eq!(map.get(&broken), Err(AttachmentError::InvalidSyncPolicy));
assert_eq!(map.set(&broken, 1), Err(AttachmentError::InvalidSyncPolicy));
assert_eq!(
map.mark_sync(&broken),
Err(AttachmentError::InvalidSyncPolicy)
);
assert!(map.take_sync_changes().is_empty());
}
#[test]
fn a_saved_sync_flag_puts_undecoded_values_in_the_snapshot() {
let mut map = Attachments::default();
map.set(&SYNCED, 5).unwrap();
map.set(&COUNTER, 6).unwrap();
let saved = map.to_nbt();
let Some(Tag::Compound(names)) = saved.get(&"test".into()) else {
panic!("namespace saved");
};
let Some(Tag::Compound(envelope)) = names.get(&"synced".into()) else {
panic!("entry saved");
};
assert_eq!(envelope.get(&"sync".into()), Some(&Tag::Byte(1)));
let loaded = Attachments::from_nbt(saved);
assert_eq!(loaded.synced(), [change("synced", Some(5))]);
}
#[test]
fn applied_changes_decode_through_the_codec_and_removals_clear() {
let mut map = Attachments::default();
map.apply_sync(change("synced", Some(9)));
assert_eq!(map.get(&SYNCED), Ok(9));
map.apply_sync(change("synced", None));
assert_eq!(map.get(&SYNCED), Ok(7));
}
#[test]
fn sync_data_reports_the_current_value() {
struct One(Attachments);
impl AttachmentHost for One {
fn attachments(&mut self, _: Holder) -> Result<&mut Attachments, AttachmentError> {
Ok(&mut self.0)
}
}
let mut host = One(Attachments::default());
SYNCED.set(&mut host, Holder::Level, 4).unwrap();
host.0.take_sync_changes();
SYNCED.sync_data(&mut host, Holder::Level).unwrap();
assert_eq!(host.0.take_sync_changes(), [change("synced", Some(4))]);
}
}
Loaded 100 of 347 files, more files were not shown because too many files have changed in this diff. Show more