mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge main into #7328
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -761,8 +761,7 @@ impl Game<'_> {
|
||||
if (effect == "instant_damage") != inverted {
|
||||
m.damage((6 << amp.min(30)) as f32, Damage::of(DamageKind::Magic));
|
||||
} else {
|
||||
m.health =
|
||||
(m.health + (4 << amp.min(30)) as f32).min(m.attrs.max_health);
|
||||
m.heal((4 << amp.min(30)) as f32);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -780,6 +779,23 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn apply_effect_action(&mut self, action: EffectAction) {
|
||||
let action = match action {
|
||||
EffectAction::Heal(mut amount) => {
|
||||
if let Some(player) = self.player.as_ref() {
|
||||
let mut cancel = false;
|
||||
horizon_client::hunger::native_living_heal(
|
||||
player.entity_id as u32,
|
||||
&mut amount,
|
||||
&mut cancel,
|
||||
);
|
||||
if cancel {
|
||||
return;
|
||||
}
|
||||
}
|
||||
EffectAction::Heal(amount)
|
||||
}
|
||||
action => action,
|
||||
};
|
||||
let creative = self.creative;
|
||||
let rules = self.world.health_rules;
|
||||
let Some(p) = self.player.as_mut() else {
|
||||
@@ -1382,7 +1398,7 @@ impl Game<'_> {
|
||||
let harm = m.potion_harm(amount(6));
|
||||
m.hurt(harm, Some(at), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + amount(4)).min(m.kind.max_health);
|
||||
m.heal(amount(4));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
@@ -1725,8 +1741,7 @@ impl Game<'_> {
|
||||
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
|
||||
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
|
||||
} else {
|
||||
m.health = (m.health + (intensity * f64::from(4 << amp) + 0.5) as i32 as f32)
|
||||
.min(m.kind.max_health);
|
||||
m.heal((intensity * f64::from(4 << amp) + 0.5) as i32 as f32);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,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],
|
||||
|
||||
@@ -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, ¯os),
|
||||
);
|
||||
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};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>];
|
||||
|
||||
+766
-585
File diff suppressed because it is too large.
Load diff
+6
-5
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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\
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¯os),
|
||||
);
|
||||
// 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, ¯os, &inputs);
|
||||
|
||||
@@ -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
Reference in new issue
Block a user