mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-08 17:19:10 -10:00
Merge main into #7332
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
f620fed9a7
48 files changed
+1297
-356
No files matched your search
@@ -7011,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 {
|
||||
@@ -7037,6 +7041,7 @@ impl Renderer {
|
||||
skin,
|
||||
&self.entity_buffer,
|
||||
0,
|
||||
outline_base,
|
||||
);
|
||||
}
|
||||
let static_sections = frame.static_block_entities;
|
||||
@@ -7163,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 {
|
||||
@@ -7194,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);
|
||||
@@ -9083,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.
|
||||
|
||||
@@ -107,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 {
|
||||
@@ -115,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.
|
||||
@@ -138,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,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,
|
||||
@@ -303,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,
|
||||
@@ -369,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];
|
||||
@@ -557,13 +604,30 @@ mod tests {
|
||||
},
|
||||
],
|
||||
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,
|
||||
});
|
||||
@@ -572,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(),
|
||||
@@ -595,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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3782,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
|
||||
@@ -7325,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());
|
||||
@@ -7356,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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -6492,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(),
|
||||
@@ -6576,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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -697,7 +697,7 @@
|
||||
//! change a cauldron.
|
||||
//!
|
||||
//! API 53 adds entities leaving a level (`native_entity_leave_level`), for
|
||||
//! item entities, experience orbs and mobs so far.
|
||||
//! every kind the join hook tells (see API 47).
|
||||
//!
|
||||
//! API 52 adds `native_block_entity_sync`: a mod block entity's block and update tag
|
||||
//! sent to clients again (`Level.sendBlockUpdated`).
|
||||
@@ -718,7 +718,7 @@
|
||||
//! API 47 adds entities joining a level (`native_entity_join_level`); it tells
|
||||
//! every kind the game keeps in its entity lists (items, orbs, mobs, boats,
|
||||
//! minecarts, armor stands, TNT, falling blocks, projectiles, rockets,
|
||||
//! tridents).
|
||||
//! tridents, end crystals, the dragon, cushions, paintings, item frames).
|
||||
//!
|
||||
//! API 46 adds bounded world-space rim and colored-light requests
|
||||
//! (`native_visuals`) and opt-in texture-matched emissive material
|
||||
@@ -2309,6 +2309,16 @@ impl Game<'_> {
|
||||
pub(super) fn native_commands(&mut self, commands: &mut Vec<&'static str>) {
|
||||
mod_api::dispatch_commands(self, mod_api::mods(), commands);
|
||||
}
|
||||
/// Local player movement input, after reading it and before applying it.
|
||||
pub(super) fn native_movement_input(
|
||||
&self,
|
||||
forward: &mut f32,
|
||||
strafe: &mut f32,
|
||||
jump: &mut bool,
|
||||
sneak: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_movement_input(self, mod_api::mods(), forward, strafe, jump, sneak);
|
||||
}
|
||||
/// World field of view, after normal calculation and zoom and before projection/Nausea.
|
||||
/// Called for world rendering and near-plane overlays; hand/HUD FOV uses its own path.
|
||||
/// Only finite values in 1..=179 degrees replace the computed FOV.
|
||||
@@ -6317,8 +6327,10 @@ impl Game<'_> {
|
||||
/// a level closing with nobody in it included. Not for the entities still
|
||||
/// loaded when the server stops, as in vanilla. `entity` is its ID and
|
||||
/// `kind` its type (`minecraft:item`); it is no longer in the level's lists.
|
||||
/// It only hears. 53 tells item entities, experience orbs and mobs (`kind`
|
||||
/// the mob type); later APIs add other kinds, so check `kind`. Server-side.
|
||||
/// It only hears. 53 told item entities, experience orbs and mobs; it now
|
||||
/// tells every kind `native_entity_join_level` does (`kind` is the type), each
|
||||
/// once, when the game next notices it gone (the tick or action that removed
|
||||
/// it). Check `kind`. Server-side.
|
||||
pub(super) fn native_entity_leave_level(&mut self, dimension: &str, entity: u32, kind: &str) {
|
||||
mod_api::dispatch_entity_leave_level(self, mod_api::mods(), dimension, entity, kind);
|
||||
}
|
||||
@@ -6332,12 +6344,14 @@ impl Game<'_> {
|
||||
/// back out as if never added, and a `/summon` of it fails. Tells every
|
||||
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
|
||||
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
|
||||
/// tridents), once each, in the order they were created; check `kind`. A
|
||||
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
|
||||
/// `/summon` and a chunk's entities are told at once). A mod that
|
||||
/// cancels one of the others sees it removed before it ticks again.
|
||||
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
|
||||
/// the entities Horizon keeps without simulating. Server-side.
|
||||
/// tridents, end crystals, the dragon, cushions, paintings, item frames),
|
||||
/// once each, in the order they were created; check `kind`. A drop, an
|
||||
/// orb, a mob, a `/summon` and a chunk's entities are told at once, any
|
||||
/// other spawn between the tick's phases (it is checked after the blocks,
|
||||
/// the vehicles, the projectiles, the items and the mobs, and at the
|
||||
/// tick's end). A mod that cancels one of the others sees it removed
|
||||
/// before it ticks again. Not told: the entities Horizon keeps without
|
||||
/// simulating (displays, markers, interactions). Server-side.
|
||||
pub(super) fn native_entity_join_level(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -8258,22 +8272,6 @@ pub(super) fn mob_kind(mob: &horizon_client::mobs::Mob) -> String {
|
||||
["minecraft:", mob.kind.id.trim_start_matches("minecraft:")].concat()
|
||||
}
|
||||
|
||||
/// The ids of `before` (id, kind) that are not in `now`, grouped by kind in
|
||||
/// first-seen order: the entities that left the level.
|
||||
fn vanished_by_kind(
|
||||
before: &[(u32, String)],
|
||||
now: &std::collections::HashSet<u32>,
|
||||
) -> Vec<(String, Vec<u32>)> {
|
||||
let mut out: Vec<(String, Vec<u32>)> = Vec::new();
|
||||
for (id, kind) in before.iter().filter(|(id, _)| !now.contains(id)) {
|
||||
match out.iter_mut().find(|(k, _)| k == kind) {
|
||||
Some((_, ids)) => ids.push(*id),
|
||||
None => out.push((kind.clone(), Vec::from([*id]))),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where the game calls the hook points.
|
||||
impl Game<'_> {
|
||||
/// The acting player as the hook points name one: the guest whose turn
|
||||
@@ -8395,80 +8393,6 @@ impl Game<'_> {
|
||||
self.native_mob_spawned(self.dim.id(), &kind, pos, natural);
|
||||
}
|
||||
}
|
||||
/// `native_entity_leave_level` for each of `entities`, of `kind`, gone
|
||||
/// from the level in place. A client of a server tells nothing.
|
||||
pub(super) fn native_entities_left(&mut self, kind: &str, entities: &[u32]) {
|
||||
if self.remote.is_some() {
|
||||
return;
|
||||
}
|
||||
let dimension = self.dim.id();
|
||||
for &entity in entities {
|
||||
self.native_entity_leave_level(dimension, entity, kind);
|
||||
}
|
||||
}
|
||||
/// The IDs of the level's item entities, for `native_items_left_since`
|
||||
/// (none on a client of a server, which tells nothing).
|
||||
pub(super) fn dropped_ids(&self) -> Vec<u32> {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.dropped.iter().map(|e| e.id).collect()
|
||||
}
|
||||
/// The IDs of the level's experience orbs, for `native_orbs_left_since`.
|
||||
pub(super) fn orb_ids(&self) -> Vec<u32> {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.orbs.iter().map(|o| o.id).collect()
|
||||
}
|
||||
/// The (id, kind) of the level's mobs, for `native_mobs_left_since` (none
|
||||
/// on a client of a server, which tells nothing).
|
||||
pub(super) fn mob_ids(&self) -> Vec<(u32, String)> {
|
||||
if self.remote.is_some() || mod_api::mods().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
self.mobs.iter().map(|m| (m.id, mob_kind(m))).collect()
|
||||
}
|
||||
/// The mobs of `before` gone since have left the level (died, despawned,
|
||||
/// entered a hive, unloaded). Mobs spawned meanwhile are not counted.
|
||||
pub(super) fn native_mobs_left_since(&mut self, before: &[(u32, String)]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.mobs.iter().map(|m| m.id).collect();
|
||||
for (kind, ids) in vanished_by_kind(before, &now) {
|
||||
self.native_entities_left(&kind, &ids);
|
||||
}
|
||||
}
|
||||
/// The item entities of `before` gone since have left the level.
|
||||
pub(super) fn native_items_left_since(&mut self, before: &[u32]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.dropped.iter().map(|e| e.id).collect();
|
||||
let gone: Vec<u32> = before
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !now.contains(id))
|
||||
.collect();
|
||||
self.native_entities_left("minecraft:item", &gone);
|
||||
}
|
||||
/// The experience orbs of `before` gone since have left the level.
|
||||
pub(super) fn native_orbs_left_since(&mut self, before: &[u32]) {
|
||||
// Nothing was taken: no mod listens, or a client of a server.
|
||||
if before.is_empty() {
|
||||
return;
|
||||
}
|
||||
let now: std::collections::HashSet<u32> = self.orbs.iter().map(|o| o.id).collect();
|
||||
let gone: Vec<u32> = before
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| !now.contains(id))
|
||||
.collect();
|
||||
self.native_entities_left("minecraft:experience_orb", &gone);
|
||||
}
|
||||
/// The acting player's blow killed mob `index`.
|
||||
pub(super) fn native_kill_event(&mut self, index: usize) {
|
||||
let (entity, kind) = (self.mobs[index].id, mob_kind(&self.mobs[index]));
|
||||
@@ -9319,26 +9243,6 @@ fn mod_screen_opens(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn vanished_mobs_are_reported_by_kind() {
|
||||
use std::collections::HashSet;
|
||||
let z = || "minecraft:zombie".to_string();
|
||||
let b = || "minecraft:bee".to_string();
|
||||
let before = vec![(1, z()), (2, b()), (3, z()), (4, b())];
|
||||
let now: HashSet<u32> = [1, 4, 99].into_iter().collect(); // 99 spawned meanwhile
|
||||
assert_eq!(
|
||||
super::vanished_by_kind(&before, &now),
|
||||
vec![(b(), vec![2]), (z(), vec![3])]
|
||||
);
|
||||
let all: HashSet<u32> = [1, 2, 3, 4, 5].into_iter().collect();
|
||||
assert!(super::vanished_by_kind(&before, &all).is_empty());
|
||||
assert_eq!(
|
||||
super::vanished_by_kind(&before, &HashSet::new()),
|
||||
vec![(z(), vec![1, 3]), (b(), vec![2, 4])]
|
||||
);
|
||||
assert!(super::vanished_by_kind(&[], &now).is_empty());
|
||||
}
|
||||
|
||||
/// Exercise the gameplay constructor, where name-based entities compete
|
||||
/// with a mod block's sign template.
|
||||
#[test]
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -5781,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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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())
|
||||
@@ -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 {
|
||||
@@ -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();
|
||||
|
||||
+47
-8
@@ -714,12 +714,14 @@ pub trait Hooks: Send + Sync {
|
||||
/// back out as if never added, and a `/summon` of it fails. Tells every
|
||||
/// kind in the game's entity lists (items, orbs, mobs, boats, minecarts,
|
||||
/// armor stands, primed TNT, falling blocks, projectiles, rockets,
|
||||
/// tridents), once each, in the order they were created; check `kind`. A
|
||||
/// gameplay spawn is told when the next tick starts (a drop, an orb, a mob, a
|
||||
/// `/summon` and a chunk's entities are told at once). A mod that
|
||||
/// cancels one of the others sees it removed before it ticks again.
|
||||
/// Not told: end crystals, the dragon, cushions, paintings, item frames and
|
||||
/// the entities Horizon keeps without simulating. Server-side.
|
||||
/// tridents, end crystals, the dragon, cushions, paintings, item frames),
|
||||
/// once each, in the order they were created; check `kind`. A drop, an
|
||||
/// orb, a mob, a `/summon` and a chunk's entities are told at once, any
|
||||
/// other spawn between the tick's phases (it is checked after the blocks,
|
||||
/// the vehicles, the projectiles, the items and the mobs, and at the
|
||||
/// tick's end). A mod that cancels one of the others sees it removed
|
||||
/// before it ticks again. Not told: the entities Horizon keeps without
|
||||
/// simulating (displays, markers, interactions). Server-side.
|
||||
///
|
||||
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
|
||||
fn entity_join_level(&self, host: &mut dyn Host, event: &mut EntityJoinLevelEvent<'_>) -> Flow {
|
||||
@@ -763,8 +765,10 @@ pub trait Hooks: Send + Sync {
|
||||
/// a level closing with nobody in it included. Not for the entities still
|
||||
/// loaded when the server stops, as in vanilla. `entity` is its ID and
|
||||
/// `kind` its type (`minecraft:item`); it is no longer in the level's lists.
|
||||
/// It only hears. 53 tells item entities, experience orbs and mobs (`kind`
|
||||
/// the mob type); later APIs add other kinds, so check `kind`. Server-side.
|
||||
/// It only hears. 53 told item entities, experience orbs and mobs; it now
|
||||
/// tells every kind `native_entity_join_level` does (`kind` is the type), each
|
||||
/// once, when the game next notices it gone (the tick or action that removed
|
||||
/// it). Check `kind`. Server-side.
|
||||
fn entity_leave_level(&self, host: &mut dyn Host, event: &mut EntityLeaveLevelEvent<'_>) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
@@ -4508,6 +4512,11 @@ pub trait Hooks: Send + Sync {
|
||||
fn living_jump(&self, event: &mut LivingJumpEvent<'_>) {
|
||||
let _ = event;
|
||||
}
|
||||
/// Local player movement input, after reading it and before applying it.
|
||||
fn movement_input(&self, host: &dyn Host, event: &mut MovementInputEvent) {
|
||||
let _ = host;
|
||||
let _ = event;
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::frame`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
@@ -13795,3 +13804,33 @@ pub fn dispatch_living_jump(mods: &[Box<dyn Hooks>], entity: u32, kind: &str) {
|
||||
mod_hook.living_jump(&mut event);
|
||||
}
|
||||
}
|
||||
/// What [`Hooks::movement_input`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
|
||||
#[non_exhaustive]
|
||||
pub struct MovementInputEvent {
|
||||
pub forward: f32,
|
||||
pub strafe: f32,
|
||||
pub jump: bool,
|
||||
pub sneak: bool,
|
||||
}
|
||||
pub fn dispatch_movement_input(
|
||||
host: &dyn Host,
|
||||
mods: &[Box<dyn Hooks>],
|
||||
forward: &mut f32,
|
||||
strafe: &mut f32,
|
||||
jump: &mut bool,
|
||||
sneak: &mut bool,
|
||||
) {
|
||||
let mut event = MovementInputEvent {
|
||||
forward: *forward,
|
||||
strafe: *strafe,
|
||||
jump: *jump,
|
||||
sneak: *sneak,
|
||||
};
|
||||
for mod_hook in super::entered(mods, "movement_input") {
|
||||
mod_hook.movement_input(host, &mut event);
|
||||
}
|
||||
*forward = event.forward;
|
||||
*strafe = event.strafe;
|
||||
*jump = event.jump;
|
||||
*sneak = event.sneak;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use std::{
|
||||
path::PathBuf,
|
||||
sync::{
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
atomic::{AtomicU32, Ordering},
|
||||
},
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
@@ -24,7 +24,7 @@ const VERSION: u32 = 2;
|
||||
const FILE_NAME: &str = "behaviours-v2.bin";
|
||||
const MAX_FILE_BYTES: usize = 128 * 1024 * 1024;
|
||||
const MAX_DECODED_BYTES: usize = 256 * 1024 * 1024;
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
static NEXT_TEMP: AtomicU32 = AtomicU32::new(0);
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
/// Keep snapshots in `dir` (the client passes `<game folder>/.horizon-cache`).
|
||||
@@ -776,9 +776,9 @@ fn write(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::AtomicU32;
|
||||
|
||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||
static NEXT_FIXTURE: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
struct FixtureDir(PathBuf);
|
||||
impl Drop for FixtureDir {
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read, Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
const SECTOR: usize = 4096;
|
||||
@@ -387,7 +387,7 @@ impl RegionFile {
|
||||
}
|
||||
type BTreeChanges<'a> = std::collections::BTreeMap<usize, Option<&'a ChunkRecord>>;
|
||||
|
||||
static NEXT_TEMP: AtomicU64 = AtomicU64::new(0);
|
||||
static NEXT_TEMP: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
/// Writes a complete new region to a temporary sibling, then publishes it with
|
||||
/// an atomic, non-overwriting hard link. Drop rolls back unpublished files.
|
||||
|
||||
@@ -10,10 +10,10 @@ use std::{
|
||||
fs,
|
||||
io::{Seek, SeekFrom, Write},
|
||||
path::{Path, PathBuf},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
sync::atomic::{AtomicU32, Ordering},
|
||||
};
|
||||
|
||||
static NEXT: AtomicU64 = AtomicU64::new(0);
|
||||
static NEXT: AtomicU32 = AtomicU32::new(0);
|
||||
struct Directory(PathBuf);
|
||||
impl Directory {
|
||||
fn new() -> Self {
|
||||
|
||||
@@ -841,6 +841,8 @@ API 108 adds `native_server_data_loaded(reload)` fires once the server's data ta
|
||||
|
||||
API 109 adds `native_compute_fov(partial, camera, degrees)` receives the computed world FOV after settings, ordinary FOV effects and zoom, before projection/Nausea. `camera` is its world position and `partial` the interpolation fraction. Mods can narrow or widen the angle; only finite values in 1..=179 degrees are accepted, otherwise the original computed angle is used. It runs for world rendering and near-plane overlays, not just once per frame. Hand/HUD FOV and the title panorama keep their existing paths. Bundles need "hooks": 109.
|
||||
|
||||
`native_movement_input(forward, strafe, jump, sneak)` runs on the client each local player tick after input is read and before it is applied. Mods may change all four values; positive strafe is left. The server receives direction flags, not scalar magnitudes, so remote servers may correct modified movement. Bundles need "hooks": @API:movement_input@.
|
||||
|
||||
API 110 adds mobs drawn by client mods: `native_mob_submit(mob, draw, drawn)` is asked each frame for every mob in view but players,
|
||||
with its id, kind, custom name and the frame's body and head turns and walk cycle; a mod draws a model of its own with the
|
||||
block entity drawing helpers under `draw`'s pose (at the feet, turned with the body) and sets `drawn` to leave out the vanilla
|
||||
|
||||
@@ -585,6 +585,7 @@ public final class VanillaMobAiOracle {
|
||||
new net.minecraft.world.item.ItemStack(BuiltInRegistries.ITEM.getValue(
|
||||
net.minecraft.resources.Identifier.parse("minecraft:red_harness"))));
|
||||
case "noai" -> mob.setNoAi(true);
|
||||
case "agelocked" -> method(AgeableMob.class, "setAgeLocked", boolean.class).invoke(mob, true);
|
||||
case "profession" -> {
|
||||
Villager villager = (Villager) mob;
|
||||
villager.setVillagerData(villager.getVillagerData().withProfession(level.registryAccess(),
|
||||
@@ -1109,6 +1110,7 @@ public final class VanillaMobAiOracle {
|
||||
mem.sort(null);
|
||||
sb.append("\tmem ").append(String.join(" ", mem));
|
||||
}
|
||||
if (m instanceof AgeableMob ageable) sb.append("\tage ").append(ageable.getAge());
|
||||
if (m instanceof Villager villager) sb.append("\tvx ").append(villager(villager));
|
||||
if (m instanceof net.minecraft.world.entity.animal.golem.CopperGolem golem) {
|
||||
Object opened = get(golem, "openedChestPos");
|
||||
|
||||
@@ -350,6 +350,7 @@ class StressSceneTests(unittest.TestCase):
|
||||
self.assertEqual(summons("stress_items", "item"), 2000)
|
||||
self.assertEqual(sum(summons("stress_held", k) for k in stress_scenes.HOLDERS), 1000)
|
||||
self.assertEqual(sum(summons("stress_eyes", k) for k in stress_scenes.EYED), 1000)
|
||||
self.assertEqual(sum(summons("stress_glowing", k) for k in stress_scenes.MOBS), 1000)
|
||||
self.assertEqual(summons("stress_frames", "item_frame"), 512)
|
||||
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
|
||||
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
|
||||
|
||||
@@ -1686,6 +1686,38 @@ def summoned_potions():
|
||||
return ops
|
||||
|
||||
|
||||
def trial_spawner_passengers():
|
||||
"""Configured passengers must exist as entities with a vehicle (#7254)."""
|
||||
ops = [*setup('/fill 1 219 1 13 219 8 stone',
|
||||
'/fill 1 220 1 13 224 8 air', '/gamerule spawn_mobs true')]
|
||||
# Exercise namespace and position independently, plus the explicit control.
|
||||
for i, (kind, pos) in enumerate([
|
||||
('chicken', ''), ('minecraft:chicken', ''),
|
||||
('chicken', ',Pos:[4.5d,220.0d,6.5d]'),
|
||||
('minecraft:chicken', ',Pos:[4.5d,220.0d,6.5d]'),
|
||||
]):
|
||||
parent, rider = f'trial_parent_{i}', f'trial_rider_{i}'
|
||||
ops += setup(
|
||||
'/setblock 4 220 4 trial_spawner[trial_spawner_state=active]'
|
||||
'{normal_config:{spawn_range:3,total_mobs:1.0f,simultaneous_mobs:1.0f,'
|
||||
'total_mobs_added_per_player:0.0f,simultaneous_mobs_added_per_player:0.0f,'
|
||||
'ticks_between_spawn:1000},spawn_data:{entity:{id:"zombie",'
|
||||
f'Tags:["{parent}"],Pos:[4.5d,220.0d,6.5d],NoAI:1b,'
|
||||
f'Passengers:[{{id:"{kind}",NoAI:1b,Tags:["{rider}"]{pos}}}]'
|
||||
'}},next_mob_spawns_at:0L}',
|
||||
'/data modify block 4 220 4 registered_players append from entity @s UUID')
|
||||
ops += [
|
||||
{'op': 'tick', 'n': 5},
|
||||
cmd(f'/execute if entity @e[tag={parent}]'),
|
||||
cmd(f'/execute if entity @e[tag={rider}]'),
|
||||
cmd(f'/execute as @e[tag={rider}] on vehicle if entity @s[tag={parent}]'),
|
||||
cmd(f'/data get entity @e[tag={parent},limit=1] Passengers[0].Tags'),
|
||||
*setup(f'/kill @e[tag={parent}]', f'/kill @e[tag={rider}]',
|
||||
'/setblock 4 220 4 air'),
|
||||
]
|
||||
return ops + setup('/fill 1 219 1 13 224 8 air', '/gamerule spawn_mobs false')
|
||||
|
||||
|
||||
def groups():
|
||||
return [
|
||||
('setup', group_setup()),
|
||||
@@ -1698,6 +1730,7 @@ def groups():
|
||||
('wither spawn', wither_spawn()),
|
||||
('riding teleport', riding_teleport()),
|
||||
('mob riding', mob_riding()),
|
||||
('trial spawner passengers', trial_spawner_passengers()),
|
||||
('camel riders', camel_riders()),
|
||||
('scores, teams and bossbars', scores()),
|
||||
('execute', execute()),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"name": "movement_input",
|
||||
"target": "horizon_client::game::native_hooks::Game<'_>::native_movement_input",
|
||||
"neoforge": "MovementInputUpdateEvent",
|
||||
"side": "client",
|
||||
"cancellable": false,
|
||||
"mod_api": {
|
||||
"contract": "listen"
|
||||
}
|
||||
}
|
||||
],
|
||||
"rust_summary": "Local player movement input before application (`native_movement_input`).",
|
||||
"doc_summary": "`native_movement_input(forward, strafe, jump, sneak)` lets client mods change local movement after input is read and before it is applied. Positive strafe is left; servers receive direction flags rather than scalar magnitudes. Bundles need hooks API @API:movement_input@."
|
||||
}
|
||||
@@ -61,7 +61,7 @@ StandingSignBlock.getStateForPlacement port crates/horizon-world/src/level/place
|
||||
StandingSignBlock.mirror port crates/horizon-world/src/worldgen/structure.rs::StateTransforms::mirror
|
||||
StandingSignBlock.rotate port crates/horizon-world/src/worldgen/structure.rs::StateTransforms::rotate
|
||||
StandingSignBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class
|
||||
StemBlock.getCloneItemStack missing unsure: minor: middle-click on a stem may not return its seed item; no stem case found in the pick code
|
||||
StemBlock.getCloneItemStack port crates/horizon-client/src/game/block_items.rs::clone_item a pumpkin or melon stem, attached or not, picks the seed it grew from; any other block picks its own item
|
||||
StemBlock.isValidBonemealTarget port crates/horizon-world/src/level/bonemeal.rs::Sim::bonemeal_valid
|
||||
StemBlock.performBonemeal port crates/horizon-world/src/level/bonemeal.rs::Sim::perform_bonemeal
|
||||
StemBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::stem_random_tick
|
||||
@@ -75,8 +75,8 @@ SuspiciousEffectHolder.tryGet port crates/horizon-items/src/flowers.rs::stew_eff
|
||||
TallSeagrassBlock.canSurvive port crates/horizon-world/src/level/survive.rs::Sim::can_survive_class
|
||||
TallSeagrassBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class
|
||||
TallSeagrassBlock.mayPlaceOn port crates/horizon-world/src/level/survive.rs::Sim::may_place_on
|
||||
TestBlock missing minor: game-test block for operators; Horizon keeps only its NBT
|
||||
TestInstanceBlock missing minor: game-test instance block; Horizon keeps only its NBT
|
||||
TestBlock skip:debug game-test tooling: an operator's test block runs a structure when triggered and Horizon keeps only its block entity NBT
|
||||
TestInstanceBlock skip:debug game-test tooling: the instance block runs and exports a test structure; Horizon keeps its NBT
|
||||
TintedParticleLeavesBlock.spawnFallingLeavesParticle port crates/horizon-client/src/animate_tick.rs::block_animate_tick falling tinted leaf particle
|
||||
TorchBlock.animateTick port crates/horizon-client/src/animate_tick.rs::block_animate_tick torch flame
|
||||
TorchflowerCropBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::random_tick_block growth arm
|
||||
@@ -100,9 +100,9 @@ WallHangingSignBlock.canAttachTo inline crates/horizon-world/src/level/place.rs:
|
||||
WallHangingSignBlock.canPlace port crates/horizon-world/src/level/place.rs::Sim::wall_hanging_sign_can_place
|
||||
WallHangingSignBlock.createBlockStateDefinition skip:data states come from the generated block state table
|
||||
WallHangingSignBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class
|
||||
WallHangingSignBlock.isHittingEditableSide missing unsure: player: a hanging sign clicked on its side may not chain onto a wall hanging sign
|
||||
WallHangingSignBlock.isHittingEditableSide inline crates/horizon-world/src/level/interact.rs::Behaviours::block_use_item_on the faces of the sign it faces and its opposite are the editable ones; a hanging sign aimed at any other face passes
|
||||
WallHangingSignBlock.rotate port crates/horizon-world/src/worldgen/structure.rs::StateTransforms::rotate
|
||||
WallHangingSignBlock.shouldTryToChainAnotherHangingSign missing unsure: player: a second hanging sign used on a wall sign may not chain on
|
||||
WallHangingSignBlock.shouldTryToChainAnotherHangingSign inline crates/horizon-world/src/level/interact.rs::Behaviours::block_use_item_on passing on a non-editable face lets the held hanging sign place its own block, which chains onto the wall sign
|
||||
WallHangingSignBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class
|
||||
WallHangingSignBlock.useItemOn port crates/horizon-world/src/level/interact.rs::Behaviours::use_item_on gap: player: chaining a hanging sign onto a wall hanging sign is not ported here
|
||||
WallSignBlock.canSurvive port crates/horizon-world/src/level/survive.rs::Sim::can_survive_class
|
||||
@@ -132,7 +132,7 @@ BannerPatternLayers.removeLast inline crates/horizon-client/src/cauldron.rs::int
|
||||
BannerPatternLayers.Builder skip:java builder for banner pattern layers
|
||||
BannerPatterns skip:data registry bootstrap
|
||||
BarrelBlockEntity.loadAdditional port crates/horizon-client-core/src/block_entity_nbt.rs::canonical gap: minor: canonical NBT form only, no load step of its own
|
||||
BarrelBlockEntity.recheckOpen missing unsure: player: a barrel lid may stay open after a player walks away; only golem openers are rechecked on a timer
|
||||
BarrelBlockEntity.recheckOpen missing the lid's periodic recheck of whether its viewers are still there; Horizon draws no barrel lid at all (containers.rs holds its 27 slots), so nothing rechecks one, where a golem's openers are rechecked on a timer
|
||||
BarrelBlockEntity.saveAdditional port crates/horizon-client-core/src/block_entity_nbt.rs::canonical gap: minor: canonical NBT form only
|
||||
BarrelBlockEntity.startOpen port crates/horizon-client/src/game/block_entity_anims.rs::Game::barrel_openers_changed
|
||||
BarrelBlockEntity.stopOpen port crates/horizon-client/src/game/block_entity_anims.rs::Game::barrel_openers_changed
|
||||
@@ -162,15 +162,15 @@ PotentSulfurBlockEntity.setLevel inline crates/horizon-client/src/game/potent_su
|
||||
PotentSulfurBlockEntity.spawnGeyserParticle port crates/horizon-client/src/game/potent_sulfur.rs::Game::spawn_geyser_particle ticked in #3516; `PotentSulfurBlockEntity.spawnGeyserParticle` is cited at crates/horizon-client/src/game/potent_sulfur.rs:380
|
||||
PotentSulfurBlockEntity.spawnNoxiousGasCloudParticle port crates/horizon-client/src/game/potent_sulfur.rs::Game::spawn_noxious_gas_cloud_particle ticked in #3516; `PotentSulfurBlockEntity.spawnNoxiousGasCloudParticle` is cited at crates/horizon-client/src/game/potent_sulfur.rs:395
|
||||
RandomizableContainerBlockEntity.applyImplicitComponents port crates/horizon-client/src/block_entity_item.rs::place the item's minecraft:container_loot hands the block its LootTable and LootTableSeed
|
||||
RandomizableContainerBlockEntity.canOpen missing unsure: minor: the spectator and loot-table open checks were not found
|
||||
RandomizableContainerBlockEntity.canOpen inline crates/horizon-client/src/game/advancements.rs::Game::container_locked_for the negative of it: a spectator opens any container, and otherwise the lock decides (LockCode.canUnlock)
|
||||
RandomizableContainerBlockEntity.collectImplicitComponents port crates/horizon-client/src/block_entity_item.rs::collect the block's LootTable and LootTableSeed leave on the item as minecraft:container_loot
|
||||
RandomizableContainerBlockEntity.createMenu port crates/horizon-client/src/game.rs::Game::open_menu gap: minor: the spectator and loot-table checks are not ported here
|
||||
SculkSensorBlockEntity.loadAdditional port crates/horizon-client-render/src/sculk.rs::SculkEntity::load
|
||||
SculkSensorBlockEntity.saveAdditional port crates/horizon-client-render/src/sculk.rs::SculkEntity::to_nbt
|
||||
SculkSensorBlockEntity.VibrationUser.canReceiveVibration port crates/horizon-client-render/src/sculk.rs::valid_vibration
|
||||
SculkSensorBlockEntity.VibrationUser.onReceiveVibration port crates/horizon-client-render/src/sculk.rs::Data::schedule
|
||||
TestBlockEntity missing minor: game-test block entity; reset, trigger and logging are not ported, NBT only
|
||||
TestInstanceBlockEntity missing minor: game-test structure tooling; export and chunk forcing are not ported
|
||||
TestBlockEntity skip:debug game-test tooling: reset, trigger and logging belong to the test runner, which Horizon does not have; its NBT is kept
|
||||
TestInstanceBlockEntity skip:debug game-test tooling: export and chunk forcing belong to the test runner, which Horizon does not have
|
||||
TrialSpawnerConfig.calculateTargetSimultaneousMobs port crates/horizon-client/src/mobs/trial_spawner.rs::TrialConfig::target_simultaneous_mobs
|
||||
TrialSpawnerConfig.calculateTargetTotalMobs port crates/horizon-client/src/mobs/trial_spawner.rs::TrialConfig::target_total_mobs
|
||||
TrialSpawnerConfig.withSpawning skip:java builder-style setter on the config record
|
||||
@@ -204,7 +204,7 @@ ImposterProtoChunk skip:other wrapper so the vanilla status pipeline can read a
|
||||
LinearPalette skip:java palette storage; Horizon's chunk.rs keeps block ids in plain arrays
|
||||
PalettedContainerFactory skip:java factory for palette storage; Horizon's chunk.rs keeps block ids in plain arrays
|
||||
SingleValuePalette skip:java palette storage; Horizon's chunk.rs keeps block ids in plain arrays
|
||||
UpgradeData missing unsure: minor: block upgrades of old chunks on load are not ported
|
||||
UpgradeData missing the block upgrades an old chunk takes when it loads (the Sides bits a save carries); the datafix writes and keeps UpgradeData (datafix/chunk_leaves.rs) but no load path applies the upgrades
|
||||
ChunkDependencies.getRadiusOf inline crates/horizon-client-render/src/streaming.rs::Ranges::margins fixed margins instead of a per-status radius
|
||||
ChunkPyramid.Builder skip:java builder for the status pyramid; Horizon uses fixed margins
|
||||
ChunkStatusTasks.buildTerrain inline crates/horizon-world/src/worldgen/generator.rs::Pipeline::generate_with_context terrain pass
|
||||
|
||||
|
@@ -3,7 +3,8 @@ SetWritableBookPagesFunction missing `horizon-world/src/loot.rs::modify` has no
|
||||
SetWrittenBookPagesFunction missing no `set_written_book_pages` arm in `loot.rs::modify` (the arms run from `sequence` and `set_count` through `set_potion` to `set_stew_effect`); no vanilla loot table asks for one
|
||||
SmeltItemFunction.run port crates/horizon-world/src/loot.rs::Context::run furnace_smelt arm: smelting recipe, use_input_count and stack cap
|
||||
SmeltItemFunction.smelted skip:java static builder factory
|
||||
ToggleTooltips home crates/horizon-client/src/game/commands/items.rs the `/item modify` arm toggle_tooltips writes the tooltip_display component's hide_tooltip and hidden_components as run does; the loot run has no arm, and no vanilla loot table uses it
|
||||
ToggleTooltips home crates/horizon-client/src/game/commands/items.rs the `/item modify` arm of cmd_item_modify writes the tooltip_display component's hide_tooltip and hidden_components as run does; the loot run has no arm, and no vanilla loot table uses it
|
||||
ToggleTooltips.run inline crates/horizon-client/src/game/commands/items.rs::Game::cmd_item_modify the `minecraft:toggle_tooltips` arm of the `/item modify` command adds each false toggle to `tooltip_display`'s hidden_components and keeps hide_tooltip; the loot run has no arm, so a loot table's toggle_tooltips is ignored (no vanilla loot table uses one)
|
||||
LootContextParamSets skip:data loot parameter set registry and validation; Horizon reads its loot tables
|
||||
AllOfCondition.allOf skip:java static builder
|
||||
AllOfCondition.combine inline crates/horizon-items/src/items.rs::LootContext::condition all_of arm: every term must hold
|
||||
|
||||
|
@@ -19,7 +19,7 @@ MobEffectsPredicate.matches inline crates/horizon-client/src/advancements.rs::en
|
||||
MobEffectsPredicate.Builder.and skip:java builder for effect predicates in code
|
||||
MobEffectsPredicate.MobEffectInstancePredicate.matches inline crates/horizon-client/src/advancements.rs::entity gap: minor: ambient and visible flags are not checked; amplifier and duration are
|
||||
NbtPredicate.matches missing minor: custom_data and entity NBT predicates are not read; no vanilla advancement uses them
|
||||
SingleComponentItemPredicate.matches inline crates/horizon-client/src/advancements.rs::item gap: minor: only enchantments, damage and potion contents are read; other components are ignored
|
||||
SingleComponentItemPredicate.matches inline crates/horizon-client/src/advancements.rs::item gap: minor: enchantments, damage and potion contents are read from components and predicates; adventure/play_jukebox_in_meadows asks for minecraft:jukebox_playable, which is ignored, so any tool passes that match_tool test
|
||||
SlotsPredicate.matches missing minor: slot-range item test is not read; no vanilla advancement uses it
|
||||
SlotsPredicate.matchSlots missing minor: helper of SlotsPredicate.matches; not read
|
||||
StatePropertiesPredicate.matches port crates/horizon-client/src/advancements.rs::block the state property test of block()
|
||||
@@ -32,8 +32,8 @@ StatePropertiesPredicate.RangedMatcher.match inline crates/horizon-client/src/ad
|
||||
TagPredicate.codec skip:java codec
|
||||
TagPredicate.is skip:java builder
|
||||
TagPredicate.isNot skip:java builder
|
||||
TagPredicate.matches inline crates/horizon-client/src/advancements.rs::id_matches gap: minor: the #tag form is read; the negated expected=false form is not kept
|
||||
CubeMobPredicate.matches missing minor: type_specific/cube_mob (slime size) is not read; no vanilla advancement uses it
|
||||
TagPredicate.matches inline crates/horizon-client/src/advancements.rs::damage_source each of the damage type's tags compared against the predicate's expected flag; DamageSourcePredicate.matches is the only caller (adventure/blowback asks for #minecraft:is_projectile with expected true)
|
||||
CubeMobPredicate.matches inline crates/horizon-world/src/loot.rs::Context::entity_matches the loot reader's minecraft:type_specific/cube_mob arm, the size bounds against the mob's own size; entity() in advancements.rs has no arm for it and answers false, so an advancement asking for one never matches
|
||||
DistanceToPlayerPredicate.matches port crates/horizon-client/src/advancements.rs::distance the distance key of entity(): from the player to the entity
|
||||
EntityEquipmentPredicate.captainPredicate skip:data datagen builder for the raid captain predicate (VanillaAdventureAdvancements)
|
||||
EntityEquipmentPredicate.matches inline crates/horizon-client/src/advancements.rs::entity the equipment key: the seven slots
|
||||
@@ -55,7 +55,7 @@ EntityTagPredicate.containsAllOf missing minor: helper of entity_tags; not read
|
||||
EntityTypePredicate.of skip:java builder from an entity type or tag
|
||||
EntityTypePredicate.matches port crates/horizon-client/src/advancements.rs::id_matches the type key of entity(): an id or #tag
|
||||
FishingHookPredicate.matches inline crates/horizon-client/src/advancements.rs::entity the type_specific/fishing_hook arm: open-water test
|
||||
LightningBoltPredicate.matches inline crates/horizon-client/src/advancements.rs::entity gap: minor: blocks_set_on_fire is checked against 0, not the bolt's own count; the lightning trigger never fires
|
||||
LightningBoltPredicate.matches inline crates/horizon-client/src/advancements.rs::entity the minecraft:type_specific/lightning arm: blocks_set_on_fire against the bolt's own count (game/lightning.rs fills it from the fires the bolt set) and entity_struck against the mobs around it
|
||||
MovementAffectedByPredicate.matches missing minor: movement_affected_by key is not read; no vanilla advancement uses it
|
||||
MovementPredicate.matches missing minor: movement key (speed, fall distance) is not read; no vanilla advancement uses it
|
||||
PassengerPredicate.matches inline crates/horizon-client/src/advancements.rs::entity the `passenger` key: any passenger of the vehicle matches the nested predicate
|
||||
@@ -66,8 +66,8 @@ PlayerPredicate.Builder.checkAdvancementDone skip:java builder
|
||||
PlayerPredicate.Builder.checkAdvancementCriterions skip:java builder
|
||||
PlayerPredicate.Builder.build skip:java builder
|
||||
PlayerPredicate.StatMatcher.createTypedCodec skip:java codec
|
||||
RaiderPredicate.matches missing minor: type_specific/raider is not read in entity(); no vanilla advancement uses it
|
||||
SheepPredicate.matches missing minor: type_specific/sheep is not read; no vanilla advancement uses it
|
||||
RaiderPredicate.matches inline crates/horizon-world/src/loot.rs::Context::entity_matches the loot reader's minecraft:type_specific/raider arm: captain matches, and has_raid matches no raid running here; the advancement reader answers false for the key
|
||||
SheepPredicate.matches inline crates/horizon-world/src/loot.rs::Context::entity_matches the loot reader's minecraft:type_specific/sheep arm, the sheared flag; the advancement reader answers false for the key
|
||||
SteppingOnPredicate.matches inline crates/horizon-client/src/advancements.rs::entity gap: minor: no on-ground test; the block is read 0.5 below the feet
|
||||
TargetedEntityPredicate.matches missing minor: targeted_entity key is not read; no vanilla advancement uses it
|
||||
TeamPredicate.matches missing minor: team key is not read though scoreboard teams exist; no vanilla advancement uses it
|
||||
|
||||
|
@@ -93,16 +93,16 @@ ClientboundTakeItemEntityPacket.write port crates/horizon-protocol/src/buf.rs::w
|
||||
DebugEntityNameGenerator.getEntityName skip:debug debug name for entities
|
||||
ServerboundBlockEntityTagQueryPacket.write port crates/horizon-protocol/src/buf.rs::write packet! BlockEntityTagQuery {transaction VarInt, pos Pos}
|
||||
ServerboundEntityTagQueryPacket.write port crates/horizon-protocol/src/buf.rs::write packet! EntityTagQuery {transaction VarInt, entity VarInt}
|
||||
ServerboundJigsawGeneratePacket.write missing minor: no jigsaw generate packet; the client has no jigsaw screen (only block_entity_nbt reads jigsaw data)
|
||||
ServerboundJigsawGeneratePacket.write port crates/horizon-protocol/src/buf.rs::write packet! JigsawGenerate {pos Pos, levels VarInt, keep_jigsaws Bool}; the jigsaw screen's Generate button builds it (jigsaw_edit.rs::JigsawEditor::generate_packet)
|
||||
ServerboundMovePlayerPacket.packFlags inline crates/horizon-client/src/player_sync.rs::PositionSender::update on-ground and horizontal-collision bits set when the move is sent
|
||||
ServerboundPlayerActionPacket.write port crates/horizon-protocol/src/buf.rs::write packet! PlayerAction {action VarInt, pos, direction UByte, sequence VarInt}
|
||||
ServerboundPlayerCommandPacket.write port crates/horizon-protocol/src/buf.rs::write packet! PlayerCommand {id, action, data VarInt}
|
||||
ServerboundSeenAdvancementsPacket.write missing minor: the advancement tab the player opened is not sent; Horizon has no seen_advancements packet (ids.rs only)
|
||||
ServerboundSetCommandMinecartPacket.write missing minor: minecart command block edits are not sent; Horizon has no such packet (ids.rs only)
|
||||
ServerboundSetCommandMinecartPacket.getCommandBlock missing minor: minecart command block edits are not sent; Horizon has no such packet (ids.rs only)
|
||||
ServerboundSetJigsawBlockPacket.write missing minor: jigsaw block edits are not sent; no packet and no jigsaw screen in the client
|
||||
ServerboundSetStructureBlockPacket.<init> missing minor: structure block settings are not sent; no packet and no structure block screen in the client
|
||||
ServerboundSetStructureBlockPacket.write missing minor: structure block settings are not sent; no packet and no structure block screen in the client
|
||||
ServerboundSetCommandMinecartPacket.write port crates/horizon-protocol/src/buf.rs::write packet! SetCommandMinecart {entity VarInt, command Str, track_output Bool}; the cart's edit screen sends it (command_minecarts.rs::Game::set_command_minecart)
|
||||
ServerboundSetCommandMinecartPacket.getCommandBlock inline crates/horizon-client/src/game/command_minecarts.rs::Game::apply_cart_command the packet's command and track-output flag go into the cart, with handleSetCommandMinecart's onUpdated and chat note, in one fn
|
||||
ServerboundSetJigsawBlockPacket.write port crates/horizon-protocol/src/buf.rs::write packet! SetJigsawBlock {pos Pos, name/target/pool Ident, final_state Str, joint Str, selection and placement priority VarInt}; the jigsaw screen sends it (jigsaw_edit.rs::JigsawEditor::set_packet)
|
||||
ServerboundSetStructureBlockPacket.<init> port crates/horizon-client/src/game/structure_edit.rs::StructureEditor::packet the constructor takes the block and the update type: the edit boxes supply the typed fields, the block its mode, mirror, rotation and flags
|
||||
ServerboundSetStructureBlockPacket.write port crates/horizon-protocol/src/packets/play.rs::SetStructureBlock::write pos, update type and mode, the name, each offset and size as a byte, mirror and rotation, data, integrity, seed, then the four flags in one byte
|
||||
VecDelta.Stepped.hasDeltaX skip:other no callers in vanilla
|
||||
VecDelta.Stepped.hasDeltaZ skip:other no callers in vanilla
|
||||
VecDelta.Stepped.tryEncode missing minor: the LAN host sends only linear deltas (send_changes); no stepped path is encoded
|
||||
|
||||
|
@@ -2,9 +2,9 @@ LinkFSPath skip:other Java path of the in-memory link filesystem; Horizon links
|
||||
LinkFSProvider skip:other Java NIO provider of the link filesystem; Horizon reads host folders and zips directly
|
||||
LinkFileSystem skip:other link filesystem and its getPath; Horizon builds overlay folders instead (packs.rs::build_overlay)
|
||||
LinkFileSystem.Builder skip:other builder of the link filesystem tree; no equivalent, see packs.rs::build_overlay
|
||||
PackFormat.fullCodec skip:java codec for supported_formats; packs.rs::compatible reads the same array
|
||||
PackFormat.fullCodec port crates/horizon-client-render/src/packs.rs::format_codec supported_formats / pack_format as an int, or a list of 1 to 256 non-negative ints whose second is the minor
|
||||
PackFormat.validateHolderList inline crates/horizon-client-render/src/packs.rs::overlays_of gap: minor: a broken overlay entry is dropped alone; vanilla rejects the whole overlays list
|
||||
PackFormat.lastPreMinorVersion inline crates/horizon-client-render/src/packs.rs::last_pre_minor the cut-off the old-format validator compares against
|
||||
PackFormat.lastPreMinorVersion port crates/horizon-client-render/src/packs.rs::last_pre_minor the last major without a minor version, 81 for data packs and 64 for resource packs, which the old-format validator compares against
|
||||
PackFormat.packCodec skip:java codec wiring; the validation it runs is missing (IntermediaryFormat.validate)
|
||||
PackFormat.of skip:java constructor; Horizon keeps (major, minor) tuples (packs.rs::RESOURCE_FORMAT)
|
||||
PackFormat.compareTo skip:java ordering; Horizon compares the (major, minor) tuples directly (packs.rs::compatibility)
|
||||
@@ -91,6 +91,7 @@ PermissionSetUnion.hasPermission skip:other no unions; a sender has one level
|
||||
PermissionSetUnion.union skip:other no unions; a sender has one level
|
||||
PermissionSetUnion.ensureNoUnionsWithinUnions skip:java constructor guard against nested unions
|
||||
PermissionTypes.bootstrap skip:data registry bootstrap of the permission codecs
|
||||
BanListEntry.<init> port crates/horizon-server-files/src/user_lists.rs::BanEntry::new both vanilla constructors land here: a missing source becomes (Unknown); gap: minor: created is stamped with now instead of being passed, and a file's stored created date is kept by fields() through from_json
|
||||
BanListEntry.equals port crates/horizon-server-files/src/user_lists.rs::BanEntry::eq same source, expiry, reason and user
|
||||
BanListEntry.hashCode skip:java hash for map keys; bans are keyed by uuid or ip string
|
||||
CachedUserNameToIdResolver.lookupGameProfile port crates/horizon-server-files/src/user_cache.rs::UserCache::get_by_name cache first, then the profile lookup, then offline if allowed
|
||||
|
||||
|
+17
-17
@@ -28,8 +28,8 @@ CrudeIncrementalIntIdentityHashBiMap.copy skip:java identity bimap over registr
|
||||
Crypt.generateKeyPair port crates/horizon-protocol/src/rsa.rs::RsaPrivateKey::generate RSA key pair with the bit size passed in; vanilla passes 1024
|
||||
Crypt.digestData port crates/horizon-protocol/src/crypto.rs::server_hash returns the signed hex string; vanilla returns bytes and the caller converts them
|
||||
Crypt.rsaStringToKey port crates/horizon-client-render/src/auth.rs::pem_der the PEM armour around the services' keys
|
||||
Crypt.rsaPublicKeyToString missing minor: PEM writer for the account chat public key; Horizon has no PEM writer
|
||||
Crypt.pemRsaPrivateKeyToString missing minor: PEM writer for the account chat private key; Horizon has no PEM writer
|
||||
Crypt.rsaPublicKeyToString missing the writer half of the key-pair codec Crypt's static initialiser builds (its only caller); Horizon reads a PEM key pair (auth.rs::pem_der, parse_certificates) and writes none
|
||||
Crypt.pemRsaPrivateKeyToString missing the writer half of the key-pair codec Crypt's static initialiser builds (its only caller); Horizon reads a PEM key pair (auth.rs::pem_der, parse_certificates) and writes none
|
||||
Crypt.byteToPrivateKey port crates/horizon-protocol/src/rsa.rs::RsaPrivateKey::from_der the account chat key pair's PEM key is decoded and read as PKCS8EncodedKeySpec (a bare PKCS#1 key too) in auth.rs::parse_certificates
|
||||
Crypt.byteToPublicKey port crates/horizon-protocol/src/rsa.rs::RsaPublicKey::from_der X.509 SubjectPublicKeyInfo decode
|
||||
Crypt.decryptByteToSecretKey port crates/horizon-protocol/src/rsa.rs::RsaPrivateKey::decrypt RSA PKCS#1 v1.5 decrypt of the shared secret; the bytes are used as the AES key as given
|
||||
@@ -63,17 +63,17 @@ DependencySorter.addDependencyIfNotCyclic inline crates/horizon-world/src/worldg
|
||||
DependencySorter.orderByDependencies inline crates/horizon-world/src/worldgen/tags.rs::TagDefinitions::build required edges first, then optional ones, then the visit order
|
||||
DirectoryLock.create port crates/horizon-client/src/save.rs::SessionLock::acquire exclusive file lock on session.lock with the two-byte snowman content
|
||||
DirectoryLock.isLocked port crates/horizon-client/src/save.rs::SessionLock::is_locked trying for the world's session.lock tells a locked world; title.rs uses it to flag the entry
|
||||
Ease.inBack missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inElastic missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inExpo missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inOutBounce missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inOutCirc missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inOutQuad missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inOutQuart missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inOutQuint missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.outBounce missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.outElastic missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.outExpo missing minor: named easing reached only through the EasingType registry; vanilla timeline data uses cubic_bezier and constant only
|
||||
Ease.inBack missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inElastic missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inExpo missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inOutBounce missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inOutCirc missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inOutQuad missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inOutQuart missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.inOutQuint missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.outBounce missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.outElastic missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
Ease.outExpo missing a named easing only a datapack's timeline can reach: the four shipped timelines ask for constant, cubic_bezier or no ease, and environment.rs::Environment::load_dimension reads those two
|
||||
EasingType.cubicBezier inline crates/horizon-client-render/src/environment.rs::Environment::load_dimension reads the four control points of a cubic_bezier ease; gap: minor: no range check, see CubicBezierControls.validate
|
||||
EasingType.CubicBezier.curveFromControls inline crates/horizon-client-render/src/environment.rs::Easing::apply the curve coefficients closure
|
||||
EasingType.CubicBezier.solveT inline crates/horizon-client-render/src/environment.rs::Easing::apply Newton steps with the 1e-5 tolerance
|
||||
@@ -82,7 +82,7 @@ EasingType.CubicBezier.equals skip:java value equality
|
||||
EasingType.CubicBezier.toString skip:debug debug string
|
||||
EasingType.CubicBezier.CubicCurve.sample inline crates/horizon-client-render/src/environment.rs::Easing::apply the sample closure
|
||||
EasingType.CubicBezier.CubicCurve.sampleGradient inline crates/horizon-client-render/src/environment.rs::Easing::apply the gradient closure
|
||||
EasingType.CubicBezierControls.validate missing minor: vanilla rejects control points outside [0;1] when a timeline loads; Horizon takes any value
|
||||
EasingType.CubicBezierControls.validate missing vanilla rejects control points outside [0;1] when a timeline loads; environment.rs::Environment::load_dimension takes the four numbers as given
|
||||
EncoderCache.Key.equals skip:java cache key equality
|
||||
EncoderCache.Key.hashCode skip:java cache key hashing
|
||||
ExceptionCollector.add skip:java suppressed exception collector
|
||||
@@ -117,7 +117,7 @@ FastBufferedInputStream.skip skip:java buffered stream helper; Rust readers do
|
||||
FastBufferedInputStream.fill skip:java buffered stream helper; Rust readers do their own buffering
|
||||
FileSystemUtil.safeGetPath skip:other opens a jar or zip filesystem for a URI; Horizon reads archives with the zip crate
|
||||
FileUtil.sanitizeName port crates/horizon-client/src/title.rs::Title::target_folder gap: minor: replaces every non-alphanumeric character with _; vanilla only replaces illegal file characters, dots, slashes and quotes
|
||||
FileUtil.isPathPortable skip:other portable path check; no caller in the 26.3 source
|
||||
FileUtil.isPathPortable port crates/horizon-client/src/game/structure_blocks.rs::portable_path the structure path's check: no empty, `.` or `..` segment; vanilla also tests the part's characters (isPathPartPortable)
|
||||
FileUtil.decomposePath skip:data resource path handling; Horizon reads its data from tables and files
|
||||
FileUtil.resolvePath skip:data resource path handling; Horizon reads its data from tables and files
|
||||
FileUtil.isValidPathSegment skip:data resource path handling; Horizon reads its data from tables and files
|
||||
@@ -127,8 +127,8 @@ FileUtil.isEmptyPath skip:data resource path handling; Horizon reads its data f
|
||||
FileZipper.add skip:debug profiling report archive (Minecraft.archiveProfilingReport, PerfCommand); Horizon writes no zip report
|
||||
FileZipper.close skip:debug profiling report archive (Minecraft.archiveProfilingReport, PerfCommand); Horizon writes no zip report
|
||||
FormattedCharSequence.forward port crates/horizon-client-core/src/text.rs::Text::styled styled text object; the per-character walk is Text::chars
|
||||
FormattedCharSequence.backward skip:other two-argument reverse walk; no caller in the 26.3 source (SubStringSource uses the three-argument form)
|
||||
FormattedCharSequence.decorateOutput missing minor: char-code modifier for reversed text, used only for right-to-left languages; Horizon has no right-to-left layout
|
||||
FormattedCharSequence.backward skip:other the reverse walk SubStringSource.substring does, which is skip:ui (the HUD draws each span with its own style)
|
||||
FormattedCharSequence.decorateOutput inline crates/horizon-client-render/src/bidi.rs::reorder the mirror a right-to-left character is drawn as; vanilla's sink mirrors every code point when the language reads right to left, here only the code points in a right-to-left run
|
||||
FormattedCharSequence.composite port crates/horizon-client-core/src/text.rs::Text::empty zero parts give the empty sequence
|
||||
FormattedCharSequence.fromPair port crates/horizon-client-core/src/text.rs::Text::append concatenation of two sequences
|
||||
FutureChain.append skip:other ordering helper for the async chat-signature checks in ServerGamePacketListenerImpl; a scheduling wrapper, no game behaviour of its own
|
||||
|
||||
|
@@ -77,6 +77,20 @@ def mobs(ox, oz):
|
||||
return out
|
||||
|
||||
|
||||
def glowing(ox, oz):
|
||||
"""1000 glowing mobs (the outline post effect): the mobs scene's kinds and grid."""
|
||||
out, i = [], 0
|
||||
for layer in range(5):
|
||||
for row in range(10):
|
||||
for col in range(20):
|
||||
kind = MOBS[i % len(MOBS)]
|
||||
x, y, z = ox + 0.5 - 14.25 + 1.5 * col, FLOOR_Y + 1 + 2.5 * layer, oz + 8 + 2 * row
|
||||
out.append(f"summon {kind} {num(x)} {num(y)} {num(z)} "
|
||||
"{NoAI:1b,NoGravity:1b,PersistenceRequired:1b,Silent:1b,Glowing:1b}")
|
||||
i += 1
|
||||
return out
|
||||
|
||||
|
||||
def eyes(ox, oz):
|
||||
"""1000 mobs with glowing eyes (spiders, cave spiders, endermen): the mobs scene's grid."""
|
||||
out, i = [], 0
|
||||
@@ -207,6 +221,7 @@ STRESS = [
|
||||
("stress_particles", particles),
|
||||
("stress_held", held),
|
||||
("stress_eyes", eyes),
|
||||
("stress_glowing", glowing),
|
||||
]
|
||||
NAMES = [name for name, _ in STRESS]
|
||||
|
||||
|
||||
@@ -68,6 +68,14 @@ def scenarios():
|
||||
add('skeleton_day_burn', 120, ['mob minecraft:skeleton 0.5 -60 0.5 0 32 persistent'])
|
||||
# NoAI only skips serverAiStep: Mob.aiStep's burnUndead still sets a NoAI zombie alight (#3430).
|
||||
add('zombie_noai_day_burn', 120, ['mob minecraft:zombie 0.5 -60 0.5 0 33 persistent noai'])
|
||||
# AgeableMob.aiStep runs with NoAI, including both signs and age-lock controls.
|
||||
for species in ('pig', 'cow', 'horse', 'turtle'):
|
||||
for age in (-2, 2):
|
||||
for noai in (False, True):
|
||||
for locked in (False, True):
|
||||
flags = (' noai' if noai else '') + (' agelocked' if locked else '')
|
||||
add(f'{species}_age_{age}_noai_{noai}_locked_{locked}', 4,
|
||||
[f'mob minecraft:{species} 0.5 -60 0.5 0 34 persistent age={age}{flags}'])
|
||||
# The wither (summoned, not built: no invulnerable spawn phase).
|
||||
add('wither_player', 200, ['player 10.5 -60 0.5', 'mob minecraft:wither 0.5 -58 0.5 0 41 persistent'])
|
||||
# A villager with a job site, a bed and a bell.
|
||||
|
||||
+13
-6
@@ -1671,16 +1671,23 @@ mod tests {
|
||||
"native_item_damaged: the let _ = (…); placeholder needs its dispatch call; run cargo run -p xtask -- mod-api"
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&hooks).unwrap(), placeholder);
|
||||
let fragment = root.join("tools/native-hook-fragments/native_item_damaged.json");
|
||||
let shipped_fragment = std::fs::read_to_string(&fragment).unwrap();
|
||||
let mut json: Value = serde_json::from_str(&shipped_fragment).unwrap();
|
||||
json["hooks"][0].as_object_mut().unwrap().remove("mod_api");
|
||||
std::fs::write(&fragment, json.to_string()).unwrap();
|
||||
// A stamped hook lives in the catalog, not in a fragment.
|
||||
let catalog_path = root.join("native-hooks.json");
|
||||
let shipped_catalog = std::fs::read_to_string(&catalog_path).unwrap();
|
||||
let mut json: Value = serde_json::from_str(&shipped_catalog).unwrap();
|
||||
let hook = json["hooks"]
|
||||
.as_array_mut()
|
||||
.unwrap()
|
||||
.iter_mut()
|
||||
.find(|hook| hook["name"] == "item_damaged")
|
||||
.unwrap();
|
||||
hook.as_object_mut().unwrap().remove("mod_api").unwrap();
|
||||
std::fs::write(&catalog_path, json.to_string()).unwrap();
|
||||
assert_eq!(
|
||||
super::generate(&root, false).unwrap_err(),
|
||||
"hook item_damaged has no mod_api entry: add one"
|
||||
);
|
||||
std::fs::write(&fragment, shipped_fragment).unwrap();
|
||||
std::fs::write(&catalog_path, shipped_catalog).unwrap();
|
||||
super::generate(&root, false).unwrap();
|
||||
// The generator writes LF; a Windows checkout (core.autocrlf) has CRLF.
|
||||
let lf = |s: &str| s.replace("\r\n", "\n");
|
||||
|
||||
Reference in new issue
Block a user