mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Refresh H313 against current mob and UI changes
Upstream changes add frog travel, player equipment visibility and UI/server work; the NoAI pufferfish caller and sting handler are unchanged. Preserve all fixture options and qualify the captured runtime pairs to d158e4473.
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
commit
0fcaef94e3
128 files changed
+8515
-427
No files matched your search
@@ -407,6 +407,16 @@ impl BlockEntities {
|
||||
self.add(pos);
|
||||
}
|
||||
}
|
||||
/// [`Self::retain`] for unloading chunks: removes the block entities at
|
||||
/// the positions `gone` names and changes none it keeps, so those that
|
||||
/// sleep stay asleep (a moving player unloads chunks most ticks).
|
||||
pub fn unload(&mut self, mut gone: impl FnMut(&Pos) -> bool) {
|
||||
let asleep = std::mem::take(&mut self.asleep);
|
||||
self.retain(|p, _| !gone(p));
|
||||
self.asleep = asleep;
|
||||
let map = &self.map;
|
||||
self.asleep.retain(|p| map.contains_key(p));
|
||||
}
|
||||
/// Removes the block entities `keep` refuses, and their slots
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
@@ -558,6 +568,24 @@ mod tests {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Unloading a chunk removes its block entities and leaves the others'
|
||||
/// sleep alone; `retain`, which may change what it keeps, wakes them all.
|
||||
#[test]
|
||||
fn unloading_a_chunk_keeps_the_others_asleep() {
|
||||
let mut b = level();
|
||||
let (kept, gone) = ((1, 64, 1), (17, 64, 1));
|
||||
b.insert(kept, hopper());
|
||||
b.insert(gone, hopper());
|
||||
b.sleep(kept);
|
||||
b.sleep(gone);
|
||||
b.unload(|p| p.0 >> 4 == 1);
|
||||
assert!(b.get(&gone).is_none());
|
||||
assert!(!b.is_asleep(&gone));
|
||||
assert!(b.is_asleep(&kept));
|
||||
b.retain(|_, _| true);
|
||||
assert!(!b.is_asleep(&kept));
|
||||
}
|
||||
|
||||
fn near(b: &BlockEntities, x: f64, z: f64, radius: f64) -> Vec<Pos> {
|
||||
let mut found: Vec<Pos> = b.mods_near(x, z, radius).copied().collect();
|
||||
found.sort();
|
||||
|
||||
@@ -487,6 +487,63 @@ mod tests {
|
||||
assert!(!stand.idle());
|
||||
}
|
||||
|
||||
/// The stand sleeps while `idle`, so its tick must change nothing then: run the real
|
||||
/// tick on idle stands and compare, and check a stand with fuel to take wakes.
|
||||
#[test]
|
||||
fn an_idle_brewing_stand_tick_changes_nothing() {
|
||||
let items = Items::for_tests(std::collections::HashMap::new());
|
||||
let brewing = Brewing {
|
||||
reagents: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
inputs: HashSet::new(),
|
||||
recipes: vec![BrewingRecipe {
|
||||
id: "awkward".into(),
|
||||
input: PotionIngredient {
|
||||
items: ["minecraft:potion".to_owned()].into_iter().collect(),
|
||||
potions: Some(["minecraft:water".to_owned()].into_iter().collect()),
|
||||
},
|
||||
reagent: PotionIngredient {
|
||||
items: ["minecraft:nether_wart".to_owned()].into_iter().collect(),
|
||||
potions: None,
|
||||
},
|
||||
output: ItemStack::new("minecraft:potion", 1).with_potion("minecraft:awkward"),
|
||||
}],
|
||||
};
|
||||
let providers = Providers::empty();
|
||||
let water = || Some(ItemStack::new("minecraft:potion", 1).with_potion("minecraft:water"));
|
||||
let stand = |bottle: bool, fuel: i32, powder: bool| {
|
||||
let mut s = BrewingStand::new();
|
||||
s.fuel = fuel;
|
||||
if bottle {
|
||||
s.items[1] = water();
|
||||
}
|
||||
if powder {
|
||||
s.items[4] = Some(ItemStack::new("minecraft:blaze_powder", 1));
|
||||
}
|
||||
s.last_bottles = Some([false, bottle, false]);
|
||||
s
|
||||
};
|
||||
for before in [
|
||||
stand(false, 0, false),
|
||||
stand(true, 0, false),
|
||||
stand(true, 5, false),
|
||||
stand(true, 5, true),
|
||||
] {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
assert_eq!(
|
||||
after.tick(&brewing, &items, &providers),
|
||||
BrewTick::default()
|
||||
);
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
// A brewable ingredient starts the brew: not idle, and the tick changes it.
|
||||
let mut brewing_now = stand(true, 5, false);
|
||||
brewing_now.items[3] = Some(ItemStack::new("minecraft:nether_wart", 1));
|
||||
assert!(!brewing_now.idle());
|
||||
brewing_now.tick(&brewing, &items, &providers);
|
||||
assert_eq!(brewing_now.brew_time, 400);
|
||||
}
|
||||
|
||||
/// #3312: `PotionContents.getPotionDescription` names the effects with
|
||||
/// translatable components, so a Russian game reads them from ru_ru.json
|
||||
/// (the prepared one; skipped without it), as the tooltip's other lines.
|
||||
|
||||
@@ -749,6 +749,43 @@ mod tests {
|
||||
assert!(c.idle(false));
|
||||
}
|
||||
|
||||
/// The campfire sleeps while `idle`, so the tick its block runs (`cook_tick` lit,
|
||||
/// `cooldown_tick` unlit) must change nothing then, and must change an awake one.
|
||||
#[test]
|
||||
fn an_idle_campfire_tick_changes_nothing() {
|
||||
let tick = |c: &mut Campfire, lit: bool| {
|
||||
if lit {
|
||||
!c.cook_tick().is_empty()
|
||||
} else {
|
||||
c.cooldown_tick()
|
||||
}
|
||||
};
|
||||
let empty = Campfire::default();
|
||||
let mut food = Campfire::default();
|
||||
assert!(food.place_food(&mut ItemStack::new("minecraft:beef", 1), 600));
|
||||
let mut warm = food.clone();
|
||||
warm.cook_tick();
|
||||
let mut cooled = warm.clone();
|
||||
while cooled.cooldown_tick() {}
|
||||
for (before, lit) in [
|
||||
(&empty, true),
|
||||
(&empty, false),
|
||||
(&food, false),
|
||||
(&cooled, false),
|
||||
] {
|
||||
assert!(before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
assert!(!tick(&mut after, lit));
|
||||
assert_eq!(&after, before, "lit {lit}");
|
||||
}
|
||||
for (before, lit) in [(&food, true), (&warm, false)] {
|
||||
assert!(!before.idle(lit), "{before:?} lit {lit}");
|
||||
let mut after = before.clone();
|
||||
tick(&mut after, lit);
|
||||
assert_ne!(&after, before, "lit {lit}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campfire_slots_take_food_cook_it_and_cool_down() {
|
||||
let mut c = Campfire::default();
|
||||
|
||||
@@ -50,11 +50,13 @@ mod inter_stage_tests;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_gpu;
|
||||
mod weather;
|
||||
mod world_text;
|
||||
use entity_batch::EntityBatch;
|
||||
pub use entity_batch::{
|
||||
DISSOLVE_OVERLAY, DRAGON_RAYS_OVERLAY, ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY, TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, mark_oit,
|
||||
TRANSLUCENT_PARTICLE_OVERLAY, WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY, mark_oit,
|
||||
};
|
||||
pub use view::{View, ViewSet};
|
||||
|
||||
@@ -1167,6 +1169,7 @@ pub struct Renderer {
|
||||
/// Native mods' world primitives (`Frame::world_primitives`).
|
||||
world_primitives: world_primitives::WorldPrimitives,
|
||||
text_polygon_offset_pipeline: wgpu::RenderPipeline,
|
||||
world_text_pipeline: wgpu::RenderPipeline,
|
||||
celestial_pipeline: wgpu::RenderPipeline,
|
||||
celestial_buffer: wgpu::Buffer,
|
||||
end_sky: end_sky::EndSky,
|
||||
@@ -1391,6 +1394,8 @@ pub struct Renderer {
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
pub separate_ao: bool,
|
||||
/// Lava's sprite layers, which a pack's solid terrain draws (`pack_atlas::terrain_parts`).
|
||||
pack_lava_layers: Vec<u16>,
|
||||
pub hud: HudRenderer,
|
||||
/// Vanilla's menu background blur, made on first use.
|
||||
menu_blur: Option<crate::menu_blur::MenuBlur>,
|
||||
@@ -3731,6 +3736,12 @@ impl Renderer {
|
||||
alpha_blend,
|
||||
false,
|
||||
);
|
||||
let world_text_pipeline = world_text::pipeline(
|
||||
&device,
|
||||
Some(&entity_layout),
|
||||
&entity_shader,
|
||||
config.format.remove_srgb_suffix(),
|
||||
);
|
||||
// RenderPipelines.ENTITY_SOLID: a surface of an entity that is coplanar with
|
||||
// terrain wins the tie in vanilla (the boat's floor over the trapdoor on the
|
||||
// ground it is stopped by, #5950). Terrain and entities are separate draw
|
||||
@@ -4553,6 +4564,7 @@ impl Renderer {
|
||||
see_through_pipeline,
|
||||
world_primitives,
|
||||
text_polygon_offset_pipeline,
|
||||
world_text_pipeline,
|
||||
celestial_pipeline,
|
||||
celestial_buffer,
|
||||
end_sky: end_sky::EndSky::default(),
|
||||
@@ -4682,6 +4694,7 @@ impl Renderer {
|
||||
pack_material_atlases: None,
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
pack_lava_layers: Vec::new(),
|
||||
hud,
|
||||
menu_blur: None,
|
||||
post_effects: None,
|
||||
@@ -6395,6 +6408,10 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Lava's sprite layers (still and flowing), whose faces a pack's solid terrain draws.
|
||||
pub fn set_pack_lava_layers(&mut self, layers: Vec<u16>) {
|
||||
self.pack_lava_layers = layers;
|
||||
}
|
||||
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
|
||||
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
|
||||
/// stages' labels; on an error the pack that ran keeps running.
|
||||
@@ -6458,6 +6475,9 @@ impl Renderer {
|
||||
}
|
||||
});
|
||||
horizon_shaders::GameInputs {
|
||||
terrain_parts: terrain
|
||||
.as_ref()
|
||||
.map(|t| pack_atlas::terrain_parts(t, &self.pack_lava_layers)),
|
||||
terrain,
|
||||
terrain_geometry,
|
||||
entity_geometry: Some(pack_entities::geometry_inputs()),
|
||||
@@ -8242,6 +8262,8 @@ impl Renderer {
|
||||
translucent_particle_quads,
|
||||
translucent_item_quads,
|
||||
oit_entity_quads,
|
||||
world_text_quads,
|
||||
world_text_offset_quads,
|
||||
] = self.entity_batch.update(
|
||||
entities,
|
||||
frame.additive,
|
||||
@@ -8267,11 +8289,14 @@ impl Renderer {
|
||||
+ shadow_quads
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
+ oit_entity_quads
|
||||
+ world_text_quads
|
||||
+ world_text_offset_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
let base_quads =
|
||||
world_quads - oit_entity_quads - world_text_quads - world_text_offset_quads;
|
||||
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
|
||||
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
|
||||
let (rebuilt, uniform) =
|
||||
@@ -10007,6 +10032,29 @@ impl Renderer {
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// Native world text is an ordinary depth-tested overlay, after
|
||||
// particles (also after the OIT composite) and before the hand.
|
||||
// Its own geometry is independent of block-entity lifetime.
|
||||
if world_text_quads + world_text_offset_quads > 0 {
|
||||
pass.set_bind_group(0, &self.entity_group, &[]);
|
||||
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
|
||||
let first = base_quads + oit_entity_quads;
|
||||
for (pipeline, count, offset) in [
|
||||
(&self.world_text_pipeline, world_text_quads, first),
|
||||
(
|
||||
&self.text_polygon_offset_pipeline,
|
||||
world_text_offset_quads,
|
||||
first + world_text_quads,
|
||||
),
|
||||
] {
|
||||
if count > 0 {
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.draw_indexed(0..count * 6, (offset * 4) as i32, 0..1);
|
||||
}
|
||||
}
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
}
|
||||
// LevelRenderer.executeClassicTransparency: translucent targets'
|
||||
// outlines follow terrain, so its blending cannot wash them out.
|
||||
if outline_edges > 0 && frame.outline_after_terrain && !pack_draws_outline {
|
||||
@@ -11718,7 +11766,7 @@ mod tests {
|
||||
let mut batch = super::EntityBatch::default();
|
||||
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
|
||||
let vertices = &batch.combined;
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
|
||||
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
vertices
|
||||
.as_chunks::<4>()
|
||||
|
||||
@@ -14,7 +14,8 @@ use super::Vertex;
|
||||
/// material at the end: the render types vanilla gives an `OitPipelineSet`
|
||||
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
|
||||
/// ignored and they stay in the first one, in their order.
|
||||
const MATERIALS: usize = 10;
|
||||
/// Two native world-canvas text materials follow it, NORMAL then POLYGON_OFFSET.
|
||||
const MATERIALS: usize = 12;
|
||||
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
|
||||
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
|
||||
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
|
||||
@@ -51,6 +52,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
|
||||
/// The overlay of text in `Font.DisplayMode.POLYGON_OFFSET` (text displays):
|
||||
/// drawn like an ordinary entity, pulled toward the camera.
|
||||
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
|
||||
/// A native world canvas, drawn after particles with normal text depth/culling.
|
||||
pub const WORLD_TEXT_OVERLAY: u32 = 39;
|
||||
/// The front of its eight-offset outlined text, using POLYGON_OFFSET.
|
||||
pub const WORLD_TEXT_POLYGON_OFFSET_OVERLAY: u32 = 40;
|
||||
|
||||
/// The material of a quad by its overlay (`MATERIALS` order): the overlay is the layer's low 16
|
||||
/// bits, as entity.wgsl reads it; HDR output's tier rides in the high ones.
|
||||
@@ -59,6 +64,8 @@ fn material(layer: u32, oit: bool) -> usize {
|
||||
return 9;
|
||||
}
|
||||
match layer & 0xffff {
|
||||
WORLD_TEXT_OVERLAY => 10,
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY => 11,
|
||||
TEXT_POLYGON_OFFSET_OVERLAY => 1,
|
||||
11 => 2,
|
||||
12 => 3,
|
||||
@@ -118,7 +125,7 @@ impl EntityBatch {
|
||||
self.combined.clear();
|
||||
self.combined
|
||||
.reserve(entity_vertices + additive.len() + hand.len() + see_through.len());
|
||||
for runs in &self.runs {
|
||||
for runs in &self.runs[..10] {
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
@@ -141,6 +148,27 @@ impl EntityBatch {
|
||||
}
|
||||
}
|
||||
}
|
||||
// RenderType.text sorts alpha-blended quads from far to near. Keep
|
||||
// both world-canvas layers after existing materials (including eyes
|
||||
// moved out of additive); front text uses polygon offset.
|
||||
for runs in &self.runs[10..] {
|
||||
let start = self.combined.len();
|
||||
for &(first, count) in runs {
|
||||
self.combined
|
||||
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
|
||||
}
|
||||
self.combined[start..]
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.sort_by(|a, b| {
|
||||
let distance = |q: &[Vertex; 4]| {
|
||||
(0..3)
|
||||
.map(|i| ((q[0].pos[i] + q[2].pos[i]) * 0.5).powi(2))
|
||||
.sum::<f32>()
|
||||
};
|
||||
distance(b).total_cmp(&distance(a))
|
||||
});
|
||||
}
|
||||
if self.moved_additive == 0 {
|
||||
self.combined.extend_from_slice(additive);
|
||||
} else {
|
||||
@@ -160,6 +188,41 @@ impl EntityBatch {
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{hint::black_box, time::Instant};
|
||||
#[test]
|
||||
fn world_canvas_sorts_after_oit_eyes_before_frame_tails() {
|
||||
let quad = |layer, x| {
|
||||
[Vertex {
|
||||
layer,
|
||||
pos: [x, 0.0, 0.0],
|
||||
..geometry(1, false)[0]
|
||||
}; 4]
|
||||
};
|
||||
let entities: Vec<_> = [
|
||||
(0, 1.0),
|
||||
(35, 2.0),
|
||||
(OIT_ENTITY, 3.0),
|
||||
(WORLD_TEXT_OVERLAY, 4.0),
|
||||
(WORLD_TEXT_OVERLAY, 5.0),
|
||||
(WORLD_TEXT_POLYGON_OFFSET_OVERLAY, 6.0),
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let additive: Vec<_> = [(OIT_ENTITY | 2, 7.0), (2, 8.0)]
|
||||
.into_iter()
|
||||
.flat_map(|(layer, x)| quad(layer, x))
|
||||
.collect();
|
||||
let mut batch = EntityBatch::default();
|
||||
assert_eq!(
|
||||
batch.update(&entities, &additive, &quad(0, 9.0), &quad(0, 10.0), true),
|
||||
[1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 1]
|
||||
);
|
||||
let positions: Vec<_> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(
|
||||
positions,
|
||||
[1.0, 2.0, 3.0, 7.0, 5.0, 4.0, 6.0, 8.0, 9.0, 10.0]
|
||||
);
|
||||
}
|
||||
|
||||
// Retain the original implementation as the equivalence/benchmark oracle.
|
||||
fn baseline(
|
||||
@@ -257,12 +320,12 @@ mod tests {
|
||||
let mut batch = EntityBatch::default();
|
||||
// Off: the tag changes nothing, the quads keep their place.
|
||||
let counts = batch.update(&entities, &[], &[], &[], false);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
|
||||
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
|
||||
// On: the tagged quads come last, in order.
|
||||
let counts = batch.update(&entities, &[], &[], &[], true);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
|
||||
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2, 0, 0]);
|
||||
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
|
||||
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
|
||||
}
|
||||
|
||||
@@ -519,6 +519,36 @@ const FACE: &str = " uint hz_q = (uint(gl_VertexIndex) & ~3u) * 6u;\n \
|
||||
}\n \
|
||||
}\n";
|
||||
|
||||
/// `terrain` (`PackAtlas::terrain_inputs`) drawing only solid terrain, then only cutout terrain:
|
||||
/// each leaves the other part's quads outside the clip volume. Solid: the quads over opaque
|
||||
/// texels and opaque leaves (`mesher::OPAQUE_LAYER`), as vanilla picks `ChunkSectionLayer.SOLID`
|
||||
/// per quad (`FaceBakery.computeMaterialTransparency`, `SectionCompiler.compile`), and lava's
|
||||
/// faces (sprite layers `lava`, its still and flowing sprites), which Iris 1.11.7 drew with
|
||||
/// `gbuffers_terrain_solid` in our probes, with and without `separateAo`.
|
||||
///
|
||||
/// The test reads only the vertex's sprite layer, which all four vertices of a quad share: a
|
||||
/// quad lands whole in one part. The colour's alpha can't decide it, as with `separateAo` it
|
||||
/// holds each vertex's own ambient occlusion.
|
||||
pub fn terrain_parts(
|
||||
terrain: &horizon_shaders::GbuffersInputs,
|
||||
lava: &[u16],
|
||||
) -> [horizon_shaders::GbuffersInputs; 2] {
|
||||
let opaque = crate::mesher::OPAQUE_LAYER;
|
||||
let lava: String = lava
|
||||
.iter()
|
||||
.map(|l| format!(" || hz_v_layer == {l}u"))
|
||||
.collect();
|
||||
let part = |solid: bool| {
|
||||
let mut inputs = terrain.clone();
|
||||
inputs.after.push_str(&format!(
|
||||
" bool hz_solid = (hz_v_layer & {opaque}u) != 0u{lava};\n \
|
||||
if (hz_solid != {solid}) gl_Position = vec4(2.0, 2.0, 2.0, 1.0);\n"
|
||||
));
|
||||
inputs
|
||||
};
|
||||
[part(true), part(false)]
|
||||
}
|
||||
|
||||
impl PackAtlas {
|
||||
/// The vertex inputs of a gbuffers program drawing terrain (`TerrainVertex`, terrain.wgsl's
|
||||
/// `vs_terrain` or, for `flat` draws, `vs_terrain_flat`), in atlas coordinates.
|
||||
|
||||
@@ -43,6 +43,9 @@ mod shadow_faces_tests;
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod weather_depth_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -516,8 +519,14 @@ pub struct PackPost {
|
||||
passes: Vec<Pass>,
|
||||
/// Shadow cache for temporal reprojection.
|
||||
shadow_cache: ShadowCache,
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
|
||||
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`;
|
||||
/// when the pack draws terrain in two parts, solid terrain only (`gbuffers_terrain_solid` or
|
||||
/// its fallback).
|
||||
terrain: Option<Pass>,
|
||||
/// What draws cutout terrain (`gbuffers_terrain_cutout` or its fallback), after `terrain`,
|
||||
/// when the pack draws terrain in two parts. A pack with no program for one part has only
|
||||
/// the other.
|
||||
terrain_cutout: Option<Pass>,
|
||||
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
|
||||
water: Option<Pass>,
|
||||
/// What draws the sky (`gbuffers_skybasic` or its fallback), before terrain, and its vertices.
|
||||
@@ -2163,10 +2172,15 @@ impl PackPost {
|
||||
ids: block_ids.as_ref().map(|i| &i.0),
|
||||
cull: g.cull,
|
||||
});
|
||||
let terrain = post
|
||||
let split = post.terrain.is_some() && post.terrain_cutout.is_some();
|
||||
let mut terrain = post
|
||||
.terrain
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let mut terrain_cutout = post
|
||||
.terrain_cutout
|
||||
.zip(terrain_geometry.as_ref())
|
||||
.and_then(|(p, g)| build(p, Some(g)));
|
||||
let water = post
|
||||
.water
|
||||
.zip(terrain_geometry.as_ref())
|
||||
@@ -2295,7 +2309,7 @@ impl PackPost {
|
||||
let weather_geometry = world.weather.then(|| Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
depth_write: post.rain_depth,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
@@ -2371,8 +2385,31 @@ impl PackPost {
|
||||
.filter_map(|p| build(p, None))
|
||||
.collect();
|
||||
let stand_ins = pack_images::stand_ins(device, stand_ins.take());
|
||||
if let Some(t) = &terrain {
|
||||
notes.push(format!("terrain draws with {}", t.name));
|
||||
// Terrain in two parts draws both or neither: one alone would leave the other part's
|
||||
// terrain undrawn. Without either, the game draws all terrain, as when the terrain
|
||||
// program of a pack that doesn't split can't draw.
|
||||
if split
|
||||
&& terrain.is_some() != terrain_cutout.is_some()
|
||||
&& let Some(p) = terrain.take().or(terrain_cutout.take())
|
||||
{
|
||||
notes.push(format!(
|
||||
"{}: the other part of terrain can't draw; the game draws all terrain",
|
||||
p.name
|
||||
));
|
||||
}
|
||||
match (&terrain, &mut terrain_cutout) {
|
||||
(Some(t), None) => notes.push(format!("terrain draws with {}", t.name)),
|
||||
(t, Some(c)) => {
|
||||
if let Some(t) = t {
|
||||
notes.push(format!("solid terrain draws with {}", t.name));
|
||||
}
|
||||
notes.push(format!("cutout terrain draws with {}", c.name));
|
||||
// Timed (and labelled) apart from the solid part when it runs the same program.
|
||||
if t.as_ref().is_some_and(|t| t.name == c.name) {
|
||||
c.name.push_str(" (cutout)");
|
||||
}
|
||||
}
|
||||
(None, None) => {}
|
||||
}
|
||||
for (part, pass) in [
|
||||
("terrain", &shadow_terrain),
|
||||
@@ -2456,6 +2493,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.chain(&shadow_comp)
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(&water))
|
||||
.chain(shadow_terrain.iter().chain(&shadow_water))
|
||||
.chain(sky.iter().flat_map(|s| s.basic.iter().chain(&s.textured)))
|
||||
@@ -2513,6 +2551,7 @@ impl PackPost {
|
||||
.iter()
|
||||
.flat_map(|s| s.basic.iter().chain(&s.textured))
|
||||
.chain(&terrain)
|
||||
.chain(&terrain_cutout)
|
||||
.chain(entities.iter().flat_map(|e| e.passes.iter().flatten()))
|
||||
.chain(&clouds)
|
||||
.chain(&lines)
|
||||
@@ -2528,6 +2567,7 @@ impl PackPost {
|
||||
}
|
||||
// Terrain tests against the world's depth (entities...), so the blit copies it then.
|
||||
let blit_depth = terrain.is_some()
|
||||
|| terrain_cutout.is_some()
|
||||
|| water.is_some()
|
||||
|| sky.is_some()
|
||||
|| entities.as_ref().is_some_and(Entities::drawn)
|
||||
@@ -2665,6 +2705,7 @@ impl PackPost {
|
||||
setup_pending: true,
|
||||
passes,
|
||||
terrain,
|
||||
terrain_cutout,
|
||||
water,
|
||||
sky,
|
||||
entities,
|
||||
@@ -3153,7 +3194,8 @@ impl PackPost {
|
||||
}
|
||||
|
||||
/// Terrain (or, `translucent`, translucent terrain) with the pack's program; `draw` issues
|
||||
/// the game's draws.
|
||||
/// the game's draws. Solid terrain in two parts has its cutout part drawn second, from the
|
||||
/// same draws.
|
||||
fn draw_terrain(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
@@ -3163,13 +3205,35 @@ impl PackPost {
|
||||
list: TerrainList,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let slot = match list {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, false, draw);
|
||||
if list == TerrainList::Solid {
|
||||
self.draw_terrain_part(device, queue, encoder, size, list, true, draw);
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass that draws `list`, or the cutout part of the solid list.
|
||||
fn terrain_slot(&mut self, list: TerrainList, cutout: bool) -> &mut Option<Pass> {
|
||||
match list {
|
||||
TerrainList::Solid if cutout => &mut self.terrain_cutout,
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
};
|
||||
let Some(mut pass) = slot.take() else {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_terrain_part(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
size: [u32; 2],
|
||||
list: TerrainList,
|
||||
cutout: bool,
|
||||
draw: &mut TerrainDraw<'_>,
|
||||
) {
|
||||
let Some(mut pass) = self.terrain_slot(list, cutout).take() else {
|
||||
return;
|
||||
};
|
||||
let translucent = list.translucent();
|
||||
@@ -3207,12 +3271,7 @@ impl PackPost {
|
||||
if !pass.group.shadow() {
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
*match list {
|
||||
TerrainList::Solid => &mut self.terrain,
|
||||
TerrainList::Translucent => &mut self.water,
|
||||
TerrainList::ShadowSolid => &mut self.shadow_terrain,
|
||||
TerrainList::ShadowTranslucent => &mut self.shadow_water,
|
||||
} = Some(pass);
|
||||
*self.terrain_slot(list, cutout) = Some(pass);
|
||||
}
|
||||
|
||||
/// The sky's parts in vanilla's order, each with the program that draws it; one render pass
|
||||
@@ -3851,7 +3910,7 @@ impl PackPost {
|
||||
|
||||
/// Whether the pack draws terrain itself.
|
||||
pub fn has_terrain(&self) -> bool {
|
||||
self.terrain.is_some()
|
||||
self.terrain.is_some() || self.terrain_cutout.is_some()
|
||||
}
|
||||
|
||||
/// Whether the pack's shadow pass draws terrain (`shadow_view`).
|
||||
@@ -3902,6 +3961,7 @@ impl PackPost {
|
||||
.passes
|
||||
.iter_mut()
|
||||
.chain(&mut self.terrain)
|
||||
.chain(&mut self.terrain_cutout)
|
||||
.chain(&mut self.water)
|
||||
.chain(sky)
|
||||
.chain(entities)
|
||||
@@ -4561,6 +4621,7 @@ impl PackPost {
|
||||
let mut shown = false;
|
||||
// A water-only pack still needs the opaque phase to populate requested snapshots.
|
||||
let mut terrain_drawn = self.terrain.is_none()
|
||||
&& self.terrain_cutout.is_none()
|
||||
&& self.sky.is_none()
|
||||
&& !self.entities.as_ref().is_some_and(Entities::drawn)
|
||||
&& self.clouds.is_none()
|
||||
@@ -4735,6 +4796,10 @@ mod snorm_tests;
|
||||
#[path = "pack_post/depth_test.rs"]
|
||||
mod depth_test;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_post/terrain_parts_tests.rs"]
|
||||
mod terrain_parts_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -3,15 +3,15 @@ use super::*;
|
||||
use crate::{gfx::Frame, hud::HudBatch};
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const SIZE: [u32; 2] = [16, 16];
|
||||
pub(super) const SIZE: [u32; 2] = [16, 16];
|
||||
|
||||
struct Gpu {
|
||||
pub(super) struct Gpu {
|
||||
adapter: wgpu::Adapter,
|
||||
device: wgpu::Device,
|
||||
pub(super) device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
}
|
||||
impl Gpu {
|
||||
fn new() -> Option<Self> {
|
||||
pub(super) fn new() -> Option<Self> {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
@@ -27,7 +27,7 @@ impl Gpu {
|
||||
queue,
|
||||
})
|
||||
}
|
||||
fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
pub(super) fn texture(&self, format: T) -> wgpu::TextureView {
|
||||
self.device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Original depth fixture input"),
|
||||
@@ -47,7 +47,7 @@ impl Gpu {
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
}
|
||||
fn load(
|
||||
pub(super) fn load(
|
||||
&self,
|
||||
files: Vec<(String, String)>,
|
||||
world: World<'_>,
|
||||
@@ -122,7 +122,7 @@ impl Gpu {
|
||||
});
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
fn render(
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
post: &mut PackPost,
|
||||
color: &wgpu::TextureView,
|
||||
@@ -213,7 +213,7 @@ enum Scene {
|
||||
WaterOnly,
|
||||
}
|
||||
|
||||
const VERTEX: &str =
|
||||
pub(super) const VERTEX: &str =
|
||||
"#version 120\nvarying vec2 uv;void main(){uv=gl_MultiTexCoord0.xy;gl_Position=ftransform();}";
|
||||
|
||||
fn shaders(sampler: &str, stage: Stage, scene: Scene) -> Vec<(String, String)> {
|
||||
@@ -561,7 +561,7 @@ fn attached_compute_preserves_distinct_hand_snapshots() {
|
||||
}
|
||||
|
||||
// Frame literal adapted from MIT Horizon f700231 pack_uniforms.rs test API setup.
|
||||
fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
eye_rotation: [0.0; 2],
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Solid and cutout terrain through `gbuffers_terrain_solid` and `gbuffers_terrain_cutout`
|
||||
//! (#9137). The expected programs are what Iris 1.11.7 drew with our probe packs (the PR has
|
||||
//! the numbers): with either program the pack draws solid terrain (quads over opaque texels, and
|
||||
//! lava) through `gbuffers_terrain_solid` and cutout terrain through `gbuffers_terrain_cutout`,
|
||||
//! the missing one falling back to `gbuffers_terrain`; solid terrain keeps a fragment written
|
||||
//! with alpha 0.05, cutout terrain loses it. Each quad draws whole with one of them, whatever
|
||||
//! its vertices' alphas (with `separateAo`, their own ambient occlusion). The programs here are
|
||||
//! the probes', cut down.
|
||||
use super::depth_test::{Gpu, SIZE, VERTEX, fixture_frame};
|
||||
use super::*;
|
||||
use crate::hud::HudBatch;
|
||||
use wgpu::{TextureFormat as T, util::DeviceExt};
|
||||
|
||||
const RED: [u8; 3] = [255, 0, 0];
|
||||
const GREEN: [u8; 3] = [0, 255, 0];
|
||||
const BLUE: [u8; 3] = [0, 0, 255];
|
||||
|
||||
/// A program writing `rgb` with `alpha` into colortex0.
|
||||
fn program(name: &str, rgb: [u8; 3], alpha: f32) -> [(String, String); 2] {
|
||||
let [r, g, b] = rgb.map(|c| f32::from(c) / 255.0);
|
||||
[
|
||||
(
|
||||
format!("{name}.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
format!("{name}.fsh"),
|
||||
format!(
|
||||
"#version 120\n/* DRAWBUFFERS:0 */\nvoid main(){{gl_FragData[0]=vec4({r:?},{g:?},{b:?},{alpha:?});}}"
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Lava's sprite layer in the fixture.
|
||||
const LAVA: u16 = 5;
|
||||
|
||||
/// The pack (`final` shows colortex0, cleared to black) drawn over three strips of terrain, left
|
||||
/// to right: a quad over opaque texels, a cutout quad, a lava quad, all with opaque vertex
|
||||
/// colours. Each strip's colour, `None` where nothing drew; `None` overall without a GPU.
|
||||
fn strips(programs: &[(&str, [u8; 3], f32)]) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
strips_with_alphas(programs, [255; 4])
|
||||
}
|
||||
|
||||
/// `strips` with the quads' corners' vertex alphas (bottom left, bottom right, top right, top
|
||||
/// left). Fails unless each strip is one colour.
|
||||
fn strips_with_alphas(
|
||||
programs: &[(&str, [u8; 3], f32)],
|
||||
alphas: [u8; 4],
|
||||
) -> Option<[Option<[u8; 3]>; 3]> {
|
||||
let files = programs
|
||||
.iter()
|
||||
.flat_map(|&(name, rgb, alpha)| program(name, rgb, alpha))
|
||||
.collect();
|
||||
let (strips, _) = strips_of(files, alphas)?;
|
||||
Some(strips)
|
||||
}
|
||||
|
||||
/// `strips_with_alphas` with the pack's terrain programs' `files`, and the built pack's notes
|
||||
/// when it draws no terrain itself (`None` when it does).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn strips_of(
|
||||
programs: Vec<(String, String)>,
|
||||
alphas: [u8; 4],
|
||||
) -> Option<([Option<[u8; 3]>; 3], Option<Vec<String>>)> {
|
||||
let Some(gpu) = Gpu::new() else {
|
||||
eprintln!("TERRAIN_PARTS_NO_ADAPTER");
|
||||
return None;
|
||||
};
|
||||
let mut files = vec![
|
||||
("final.vsh".to_owned(), VERTEX.to_owned()),
|
||||
(
|
||||
"final.fsh".to_owned(),
|
||||
"#version 120\nuniform sampler2D colortex0;varying vec2 uv;\
|
||||
const vec4 colortex0ClearColor = vec4(0.0, 0.0, 0.0, 1.0);\
|
||||
void main(){gl_FragColor=vec4(texture2D(colortex0,uv).rgb,1.0);}"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
files.extend(programs);
|
||||
// The game's terrain inputs as far as the parts read them: the sprite layer and the colour.
|
||||
let terrain = horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n\
|
||||
layout(location=2)in uint hz_v_layer;\n\
|
||||
layout(location=3)in vec4 hz_v_color;\n"
|
||||
.into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);\
|
||||
hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain_parts: Some(super::super::pack_atlas::terrain_parts(
|
||||
&terrain,
|
||||
&[LAVA, LAVA + 1],
|
||||
)),
|
||||
terrain: Some(terrain),
|
||||
..Default::default()
|
||||
};
|
||||
const ATTRS: [wgpu::VertexAttribute; 3] =
|
||||
wgpu::vertex_attr_array![0=>Float32x3,2=>Uint32,3=>Unorm8x4];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 20,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
let opaque = u32::from(crate::mesher::OPAQUE_LAYER);
|
||||
let mut vertices: Vec<u32> = Vec::new();
|
||||
for (i, layer) in [opaque | 1, 2, u32::from(LAVA)].into_iter().enumerate() {
|
||||
let (x0, x1) = (
|
||||
-1.0 + i as f32 * 2.0 / 3.0,
|
||||
-1.0 + (i + 1) as f32 * 2.0 / 3.0,
|
||||
);
|
||||
let corners = [[x0, -1.0], [x1, -1.0], [x1, 1.0], [x0, 1.0]];
|
||||
for corner in [0, 1, 2, 0, 2, 3] {
|
||||
let [x, y] = corners[corner];
|
||||
let color = u32::from(alphas[corner]) << 24 | 0x00FF_FFFF;
|
||||
vertices.extend([x.to_bits(), f32::to_bits(y), 0f32.to_bits(), layer, color]);
|
||||
}
|
||||
}
|
||||
let buffer = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Terrain parts fixture"),
|
||||
contents: bytemuck::cast_slice(&vertices),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let undrawn = (!post.has_terrain()).then(|| post.notes.clone());
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
let pixels = gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::Solid {
|
||||
rp.set_vertex_buffer(0, buffer.slice(..));
|
||||
rp.draw(0..18, 0..1);
|
||||
}
|
||||
});
|
||||
// Every pixel of each strip (columns 0-4, 6-9 and 11-15; 5 and 10 straddle two strips).
|
||||
let colour = |x: usize, y: usize| {
|
||||
let [r, g, b, _] = pixels[y * SIZE[0] as usize + x];
|
||||
Some([r, g, b]).filter(|&c| c != [0, 0, 0])
|
||||
};
|
||||
let strips = [0..5, 6..10, 11..16].map(|columns| {
|
||||
let first = colour(columns.start, 0);
|
||||
for x in columns.clone() {
|
||||
for y in 0..SIZE[1] as usize {
|
||||
assert_eq!(colour(x, y), first, "strip {columns:?} at ({x}, {y})");
|
||||
}
|
||||
}
|
||||
first
|
||||
});
|
||||
Some((strips, undrawn))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_and_cutout_programs_draw_their_own_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_cutout_program_cutout_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), Some(RED), Some(GREEN)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_solid_program_solid_terrain_falls_back_to_gbuffers_terrain() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_terrain_keeps_alpha_0_05_and_cutout_loses_it() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 0.05),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(GREEN), None, Some(GREEN)]);
|
||||
// The solid part through its fallback, as the probe's `gbuffers_terrain` writing 0.05.
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 0.05),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
let Some(got) = strips(&programs) else { return };
|
||||
assert_eq!(got, [Some(RED), Some(BLUE), Some(RED)]);
|
||||
}
|
||||
|
||||
/// With `separateAo` a vertex's alpha is its own ambient occlusion, so the corners of one quad
|
||||
/// differ; the quad still draws whole, with its part's program.
|
||||
#[test]
|
||||
fn a_quad_draws_whole_whatever_its_vertex_alphas() {
|
||||
let programs = [
|
||||
("gbuffers_terrain", RED, 1.0),
|
||||
("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
("gbuffers_terrain_cutout", BLUE, 1.0),
|
||||
];
|
||||
for alphas in [[255, 160, 255, 160], [128, 255, 200, 64], [160; 4]] {
|
||||
let Some(got) = strips_with_alphas(&programs, alphas) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(GREEN), Some(BLUE), Some(GREEN)], "{alphas:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pack_with_neither_draws_all_terrain_with_gbuffers_terrain() {
|
||||
let Some(got) = strips(&[("gbuffers_terrain", RED, 1.0)]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [Some(RED); 3]);
|
||||
}
|
||||
|
||||
/// A pack whose only terrain program is `gbuffers_terrain_solid` (or `_cutout`) draws that part,
|
||||
/// and nothing draws the other: Iris 1.11.7 left it undrawn with our probes `tsc-solidonly` and
|
||||
/// `tsc-cutoutonly`.
|
||||
#[test]
|
||||
fn a_lone_split_program_draws_only_its_own_terrain() {
|
||||
let solid = strips(&[("gbuffers_terrain_solid", GREEN, 1.0)]);
|
||||
let Some(solid) = solid else { return };
|
||||
assert_eq!(solid, [Some(GREEN), None, Some(GREEN)]);
|
||||
let cutout = strips(&[("gbuffers_terrain_cutout", BLUE, 1.0)]);
|
||||
assert_eq!(cutout, Some([None, Some(BLUE), None]));
|
||||
}
|
||||
|
||||
/// When one part's pipeline can't be built (here a cutout program writing no buffer, which
|
||||
/// Horizon skips), the other part doesn't draw alone either: the game draws all terrain.
|
||||
#[test]
|
||||
fn a_part_that_fails_to_build_takes_the_other_with_it() {
|
||||
let mut files: Vec<(String, String)> = [
|
||||
program("gbuffers_terrain", RED, 1.0),
|
||||
program("gbuffers_terrain_solid", GREEN, 1.0),
|
||||
]
|
||||
.concat();
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,0.0,1.0);}".into(),
|
||||
));
|
||||
files.push((
|
||||
"gbuffers_terrain_cutout.fsh".into(),
|
||||
"#version 120\nvoid main(){}".into(),
|
||||
));
|
||||
let Some((got, undrawn)) = strips_of(files, [255; 4]) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(got, [None; 3]);
|
||||
let notes = undrawn.expect("the game draws terrain");
|
||||
for note in [
|
||||
"gbuffers_terrain_cutout: writes no buffer; skipped",
|
||||
"gbuffers_terrain_solid: the other part of terrain can't draw",
|
||||
] {
|
||||
assert!(notes.iter().any(|n| n.starts_with(note)), "{notes:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
//! Original synthetic weather oracle: no pack assets or external shader algorithms.
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn rain_depth_controls_writes_for_rain_snow_and_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Weather depth GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let mut depth_failures = Vec::new();
|
||||
for program in ["gbuffers_weather", "gbuffers_textured_lit"] {
|
||||
for (property, writes) in [
|
||||
("rain.depth=true", true),
|
||||
("rain.depth=false", false),
|
||||
// An evidence-limited compatibility choice: preserve accepted main here.
|
||||
// The permitted packs do not establish the external omitted-value default.
|
||||
("", true),
|
||||
] {
|
||||
let mut post = pack(&device, &queue, &adapter, program, property);
|
||||
// Retain the pipelines for another frame, then resize their targets.
|
||||
for size in [[6, 1], [6, 1], [12, 2]] {
|
||||
post.resize(&device, size);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = &targets.color[0].as_ref().unwrap()[0];
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[attach(
|
||||
color,
|
||||
wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
)],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &targets.depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.5),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
post.set_weather(Some(geometry(&device)));
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.weather_draw.is_none(), "draw consumes this frame only");
|
||||
assert!(post.has_weather(), "pipelines survive the draw");
|
||||
// A dry frame must neither redraw old quads nor discard the pipelines.
|
||||
post.set_weather(None);
|
||||
post.draw_weather(&device, &queue, &mut encoder, size);
|
||||
assert!(post.has_weather());
|
||||
let targets = post.targets.as_ref().unwrap();
|
||||
let color = targets.color[0].as_ref().unwrap()[0].texture();
|
||||
let colors = readback(&device, &mut encoder, color);
|
||||
let depths = readback(&device, &mut encoder, &targets.depth_texture);
|
||||
queue.submit([encoder.finish()]);
|
||||
for buffer in [&colors, &depths] {
|
||||
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
}
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let colors = colors.slice(..).get_mapped_range().unwrap();
|
||||
let depths = depths.slice(..).get_mapped_range().unwrap();
|
||||
for y in 0..size[1] as usize {
|
||||
for x in 0..size[0] as usize {
|
||||
let quad = x / (size[0] as usize / 6);
|
||||
let visible = quad.is_multiple_of(3);
|
||||
// First quad is in front; second is behind scene depth; third discards.
|
||||
let expected_color = if visible && quad < 3 {
|
||||
[128, 0, 127, 255]
|
||||
} else if visible {
|
||||
[0, 128, 127, 255]
|
||||
} else {
|
||||
[0, 0, 255, 255]
|
||||
};
|
||||
let offset = y * 256 + x * 4;
|
||||
let actual_color = &colors[offset..offset + 4];
|
||||
for (actual, expected) in actual_color.iter().zip(expected_color) {
|
||||
assert!(
|
||||
i16::from(*actual).abs_diff(expected) <= 1,
|
||||
"{program} {property:?} size={size:?} ({x},{y}): color {actual_color:?}"
|
||||
);
|
||||
}
|
||||
let depth =
|
||||
f32::from_le_bytes(depths[offset..offset + 4].try_into().unwrap());
|
||||
let expected_depth = if visible && writes { 0.25 } else { 0.5 };
|
||||
if depth != expected_depth {
|
||||
depth_failures.push(format!(
|
||||
"{program} {property:?} size={size:?} ({x},{y}): depth={depth}, expected={expected_depth}"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"Weather oracle {program} {property:?} {size:?}: color, occlusion and discard pass; cumulative depth mismatches={}",
|
||||
depth_failures.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(depth_failures.is_empty(), "{}", depth_failures.join("\n"));
|
||||
}
|
||||
|
||||
fn pack(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
adapter: &wgpu::Adapter,
|
||||
program: &str,
|
||||
property: &str,
|
||||
) -> PackPost {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (extension, source) in [
|
||||
(
|
||||
"vsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; void main(){gl_Position=gl_Vertex;color=gl_Color;marker=gl_MultiTexCoord0.x;}",
|
||||
),
|
||||
(
|
||||
"fsh",
|
||||
"#version 120\nvarying vec4 color; varying float marker; /* DRAWBUFFERS:0 */ void main(){if(marker>1.5) discard;gl_FragData[0]=color;}",
|
||||
),
|
||||
] {
|
||||
zip.start_file(
|
||||
format!("shaders/{program}.{extension}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-weather-depth",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
let properties = horizon_shaders::Properties::parse(property, ¯os);
|
||||
let post = horizon_shaders::load_post(
|
||||
&pack,
|
||||
"",
|
||||
&properties,
|
||||
¯os,
|
||||
&horizon_shaders::GameInputs {
|
||||
weather: Some(super::super::pack_weather::inputs()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert!(post.notes.is_empty(), "{:?}", post.notes);
|
||||
assert_eq!(post.weather.as_ref().unwrap().name, program);
|
||||
let lightmap = device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default());
|
||||
let mut post = PackPost::new(
|
||||
device,
|
||||
queue,
|
||||
adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
sprite_rects: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: true,
|
||||
hand: false,
|
||||
},
|
||||
)
|
||||
.expect("weather pack runs");
|
||||
assert!(
|
||||
post.has_weather(),
|
||||
"production weather pipelines must compile: {:?}",
|
||||
post.notes
|
||||
);
|
||||
post.copied |= 1; // Keep colortex0 readable for the independent color oracle.
|
||||
post
|
||||
}
|
||||
|
||||
fn geometry(device: &wgpu::Device) -> Weather {
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
for quad in 0..6u32 {
|
||||
let left = quad as f32 / 3.0 - 1.0;
|
||||
let right = (quad + 1) as f32 / 3.0 - 1.0;
|
||||
let z = if quad % 3 == 1 { 0.5 } else { -0.5 };
|
||||
for pos in [
|
||||
[left, -1.0, z],
|
||||
[right, -1.0, z],
|
||||
[right, 1.0, z],
|
||||
[left, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [(quad % 3) as f32, 0.0],
|
||||
layer: 0,
|
||||
color: u32::from_le_bytes(if quad < 3 {
|
||||
[255, 0, 0, 128]
|
||||
} else {
|
||||
[0, 255, 0, 128]
|
||||
}),
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
indices.extend([0, 1, 2, 0, 2, 3].map(|i| quad * 4 + i));
|
||||
}
|
||||
let buffer = |contents: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
Weather {
|
||||
vertices: buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX),
|
||||
indices: buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX),
|
||||
quads: [0..3, 3..6],
|
||||
sprites: [None, None],
|
||||
generation: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn readback(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
texture: &wgpu::Texture,
|
||||
) -> wgpu::Buffer {
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: u64::from(256 * texture.height()),
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: if texture.format().is_depth_stencil_format() {
|
||||
wgpu::TextureAspect::DepthOnly
|
||||
} else {
|
||||
wgpu::TextureAspect::All
|
||||
},
|
||||
..texture.as_image_copy()
|
||||
},
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
buffer
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
//! NORMAL world text: alpha blended, back-face culled, ordinary depth writes.
|
||||
pub(super) fn pipeline(
|
||||
device: &wgpu::Device,
|
||||
layout: Option<&wgpu::PipelineLayout>,
|
||||
shader: &wgpu::ShaderModule,
|
||||
format: wgpu::TextureFormat,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Normal world text"),
|
||||
layout,
|
||||
vertex: wgpu::VertexState {
|
||||
module: shader,
|
||||
entry_point: Some("vs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[Some(wgpu::VertexBufferLayout {
|
||||
array_stride: std::mem::size_of::<super::Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &attributes,
|
||||
})],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: shader,
|
||||
entry_point: Some("fs_entity"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
#[test]
|
||||
#[ignore = "requires a Vulkan/Metal/DX12 adapter"]
|
||||
fn world_text_blends_culls_and_obeys_world_depth() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
// Use the production pipeline/vertex layout with a pass-through shader
|
||||
// to isolate its actual depth, winding and blend behavior from lighting.
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Wgsl(
|
||||
r#"
|
||||
struct Out { @builtin(position) pos:vec4<f32>, @location(0) color:vec4<f32> }
|
||||
@vertex fn vs_entity(@location(0) pos:vec3<f32>, @location(3) color:vec4<f32>)->Out {
|
||||
return Out(vec4(pos,1.0),color);
|
||||
}
|
||||
@fragment fn fs_entity(v:Out)->@location(0) vec4<f32> { return v.color; }
|
||||
"#
|
||||
.into(),
|
||||
),
|
||||
});
|
||||
let pipeline = pipeline(&device, None, &shader, wgpu::TextureFormat::Rgba8Unorm);
|
||||
let texture = |format, usage| {
|
||||
device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 4,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let color = texture(
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
);
|
||||
let depth = texture(
|
||||
wgpu::TextureFormat::Depth32Float,
|
||||
wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
);
|
||||
let mut vertices = Vec::new();
|
||||
let mut indices = Vec::new();
|
||||
let mut quad = |pixel: u32, z: f32, color: [u8; 4], back: bool| {
|
||||
let x = -1.0 + pixel as f32 * 0.5;
|
||||
let first = vertices.len() as u32;
|
||||
for pos in [
|
||||
[x, -1.0, z],
|
||||
[x + 0.5, -1.0, z],
|
||||
[x + 0.5, 1.0, z],
|
||||
[x, 1.0, z],
|
||||
] {
|
||||
vertices.push(super::super::Vertex {
|
||||
pos,
|
||||
uv: [0.0; 2],
|
||||
color: u32::from_le_bytes(color),
|
||||
layer: 0,
|
||||
light: 0,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
for i in if back {
|
||||
[0, 2, 1, 0, 3, 2]
|
||||
} else {
|
||||
[0, 1, 2, 0, 2, 3]
|
||||
} {
|
||||
indices.push(first + i);
|
||||
}
|
||||
};
|
||||
quad(0, 0.6, [255, 0, 0, 255], false);
|
||||
// A hair behind: the entity model pipeline's 128-ulp depth bias
|
||||
// would incorrectly put the green quad in front of the red surface.
|
||||
quad(0, 0.599_999, [0, 255, 0, 255], false);
|
||||
quad(1, 0.8, [0, 255, 0, 255], false);
|
||||
quad(1, 0.7, [255, 0, 0, 255], false);
|
||||
quad(2, 0.5, [255, 0, 0, 128], false);
|
||||
quad(3, 0.5, [255, 255, 0, 255], true);
|
||||
let buffer = |data: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: data,
|
||||
usage,
|
||||
})
|
||||
};
|
||||
let vertex = buffer(bytemuck::cast_slice(&vertices), wgpu::BufferUsages::VERTEX);
|
||||
let index = buffer(bytemuck::cast_slice(&indices), wgpu::BufferUsages::INDEX);
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let color = color.create_view(&Default::default());
|
||||
let depth = depth.create_view(&Default::default());
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &color,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLUE),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(0.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_vertex_buffer(0, vertex.slice(..));
|
||||
pass.set_index_buffer(index.slice(..), wgpu::IndexFormat::Uint32);
|
||||
pass.draw_indexed(0..indices.len() as u32, 0, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
color.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
color.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
readback.map_async(wgpu::MapMode::Read, .., move |result| {
|
||||
tx.send(result).unwrap()
|
||||
});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
rx.recv().unwrap().unwrap();
|
||||
let pixels = readback.get_mapped_range(..).unwrap();
|
||||
assert_eq!(
|
||||
&pixels[..16],
|
||||
&[
|
||||
255, 0, 0, 255, 0, 255, 0, 255, 128, 0, 127, 255, 0, 0, 255, 255
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -256,7 +256,15 @@ pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
match ImageFormat::sniff(data) {
|
||||
Some(ImageFormat::WebP) => decode_webp(data),
|
||||
Some(ImageFormat::Jpeg) => decode_jpeg(data),
|
||||
Some(ImageFormat::Png) | None => decode_bytes(data),
|
||||
// A PNG's IHDR states the size before the decoder sizes its pixel
|
||||
// buffer, so it is checked there like the WebP and JPEG headers: the
|
||||
// PNG reader alone would trust the header and allocate it.
|
||||
Some(ImageFormat::Png) | None => {
|
||||
if let Some((width, height)) = png_bytes_size(data) {
|
||||
check_fetched_size(u64::from(width), u64::from(height))?;
|
||||
}
|
||||
decode_bytes(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,15 +316,19 @@ fn within_budget(files: &[&PathBuf], budget: u64) -> HashSet<PathBuf> {
|
||||
.map(|p| (*p).clone())
|
||||
.collect()
|
||||
}
|
||||
/// A PNG's width and height from its first 24 bytes (`IHDR`), before any
|
||||
/// pixel buffer is made.
|
||||
fn png_bytes_size(data: &[u8]) -> Option<(u32, u32)> {
|
||||
let header = data.get(..24)?;
|
||||
if header[..8] != *b"\x89PNG\r\n\x1a\n" || header[12..16] != *b"IHDR" {
|
||||
return None;
|
||||
}
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
Some((field(16), field(20)))
|
||||
}
|
||||
/// A PNG's width and height, from its header alone.
|
||||
fn png_size(path: &Path) -> Option<(u32, u32)> {
|
||||
let header: [u8; 24] = horizon_core::prepared_files::read_head(path, 24)
|
||||
.ok()?
|
||||
.try_into()
|
||||
.ok()?;
|
||||
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
|
||||
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
|
||||
.then(|| (field(16), field(20)))
|
||||
png_bytes_size(&horizon_core::prepared_files::read_head(path, 24).ok()?)
|
||||
}
|
||||
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
// A prepared root's folder: from its data file, without listing the disk.
|
||||
@@ -1928,7 +1940,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, false, None)
|
||||
}
|
||||
/// `text_styled` for text drawn in the world (`TextRenderable.render`
|
||||
/// with `flat` false): each quad's `depth` is its z in text units, the
|
||||
@@ -1944,7 +1956,42 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
) -> f32 {
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true)
|
||||
self.styled_text(atlas, text, x, y, color, shadow, true, None)
|
||||
}
|
||||
/// `Font.drawInBatch8xOutline`: eight glyph offsets in the outline color,
|
||||
/// then the original text. Returns (advance, first front-text vertex), so
|
||||
/// world rendering can use POLYGON_OFFSET for the front and NORMAL for
|
||||
/// its outline. Offsets use each glyph's own shadow offset (unihex: 0.5).
|
||||
pub fn text_styled_outline_world(
|
||||
&mut self,
|
||||
atlas: &HudAtlas,
|
||||
text: &crate::text::Text,
|
||||
x: f32,
|
||||
y: f32,
|
||||
color: u32,
|
||||
outline: u32,
|
||||
) -> (f32, usize) {
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
if dx != 0 || dy != 0 {
|
||||
self.styled_text(
|
||||
atlas,
|
||||
text,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
false,
|
||||
true,
|
||||
Some((outline, [dx as f32, dy as f32])),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let front = self.vertices.len();
|
||||
(
|
||||
self.styled_text(atlas, text, x, y, color, false, true, None),
|
||||
front,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn styled_text(
|
||||
@@ -1956,6 +2003,7 @@ impl HudBatch {
|
||||
color: u32,
|
||||
shadow: bool,
|
||||
world: bool,
|
||||
outline: Option<(u32, [f32; 2])>,
|
||||
) -> f32 {
|
||||
// StringDecomposer.iterateFormatted inside Font.drawInternal:
|
||||
// `§` codes inside a span restyle what follows and are not drawn.
|
||||
@@ -2023,10 +2071,11 @@ impl HudBatch {
|
||||
}
|
||||
self.patches.extend(font.take_patches());
|
||||
let white = font.white();
|
||||
let has_shadow = shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0));
|
||||
let has_shadow = outline.is_none()
|
||||
&& (shadow
|
||||
|| glyphs
|
||||
.iter()
|
||||
.any(|glyph| glyph.3.is_some_and(|color| color != 0)));
|
||||
let passes: &[bool] = if has_shadow { &[true, false] } else { &[false] };
|
||||
// ARGB.scaleRGB(color, 0.25): the default shadow colour.
|
||||
let shade = |c: u32| {
|
||||
@@ -2061,8 +2110,9 @@ impl HudBatch {
|
||||
} else {
|
||||
shade(*c)
|
||||
};
|
||||
let has_shadow = (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
let has_shadow = outline.is_none()
|
||||
&& (custom_shadow.is_some_and(|argb| argb != 0)
|
||||
|| custom_shadow.is_none() && shadow)
|
||||
&& !(world && shadow_color == 0);
|
||||
if is_shadow && !has_shadow {
|
||||
continue;
|
||||
@@ -2072,7 +2122,11 @@ impl HudBatch {
|
||||
} else {
|
||||
(0.0, *c)
|
||||
};
|
||||
let (px, py) = (*gx + dx, y + dx);
|
||||
let (px, py, col) = if let Some((color, [dx, dy])) = outline {
|
||||
(*gx + dx * g.offset, y + dy * g.offset, color)
|
||||
} else {
|
||||
(*gx + dx, y + dx, col)
|
||||
};
|
||||
if let Some(object) = object {
|
||||
// `PlainTextRenderable.render`: sprite glyphs use the same
|
||||
// shadow colour and offset as text, with the front at SHADOW_DEPTH.
|
||||
@@ -2916,6 +2970,41 @@ mod tests {
|
||||
assert!(check_fetched_size(16_383, 16_383).is_err());
|
||||
}
|
||||
|
||||
/// A fetched PNG is refused by the size its own header claims, before the
|
||||
/// reader makes the pixel buffer: a few hundred bytes of payload would
|
||||
/// otherwise ask for 64 MiB or much more.
|
||||
#[test]
|
||||
fn a_fetched_png_is_refused_by_the_size_its_header_claims() {
|
||||
let mut encoded = Vec::new();
|
||||
{
|
||||
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&[7; 16]).unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
ImageFormat::sniff(&encoded),
|
||||
Some(ImageFormat::Png)
|
||||
));
|
||||
assert_eq!(
|
||||
decode_image_bytes(&encoded).expect("a small PNG decodes").0,
|
||||
2
|
||||
);
|
||||
|
||||
// The same image, its IHDR claiming one pixel row past the limit: the
|
||||
// CRC is ignored on purpose, so this stands in for a hostile header.
|
||||
let ihdr = encoded.windows(4).position(|w| w == b"IHDR").expect("IHDR");
|
||||
encoded[ihdr + 4..ihdr + 8].copy_from_slice(&4097u32.to_be_bytes());
|
||||
encoded[ihdr + 8..ihdr + 12].copy_from_slice(&4096u32.to_be_bytes());
|
||||
let refused = decode_image_bytes(&encoded).expect_err("the size is refused");
|
||||
assert!(
|
||||
refused.to_string().contains("too large to decode"),
|
||||
"{refused}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the network path reads WebP and JPEG: a resource pack's texture
|
||||
/// stays PNG-only, as vanilla's `NativeImage.read` is.
|
||||
#[test]
|
||||
@@ -3819,6 +3908,44 @@ texture=wide",
|
||||
assert_eq!(fonts.font.width("abba"), width);
|
||||
}
|
||||
#[test]
|
||||
fn outlined_world_text_keeps_font_metrics_and_overrides_outline_color() {
|
||||
let root = two_glyph_font("world-outline");
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let text = crate::text::Text::colored("ab", 0xff0000);
|
||||
let outline = rgba(1, 2, 3, 128);
|
||||
let (advance, front) =
|
||||
batch.text_styled_outline_world(&atlas, &text, 10.0, 20.0, u32::MAX, outline);
|
||||
assert_eq!((advance, front, batch.vertices.len()), (10.0, 96, 108));
|
||||
assert!(batch.vertices[..front].iter().all(|v| v.color == outline));
|
||||
assert!(
|
||||
batch.vertices[front..]
|
||||
.iter()
|
||||
.all(|v| v.color == rgba(255, 0, 0, 255))
|
||||
);
|
||||
// Eight independently offset silhouettes, retaining both glyphs and
|
||||
// the bitmap font's advance (a=4, b=6); front text has no shadow.
|
||||
for (i, [dx, dy]) in [
|
||||
[-1.0, -1.0],
|
||||
[-1.0, 0.0],
|
||||
[-1.0, 1.0],
|
||||
[0.0, -1.0],
|
||||
[0.0, 1.0],
|
||||
[1.0, -1.0],
|
||||
[1.0, 0.0],
|
||||
[1.0, 1.0],
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let at = batch.vertices[i * 12].pos;
|
||||
let main = batch.vertices[front].pos;
|
||||
assert_eq!(at, [main[0] + dx, main[1] + dy]);
|
||||
assert_eq!(batch.vertices[i * 12 + 6].pos[0] - at[0], 4.0);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn obfuscated_glyphs_keep_the_width() {
|
||||
let root = two_glyph_font("obfuscated");
|
||||
let fonts = HudAtlas::fonts(&root).unwrap();
|
||||
|
||||
@@ -354,7 +354,9 @@ fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
|
||||
if !fluid(top) {
|
||||
spans.push(ground(top));
|
||||
} else if let Some(under) = below.iter().rev().find(|&span| !fluid(span)) {
|
||||
// Shaded water is a span per light level; the bed is under all of it.
|
||||
// The bed is under all of the water, however many spans the water
|
||||
// takes: a chunk's own columns shade it a span a light level, a
|
||||
// previewed far column one span for the run.
|
||||
spans.push(ground(under));
|
||||
spans.push(Span {
|
||||
min: under.max,
|
||||
|
||||
@@ -709,15 +709,24 @@ pub fn lookup(
|
||||
];
|
||||
for (kind, url) in urls {
|
||||
let Some(url) = url else { continue };
|
||||
// registerTexture computes the hash first; a bad URL fails the lookup.
|
||||
let hash = texture_hash(url)?;
|
||||
// registerTexture computes the hash first. A cape or elytra URL the
|
||||
// hash cannot take is that texture's problem alone: vanilla's lookup
|
||||
// goes on to the next, and only the skin texture decides the default.
|
||||
let Some(hash) = texture_hash(url) else {
|
||||
if kind == Kind::Skin {
|
||||
return None;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if kind == Kind::Skin {
|
||||
skin = Some(skin_location(&hash));
|
||||
}
|
||||
match download(kind, url) {
|
||||
Download::Failed => return None,
|
||||
// A cape or elytra that cannot be fetched is simply not drawn; only
|
||||
// the skin itself falls back to the default one (`SkinManager.get`).
|
||||
Download::Failed if kind == Kind::Skin => return None,
|
||||
Download::Failed | Download::Ready => {}
|
||||
Download::Pending => state = Download::Pending,
|
||||
Download::Ready => {}
|
||||
}
|
||||
}
|
||||
if state != Download::Ready || require_secure && !textures.signed {
|
||||
@@ -809,6 +818,12 @@ pub struct Downloader {
|
||||
resource_root: std::path::PathBuf,
|
||||
/// How each `patch/` texture of the resource packs loaded, by location.
|
||||
resources: std::collections::HashMap<String, Download>,
|
||||
/// When each failed texture download was last tried, so it can be retried:
|
||||
/// a transient network error must not pin a player to the default skin for
|
||||
/// the rest of the session.
|
||||
failed: std::collections::HashMap<(Kind, String), std::time::Instant>,
|
||||
/// How long a failed texture download waits before it is tried again.
|
||||
retry_after: std::time::Duration,
|
||||
}
|
||||
/// `images` is bounded only so a long session on a busy server does not grow
|
||||
/// without end; the disk cache holds everything it drops.
|
||||
@@ -839,6 +854,8 @@ impl Downloader {
|
||||
drawn: Default::default(),
|
||||
resource_root: Default::default(),
|
||||
resources: Default::default(),
|
||||
failed: Default::default(),
|
||||
retry_after: std::time::Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
/// The resource packs changed (or were first loaded) at `root`: patch
|
||||
@@ -879,7 +896,8 @@ impl Downloader {
|
||||
};
|
||||
self.resources.insert(location.to_owned(), state);
|
||||
}
|
||||
/// How the download of `url` stands, starting it the first time.
|
||||
/// How the download of `url` stands, starting it the first time. A
|
||||
/// failure is permanent for `retry_after`, then tried again.
|
||||
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
|
||||
while let Ok((key, image)) = self.done.try_recv() {
|
||||
let state = match image {
|
||||
@@ -889,9 +907,13 @@ impl Downloader {
|
||||
Kind::Cape => self.queue(cape_location(&key.1), rgba),
|
||||
Kind::Elytra => {}
|
||||
}
|
||||
self.failed.remove(&key);
|
||||
Download::Ready
|
||||
}
|
||||
None => Download::Failed,
|
||||
None => {
|
||||
self.failed.insert(key.clone(), std::time::Instant::now());
|
||||
Download::Failed
|
||||
}
|
||||
};
|
||||
self.states.insert(key, state);
|
||||
}
|
||||
@@ -899,34 +921,57 @@ impl Downloader {
|
||||
return Download::Failed;
|
||||
};
|
||||
let key = (kind, hash);
|
||||
if let Some(state) = self.states.get(&key) {
|
||||
return *state;
|
||||
if let Some(state) = self.states.get(&key).copied() {
|
||||
let due = self
|
||||
.failed
|
||||
.get(&key)
|
||||
.is_none_or(|at| at.elapsed() >= self.retry_after);
|
||||
if state != Download::Failed || !due {
|
||||
return state;
|
||||
}
|
||||
self.states.remove(&key);
|
||||
self.failed.remove(&key);
|
||||
}
|
||||
self.states.insert(key.clone(), Download::Pending);
|
||||
let digest: String = skin_location(&key.1)["skins/".len()..].to_owned();
|
||||
let file = self
|
||||
.root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(&digest);
|
||||
let (url, send) = (url.to_owned(), self.send.clone());
|
||||
let (url, send, root) = (url.to_owned(), self.send.clone(), self.root.clone());
|
||||
std::thread::spawn(move || {
|
||||
let bytes = std::fs::read(&file).ok().or_else(|| {
|
||||
let body = http_get(&url)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(body)
|
||||
});
|
||||
let image = bytes
|
||||
.and_then(|b| crate::hud::decode_bytes(&b).ok())
|
||||
.and_then(|(w, h, rgba)| match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
});
|
||||
let image = Self::load_texture(&root, kind, &digest, &url, http_get);
|
||||
let _ = send.send((key, image));
|
||||
});
|
||||
Download::Pending
|
||||
}
|
||||
/// One texture's pixels: the disk cache's body if it decodes, else a fetch
|
||||
/// (`SkinTextureDownloader.downloadSkin`). Only a body that decodes is
|
||||
/// written to the cache: an error page or a half image must not be what a
|
||||
/// later start reads instead of fetching again.
|
||||
fn load_texture(
|
||||
root: &std::path::Path,
|
||||
kind: Kind,
|
||||
digest: &str,
|
||||
url: &str,
|
||||
fetch: impl FnOnce(&str) -> Option<Vec<u8>>,
|
||||
) -> Option<Vec<u8>> {
|
||||
let file = root
|
||||
.join(if digest.len() > 2 { &digest[..2] } else { "xx" })
|
||||
.join(digest);
|
||||
let decode = |bytes: &[u8]| -> Option<Vec<u8>> {
|
||||
let (w, h, rgba) = crate::hud::decode_bytes(bytes).ok()?;
|
||||
match kind {
|
||||
Kind::Skin => process_legacy_skin(w, h, &rgba),
|
||||
Kind::Cape => process_cape(w, h, &rgba),
|
||||
Kind::Elytra => Some(rgba),
|
||||
}
|
||||
};
|
||||
if let Some(image) = std::fs::read(&file).ok().and_then(|b| decode(&b)) {
|
||||
return Some(image);
|
||||
}
|
||||
let body = fetch(url)?;
|
||||
let image = decode(&body)?;
|
||||
let _ = std::fs::create_dir_all(file.parent()?);
|
||||
let _ = std::fs::write(&file, &body);
|
||||
Some(image)
|
||||
}
|
||||
/// Keeps a decoded skin for `again`, dropping the oldest past the bound.
|
||||
fn remember(&mut self, location: String, rgba: Vec<u8>) {
|
||||
if !self.images.contains_key(&location) {
|
||||
@@ -1516,4 +1561,156 @@ mod tests {
|
||||
"abc"
|
||||
);
|
||||
}
|
||||
|
||||
/// A cape or elytra that cannot be fetched takes only itself out of the
|
||||
/// picture: the player's skin still shows. Only the skin texture's own
|
||||
/// failure is the default skin.
|
||||
#[test]
|
||||
fn a_failed_cape_does_not_hide_the_skin() {
|
||||
let textures = super::Textures {
|
||||
skin: Some("https://textures.minecraft.net/texture/skin1".into()),
|
||||
cape: Some("https://textures.minecraft.net/texture/cape1".into()),
|
||||
elytra: None,
|
||||
signed: true,
|
||||
slim: false,
|
||||
};
|
||||
let skin = super::lookup(&textures, true, |kind, _| match kind {
|
||||
super::Kind::Skin => super::Download::Ready,
|
||||
_ => super::Download::Failed,
|
||||
});
|
||||
assert_eq!(skin, Some(super::skin_location("skin1")));
|
||||
// A cape URL the hash cannot take is the same: the skin shows.
|
||||
let bad_url = super::Textures {
|
||||
cape: Some("no-scheme".into()),
|
||||
..textures.clone()
|
||||
};
|
||||
assert_eq!(
|
||||
super::lookup(&bad_url, true, |_, _| super::Download::Ready),
|
||||
Some(super::skin_location("skin1"))
|
||||
);
|
||||
// The skin itself failing is still the default skin.
|
||||
assert_eq!(
|
||||
super::lookup(&textures, true, |_, _| super::Download::Failed),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// A 1x1 PNG of `rgba`.
|
||||
fn png(rgba: [u8; 4]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
let mut encoder = png::Encoder::new(&mut out, 1, 1);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer.write_image_data(&rgba).unwrap();
|
||||
writer.finish().unwrap();
|
||||
out
|
||||
}
|
||||
|
||||
/// A downloaded body reaches the disk cache only if it decodes; a broken
|
||||
/// cached file is fetched again and replaced. A failed fetch writes nothing.
|
||||
#[test]
|
||||
fn the_texture_cache_holds_only_what_decodes() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-cache-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let body = png([7, 8, 9, 255]);
|
||||
let file = root.join("ab").join("abcdef");
|
||||
let load = |fetched: Option<&[u8]>| {
|
||||
let fetched = fetched.map(<[u8]>::to_vec);
|
||||
super::Downloader::load_texture(
|
||||
&root,
|
||||
super::Kind::Elytra,
|
||||
"abcdef",
|
||||
"https://example/texture/abc.png",
|
||||
|_| fetched.clone(),
|
||||
)
|
||||
};
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// From then on the cache answers; no fetch is needed.
|
||||
assert_eq!(load(None), Some(vec![7, 8, 9, 255]));
|
||||
// A broken file is fetched again and replaced, not kept.
|
||||
std::fs::write(&file, b"<html>not a png</html>").unwrap();
|
||||
assert_eq!(load(Some(&body)), Some(vec![7, 8, 9, 255]));
|
||||
assert_eq!(std::fs::read(&file).unwrap(), body);
|
||||
// A body that does not decode is not cached at all.
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert_eq!(load(Some(b"<html>not a png</html>")), None);
|
||||
assert!(!file.exists());
|
||||
}
|
||||
|
||||
fn wait_state(
|
||||
d: &mut super::Downloader,
|
||||
kind: super::Kind,
|
||||
url: &str,
|
||||
want: super::Download,
|
||||
) -> super::Download {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
let state = d.state(kind, url);
|
||||
if state == want || std::time::Instant::now() > deadline {
|
||||
return state;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// A texture download that failed is tried again after the wait, instead of
|
||||
/// pinning the player to the default skin for the rest of the session.
|
||||
#[test]
|
||||
fn a_failed_download_is_retried_after_the_wait() {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let base = format!("http://{}", listener.local_addr().unwrap());
|
||||
let body = png([1, 2, 3, 4]);
|
||||
let served = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let count = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for (i, stream) in listener.incoming().take(2).enumerate() {
|
||||
let mut stream = stream.unwrap();
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(stream.try_clone().unwrap());
|
||||
reader.read_line(&mut line).unwrap();
|
||||
while reader.read_line(&mut String::new()).unwrap() > 2 {}
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
if i == 0 {
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
} else {
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(head.as_bytes()).unwrap();
|
||||
stream.write_all(&body).unwrap();
|
||||
}
|
||||
}
|
||||
});
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-test-skin-retry-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let mut d = super::Downloader::new(root);
|
||||
let url = format!("{base}/texture/retry.png");
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Failed),
|
||||
super::Download::Failed
|
||||
);
|
||||
// Within the wait the failure stands: no request goes out.
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Failed);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 1);
|
||||
// After it, the download is started again and takes the good answer.
|
||||
d.retry_after = std::time::Duration::ZERO;
|
||||
assert_eq!(d.state(super::Kind::Elytra, &url), super::Download::Pending);
|
||||
assert_eq!(
|
||||
wait_state(&mut d, super::Kind::Elytra, &url, super::Download::Ready),
|
||||
super::Download::Ready
|
||||
);
|
||||
assert_eq!(served.load(Ordering::SeqCst), 2);
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,10 @@ impl SoundEntity {
|
||||
pub enum Ticking {
|
||||
/// Not tickable.
|
||||
No,
|
||||
/// A compiled native owner updates this instance through `instance_mut`.
|
||||
/// The ordinary engine applies gain, pitch and position each unpaused tick;
|
||||
/// no vanilla entity behavior overwrites them. Stop and release when done.
|
||||
Native,
|
||||
/// `EntityBoundSoundInstance`.
|
||||
EntityBound,
|
||||
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
|
||||
@@ -611,7 +615,7 @@ impl SoundInstance {
|
||||
/// `TickableSoundInstance.tick`.
|
||||
pub fn tick(&mut self, ctx: &TickContext) {
|
||||
match &mut self.ticking {
|
||||
Ticking::No | Ticking::MusicLayer => {}
|
||||
Ticking::No | Ticking::Native | Ticking::MusicLayer => {}
|
||||
Ticking::EntityBound => {
|
||||
let entity = self
|
||||
.bound_entity
|
||||
@@ -1552,6 +1556,8 @@ pub mod test_backend {
|
||||
next: ChannelId,
|
||||
pub stopped: HashMap<ChannelId, bool>,
|
||||
pub volume: HashMap<ChannelId, f32>,
|
||||
pub pitch: HashMap<ChannelId, f32>,
|
||||
pub position: HashMap<ChannelId, [f64; 3]>,
|
||||
}
|
||||
impl Backend for Null {
|
||||
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
|
||||
@@ -1562,13 +1568,17 @@ pub mod test_backend {
|
||||
fn destroy(&mut self, c: ChannelId) {
|
||||
self.stopped.remove(&c);
|
||||
}
|
||||
fn set_pitch(&mut self, _: ChannelId, _: f32) {}
|
||||
fn set_pitch(&mut self, c: ChannelId, value: f32) {
|
||||
self.pitch.insert(c, value);
|
||||
}
|
||||
fn set_volume(&mut self, c: ChannelId, v: f32) {
|
||||
self.volume.insert(c, v);
|
||||
}
|
||||
fn set_attenuation(&mut self, _: ChannelId, _: Option<f32>) {}
|
||||
fn set_looping(&mut self, _: ChannelId, _: bool) {}
|
||||
fn set_position(&mut self, _: ChannelId, _: [f64; 3]) {}
|
||||
fn set_position(&mut self, c: ChannelId, value: [f64; 3]) {
|
||||
self.position.insert(c, value);
|
||||
}
|
||||
fn set_relative(&mut self, _: ChannelId, _: bool) {}
|
||||
fn start(&mut self, _: ChannelId, _: &Sound, _: bool) {}
|
||||
fn stop(&mut self, c: ChannelId) {
|
||||
@@ -1586,6 +1596,76 @@ pub mod test_backend {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn native_owned_loop_applies_updates_and_stops_through_the_ordinary_engine() {
|
||||
let mut registry = SoundRegistry::default();
|
||||
registry.add_file(
|
||||
"example",
|
||||
br#"{"loop":{"sounds":["machine/loop"]}}"#,
|
||||
&|_, _| true,
|
||||
);
|
||||
let mut engine = SoundEngine::new(registry);
|
||||
let mut backend = test_backend::Null::default();
|
||||
let mut instance = SoundInstance::new(
|
||||
"example:loop",
|
||||
4,
|
||||
0.1,
|
||||
0.5,
|
||||
LegacyRandom::new(1),
|
||||
true,
|
||||
0,
|
||||
Attenuation::None,
|
||||
[0.0; 3],
|
||||
true,
|
||||
);
|
||||
instance.ticking = Ticking::Native;
|
||||
let (id, result) = engine.play_new(instance, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
let channel = engine.channel(id).unwrap();
|
||||
assert_eq!(backend.volume[&channel], 0.1);
|
||||
let owned = engine.instance_mut(id).unwrap();
|
||||
owned.volume = 0.4;
|
||||
owned.pitch = 1.25;
|
||||
owned.position = [2.0, 3.0, 4.0];
|
||||
engine.tick(true, &TickContext::default(), &mut backend);
|
||||
assert_eq!(
|
||||
backend.volume[&channel], 0.1,
|
||||
"paused world sounds retain channel values"
|
||||
);
|
||||
assert_eq!(backend.pitch[&channel], 0.5);
|
||||
assert_eq!(backend.position[&channel], [0.0; 3]);
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
assert_eq!(backend.volume[&channel], 0.4);
|
||||
assert_eq!(backend.pitch[&channel], 1.25);
|
||||
assert_eq!(backend.position[&channel], [2.0, 3.0, 4.0]);
|
||||
let owned = engine.instance(id).unwrap();
|
||||
assert_eq!((owned.pitch, owned.position), (1.25, [2.0, 3.0, 4.0]));
|
||||
let replacement = owned.clone();
|
||||
assert!(engine.is_active(id));
|
||||
engine.stop(id, &mut backend);
|
||||
assert_eq!(backend.stopped.get(&channel), Some(&true));
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
engine.release(id);
|
||||
for _ in 0..20 {
|
||||
engine.tick(false, &TickContext::default(), &mut backend);
|
||||
}
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(!backend.stopped.contains_key(&channel));
|
||||
|
||||
let (id, result) = engine.play_new(replacement, true, &mut backend);
|
||||
assert_eq!(result, PlayResult::Started);
|
||||
engine.stop_all(&mut backend);
|
||||
assert!(!engine.is_active(id));
|
||||
assert!(backend.stopped.is_empty());
|
||||
assert!(engine.instance(id).is_some(), "the owner still retains it");
|
||||
engine.release(id);
|
||||
assert!(engine.instance(id).is_none());
|
||||
assert_eq!(engine.census()["playing"], 0);
|
||||
assert_eq!(engine.census()["ticking"], 0);
|
||||
assert_eq!(engine.census()["handles"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
|
||||
let ghast = RiddenSound {
|
||||
|
||||
@@ -1130,6 +1130,9 @@ pub fn main() -> horizon_client::Result<()> {
|
||||
main_hand: s.hand.as_deref().map(Into::into),
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -605,6 +605,10 @@ pub trait World {
|
||||
fn light(&self, pos: [i32; 3]) -> i32;
|
||||
fn smokey(&self, pos: [i32; 3]) -> bool;
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool;
|
||||
/// The block entity's `saveWithFullMetadata` at `pos`, `None` without one.
|
||||
fn block_entity_nbt(&self, _pos: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// An item stack as predicates see it.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -853,6 +857,16 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
|
||||
}
|
||||
}
|
||||
}
|
||||
// `matchesBlockEntity`: the block entity's data holds the pattern
|
||||
// (`NbtPredicate.matches`, `NbtUtils.compareNbt`); no block entity fails.
|
||||
if let Some(nbt) = v["nbt"].as_str() {
|
||||
let Ok((pattern, _)) = horizon_world::nbt::parse_snbt(nbt) else {
|
||||
return false;
|
||||
};
|
||||
if !crate::nbt_path::compare_nbt(&pattern, w.block_entity_nbt(pos).as_ref()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
/// `LocationPredicate`.
|
||||
@@ -1810,6 +1824,67 @@ mod tests {
|
||||
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
|
||||
}
|
||||
}
|
||||
/// `BlockPredicate.matchesBlockEntity`: an `nbt` test needs a block
|
||||
/// entity whose `saveWithFullMetadata` contains the pattern
|
||||
/// (`NbtPredicate.matches`), so it never matches a plain block.
|
||||
#[test]
|
||||
fn a_block_predicates_nbt_needs_the_block_entitys_data() {
|
||||
let w = Flat;
|
||||
let stone = serde_json::json!({"blocks": "minecraft:stone", "nbt": "{}"});
|
||||
assert!(!block(&stone, [0, 0, 0], &w), "stone has no block entity");
|
||||
assert!(block(
|
||||
&serde_json::json!({"blocks": "minecraft:stone"}),
|
||||
[0, 0, 0],
|
||||
&w
|
||||
));
|
||||
/// An unpowered beacon: its saved data has `Levels:0`.
|
||||
struct Beacon;
|
||||
impl World for Beacon {
|
||||
fn dimension(&self) -> &str {
|
||||
Flat.dimension()
|
||||
}
|
||||
fn weather(&self) -> (bool, bool) {
|
||||
Flat.weather()
|
||||
}
|
||||
fn biome(&self, p: [i32; 3]) -> String {
|
||||
Flat.biome(p)
|
||||
}
|
||||
fn structures(&self, p: [i32; 3]) -> Vec<String> {
|
||||
Flat.structures(p)
|
||||
}
|
||||
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
|
||||
("minecraft:beacon".into(), BTreeMap::new())
|
||||
}
|
||||
fn fluid(&self, _: [i32; 3]) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn can_see_sky(&self, _: [i32; 3]) -> bool {
|
||||
true
|
||||
}
|
||||
fn light(&self, _: [i32; 3]) -> i32 {
|
||||
15
|
||||
}
|
||||
fn smokey(&self, _: [i32; 3]) -> bool {
|
||||
false
|
||||
}
|
||||
fn in_tag(&self, _: &str, _: &str, _: &str) -> bool {
|
||||
false
|
||||
}
|
||||
fn block_entity_nbt(&self, _: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
horizon_world::nbt::parse_snbt(r#"{id:"minecraft:beacon",x:0,y:0,z:0,Levels:0}"#)
|
||||
.ok()
|
||||
.map(|(t, _)| t)
|
||||
}
|
||||
}
|
||||
let beacon = |nbt: &str| serde_json::json!({"blocks": "minecraft:beacon", "nbt": nbt});
|
||||
assert!(block(&beacon("{Levels:0}"), [0, 0, 0], &Beacon));
|
||||
assert!(block(&beacon("{}"), [0, 0, 0], &Beacon));
|
||||
assert!(!block(&beacon("{Levels:4}"), [0, 0, 0], &Beacon));
|
||||
assert!(
|
||||
!block(&beacon("{Levels:0b}"), [0, 0, 0], &Beacon),
|
||||
"an int, not a byte"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn weather_conditions_leave_absent_fields_unconstrained_and_compose() {
|
||||
let ctx = Entities {
|
||||
|
||||
@@ -1591,6 +1591,8 @@ pub struct ItemFrame {
|
||||
pub fixed: bool,
|
||||
pub invisible: bool,
|
||||
pub check_interval: u32,
|
||||
/// `Entity.getCustomName` (`CustomName`), copied onto the dropped frame.
|
||||
pub custom_name: Option<horizon_world::nbt::Tag>,
|
||||
}
|
||||
impl ItemFrame {
|
||||
pub fn step(&self) -> [i32; 3] {
|
||||
@@ -1620,6 +1622,16 @@ impl ItemFrame {
|
||||
std::array::from_fn(|i| c[i] + size[i] / 2.0),
|
||||
)
|
||||
}
|
||||
/// `ItemFrame.getFrameItemStackWithData`: `getFrameItemStack` (a glow
|
||||
/// frame's own item), then `DataComponents.CUSTOM_NAME` from
|
||||
/// `getCustomName`.
|
||||
pub fn item_stack_with_data(&self) -> ItemStack {
|
||||
let mut item_stack = ItemStack::new(self.kind(), 1);
|
||||
if let Some(name) = &self.custom_name {
|
||||
item_stack.data_mut().custom_name = Some(name.clone());
|
||||
}
|
||||
item_stack
|
||||
}
|
||||
}
|
||||
/// An armor stand (`ArmorStand`).
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
@@ -1952,7 +1952,8 @@ pub fn mob_from_nbt(
|
||||
}
|
||||
read_variant(&mut m, tag);
|
||||
m.fire_ticks = number(tag.get("Fire")).unwrap_or(0) as i32;
|
||||
m.air = number(tag.get("Air")).unwrap_or(300) as i32;
|
||||
// Entity.load: `input.getIntOr("Air", getMaxAirSupply())`, 4800 for a dolphin.
|
||||
m.air = number(tag.get("Air")).map_or(m.max_air_supply(), |air| air as i32);
|
||||
if m.species() == crate::mobs::Species::Allay {
|
||||
if let Some(Tag::List(inv)) = tag.get("Inventory") {
|
||||
for stack in inv.iter().filter_map(item_from_nbt) {
|
||||
@@ -3186,6 +3187,12 @@ pub fn item_frame_to_nbt(f: &ItemFrame, uuid: [i32; 4], raw: Option<&Tag>) -> Ta
|
||||
fall_distance: 0.0,
|
||||
};
|
||||
let mut tag = base.tag(raw);
|
||||
// `Entity.saveWithoutId`: `CustomName` before the frame's own fields.
|
||||
if let Some(name) = &f.custom_name {
|
||||
put(&mut tag, "CustomName", name.clone());
|
||||
} else if let Tag::Compound(map) = &mut tag {
|
||||
map.remove(&"CustomName".into());
|
||||
}
|
||||
put(&mut tag, "Facing", Tag::Byte(f.facing as i8));
|
||||
put(&mut tag, "block_pos", Tag::IntArray(f.pos.to_vec()));
|
||||
put(&mut tag, "ItemRotation", Tag::Byte(f.rotation as i8));
|
||||
@@ -3217,6 +3224,7 @@ pub fn item_frame_from_nbt(tag: &Tag, id: u32) -> Option<ItemFrame> {
|
||||
fixed: flag(tag, "Fixed"),
|
||||
invisible: flag(tag, "Invisible"),
|
||||
check_interval: 0,
|
||||
custom_name: tag.get("CustomName").cloned(),
|
||||
})
|
||||
}
|
||||
/// ArmorStand.addAdditionalSaveData with LivingEntity's `equipment`.
|
||||
@@ -6290,6 +6298,41 @@ mod fireball_nbt_tests {
|
||||
assert_eq!(item_frame_from_nbt(&frame, 1).unwrap().pos, [-1, 80, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_frame_drops_with_its_custom_name() {
|
||||
// `ItemFrame.getFrameItemStackWithData`: the frame item, then
|
||||
// `DataComponents.CUSTOM_NAME` from `getCustomName`. Glow frames
|
||||
// share it through `GlowItemFrame.getFrameItemStack`.
|
||||
for id in ["minecraft:item_frame", "minecraft:glow_item_frame"] {
|
||||
let tag = Tag::compound([
|
||||
("id", string(id)),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(2)),
|
||||
("CustomName", string("Gallery")),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&tag, 1).unwrap();
|
||||
let stack = frame.item_stack_with_data();
|
||||
assert_eq!(&*stack.item, id);
|
||||
assert_eq!(stack.count, 1);
|
||||
assert_eq!(stack.custom_name().as_deref(), Some("Gallery"));
|
||||
let saved = item_frame_to_nbt(&frame, [1, 2, 3, 4], None);
|
||||
assert_eq!(text(saved.get("CustomName")).as_deref(), Some("Gallery"));
|
||||
}
|
||||
let bare = Tag::compound([
|
||||
("id", string("minecraft:item_frame")),
|
||||
("Pos", doubles(&[0.5, 64.0, 0.5])),
|
||||
("Facing", Tag::Byte(3)),
|
||||
]);
|
||||
let frame = item_frame_from_nbt(&bare, 2).unwrap();
|
||||
assert!(frame.custom_name.is_none());
|
||||
assert!(frame.item_stack_with_data().custom_name().is_none());
|
||||
assert!(
|
||||
item_frame_to_nbt(&frame, [1, 2, 3, 4], Some(&bare))
|
||||
.get("CustomName")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projectile_shot_flag_round_trips_independently_of_age() {
|
||||
for id in [
|
||||
|
||||
@@ -214,15 +214,12 @@ mod tests {
|
||||
assert!(!furnace(stack("minecraft:iron_ore"), None, 0, 5).idle());
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-furnace-remainder-{}-{}-{count}",
|
||||
std::process::id(),
|
||||
fuel.replace(':', "-")
|
||||
));
|
||||
/// A data root holding one recipe, `test:ore` into `test:ingot`, and an item table
|
||||
/// with two fuels: `test:coal` (no remainder) and `test:cell` (a content mod's item
|
||||
/// that leaves `test:husk`). The folder is gone when this returns.
|
||||
fn data(name: &str) -> (Items, Recipes, Providers) {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-furnace-{name}-{}", std::process::id(),));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
@@ -248,6 +245,57 @@ mod tests {
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let providers = Providers::load(&root);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
(items, recipes, providers)
|
||||
}
|
||||
|
||||
/// The furnace sleeps while `idle`, so its tick must change nothing then: run the
|
||||
/// real tick on every idle case and compare, and check a furnace that can smelt
|
||||
/// isn't idle and does change.
|
||||
#[test]
|
||||
fn an_idle_furnace_tick_changes_nothing() {
|
||||
let (items, recipes, providers) = data("idle");
|
||||
let stack = |id: &str| Some(ItemStack::new(id, 1));
|
||||
let furnace = |input, fuel, output| Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [input, fuel, output],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 200,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let idle = [
|
||||
furnace(None, None, None),
|
||||
furnace(stack("test:ore"), None, None),
|
||||
furnace(None, stack("test:coal"), None),
|
||||
furnace(None, None, stack("test:ingot")),
|
||||
furnace(stack("test:ingot"), None, stack("test:ingot")),
|
||||
];
|
||||
for before in idle {
|
||||
assert!(before.idle(), "{before:?}");
|
||||
let mut after = before.clone();
|
||||
let mut drops = Vec::new();
|
||||
let lit = after.tick(&recipes, &items, &providers, &mut drops);
|
||||
assert_eq!(lit, None, "{before:?}");
|
||||
assert!(drops.is_empty(), "{before:?}");
|
||||
assert_eq!(format!("{after:?}"), format!("{before:?}"));
|
||||
}
|
||||
let mut busy = furnace(stack("test:ore"), stack("test:coal"), None);
|
||||
assert!(!busy.idle());
|
||||
let before = format!("{busy:?}");
|
||||
assert_eq!(
|
||||
busy.tick(&recipes, &items, &providers, &mut Vec::new()),
|
||||
Some(true)
|
||||
);
|
||||
assert_ne!(format!("{busy:?}"), before);
|
||||
}
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot` ([`data`]).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let (items, recipes, providers) =
|
||||
data(&format!("remainder-{}-{count}", fuel.replace(':', "-")));
|
||||
let mut furnace = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
@@ -264,7 +312,6 @@ mod tests {
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(lit, Some(true), "the fuel lit the furnace");
|
||||
(furnace, drops)
|
||||
}
|
||||
|
||||
@@ -247,6 +247,7 @@ mod native_shaders;
|
||||
mod native_ticks;
|
||||
mod native_visuals;
|
||||
mod native_world_columns;
|
||||
pub mod native_world_draw;
|
||||
mod native_world_edit;
|
||||
mod native_world_read;
|
||||
mod natural_spawning;
|
||||
@@ -441,6 +442,7 @@ pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_draw::NativeWorldDraw;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
pub use native_world_read::NativeBlockReadError;
|
||||
use std::{
|
||||
@@ -527,6 +529,9 @@ struct Options {
|
||||
/// `--dedicated`: run as a dedicated server; vanilla's server arguments
|
||||
/// (`--nogui`, `--port`, `--world`, `--universe`, `--initSettings`) follow.
|
||||
dedicated: Option<Vec<String>>,
|
||||
/// `--transfers SETTINGS`: a dedicated server takes a proxy's hand-over requests
|
||||
/// (`server.toml`'s `[transfer]`, which horizon-server passes; lan/arrivals.rs).
|
||||
transfers: Option<horizon_client::lan::arrivals::Settings>,
|
||||
/// `--benchmark SCENARIO`: run the benchmark, write its report and quit.
|
||||
benchmark: Option<String>,
|
||||
/// `--dev-mod PATH` (repeatable): content mods always on.
|
||||
@@ -662,6 +667,7 @@ impl Options {
|
||||
.ok()
|
||||
.filter(|n| !n.is_empty()),
|
||||
dedicated: None,
|
||||
transfers: None,
|
||||
benchmark: None,
|
||||
dev_mods: Vec::new(),
|
||||
execution_role: execution_role::ExecutionRole::Client,
|
||||
@@ -675,7 +681,7 @@ impl Options {
|
||||
match arg.as_str() {
|
||||
"--help" | "-h" => {
|
||||
println!(
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --transfers SETTINGS A dedicated server takes a proxy's hand-over requests (server.toml [transfer], docs/server.md)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --player-dir DIR This player's own files when several play in one game folder: options.txt, logs, horizon-player.txt, servers.dat, screenshots\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
|
||||
horizon_core::VANILLA_VERSION
|
||||
);
|
||||
return Ok(None);
|
||||
@@ -778,6 +784,10 @@ impl Options {
|
||||
"--name" => o.player_name = Some(value),
|
||||
"--session-file" => o.session_file = Some(value),
|
||||
"--control-socket" => o.control = Some(value.into()),
|
||||
"--transfers" => {
|
||||
o.transfers =
|
||||
Some(horizon_client::lan::arrivals::Settings::parse(&value)?)
|
||||
}
|
||||
"--time" => o.time = Some(value.parse()?),
|
||||
"--weather" => o.weather = Some(value),
|
||||
"--dimension" => {
|
||||
@@ -1005,9 +1015,11 @@ struct GameLevel<'w> {
|
||||
debug: bool,
|
||||
native_sounds: &'w mut Vec<creatures::NativeSound>,
|
||||
native_particles: &'w mut Vec<lan_particles::QueuedParticle>,
|
||||
/// The tool a mod's machine breaks blocks with (`native_machine_destroy_block`):
|
||||
/// `Block.dropResources` rolls the loot with it instead of with none.
|
||||
drop_tool: Option<ItemStack>,
|
||||
/// The block a mod's machine breaks and the tool it breaks it with
|
||||
/// (`native_machine_destroy_block`): `Block.dropResources` rolls that
|
||||
/// block's loot with the tool instead of with none. Neighbours that pop
|
||||
/// off because of the break drop as with no tool, as in vanilla.
|
||||
drop_tool: Option<((i32, i32, i32), Option<ItemStack>)>,
|
||||
/// Where `Block.dropResources` puts the drops instead of popping them,
|
||||
/// while a mod's machine breaks a block (`native_machine_destroy_block`).
|
||||
drop_into: Option<Vec<ItemStack>>,
|
||||
@@ -1991,6 +2003,15 @@ struct WorldTables {
|
||||
/// The values of the data map types native mods registered
|
||||
/// (`native_data_map_types`).
|
||||
data_maps: native_data_maps::DataMaps,
|
||||
/// The entity type, biome and damage type tags with the packs' over the
|
||||
/// game's own, by registry (`TagLoader.loadTagsForExistingRegistries`):
|
||||
/// what the predicates' `#tag`s of those registries read.
|
||||
pack_tags: HashMap<&'static str, HashMap<String, HashSet<String>>>,
|
||||
}
|
||||
|
||||
impl WorldTables {
|
||||
/// The registries [`WorldTables::pack_tags`] holds.
|
||||
const PACK_TAG_REGISTRIES: [&'static str; 3] = ["entity_type", "worldgen/biome", "damage_type"];
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
@@ -3869,7 +3890,14 @@ impl Game<'_> {
|
||||
/// `HoneyBlock.showSlideParticles` (entity event 53): five honey block
|
||||
/// particles at the entity.
|
||||
fn honey_slide_particles(&mut self, at: [f64; 3]) {
|
||||
self.honey_entity_particles(at, 53);
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: the crumb count of entity event 53 or 54.
|
||||
fn honey_entity_particles(&mut self, at: [f64; 3], event: i8) {
|
||||
use horizon_client::particles::{ParticleOptions, ParticleType};
|
||||
let Some(count) = horizon_client::landing::honey_particle_count(event) else {
|
||||
return;
|
||||
};
|
||||
let Some(honey) = self
|
||||
.pack
|
||||
.assets
|
||||
@@ -3884,7 +3912,7 @@ impl Game<'_> {
|
||||
at[2].floor() as i32,
|
||||
];
|
||||
if let Some((sprite, tint)) = self.particle_sprite(honey, block) {
|
||||
for _ in 0..5 {
|
||||
for _ in 0..count {
|
||||
self.particles.add(
|
||||
&ParticleOptions::block(ParticleType::Block, sprite.clone(), tint),
|
||||
at,
|
||||
@@ -6218,8 +6246,15 @@ impl Game<'_> {
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// ServerPlayerGameMode.destroyBlock.
|
||||
// ServerPlayerGameMode.destroyBlock (and MultiPlayerGameMode.destroyBlock
|
||||
// on the client): a GameMasterBlock stays when the player cannot use
|
||||
// game master blocks (Player.canUseGameMasterBlocks).
|
||||
let state = self.world.block(pos[0], pos[1], pos[2]);
|
||||
if !self.can_use_game_master_blocks()
|
||||
&& is_game_master_block(&self.res.facts().get(state).name)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
// Native mods, once the game allowed the break: a canceled one keeps
|
||||
// the block, and a guest's acknowledgement puts it back in its world.
|
||||
let mut canceled = false;
|
||||
@@ -7719,8 +7754,9 @@ impl Game<'_> {
|
||||
self.world.weather.tick_timers(&mut self.world.random);
|
||||
}
|
||||
self.world.weather.tick_levels();
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
let _ = self.finish_player_autosaves(false);
|
||||
// MinecraftServer.tickServer's countdown, then autoSave: three
|
||||
// hundred seconds of ticks at the rate the server runs at now.
|
||||
@@ -11641,6 +11677,7 @@ impl Game<'_> {
|
||||
laps.lap("eg_cracks");
|
||||
self.finish_async_entity_culling();
|
||||
laps.lap("eg_culling_finish");
|
||||
self.native_world_geometry(camera, alpha as f32, &mut out);
|
||||
(
|
||||
out,
|
||||
static_block_entities,
|
||||
@@ -17195,7 +17232,7 @@ impl Game<'_> {
|
||||
unloaded.iter().map(|c| (c.x, c.z)).collect();
|
||||
self.world
|
||||
.block_entities
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
.unload(|p| gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
self.world
|
||||
.block_names
|
||||
.retain(|p, _| !gone.contains(&(p.0 >> 4, p.2 >> 4)));
|
||||
@@ -19947,6 +19984,14 @@ impl Game<'_> {
|
||||
None => self.res.stations(),
|
||||
}
|
||||
}
|
||||
/// The enchantments item names and tooltips read: a server's, with the
|
||||
/// entries its data packs add, else the world's.
|
||||
fn enchantments(&self) -> &horizon_client::enchant::Enchantments {
|
||||
match self.remote.as_ref().and_then(|r| r.enchantments.as_ref()) {
|
||||
Some(e) => e,
|
||||
None => &self.stations().enchantments,
|
||||
}
|
||||
}
|
||||
/// The world's packs' content files, none without packs.
|
||||
/// The biome table the world plays with, `None` before a world opens.
|
||||
fn packed_biomes(&self) -> Option<Arc<Biomes>> {
|
||||
@@ -20026,6 +20071,20 @@ impl Game<'_> {
|
||||
self.res.core.collision.clone(),
|
||||
&causes_suffocation,
|
||||
)?;
|
||||
// A registry whose pack tags don't load keeps the game's own tags
|
||||
// (`in_tag` falls back to them), rather than matching no tag at all.
|
||||
let pack_tags = WorldTables::PACK_TAG_REGISTRIES
|
||||
.into_iter()
|
||||
.filter_map(|registry| {
|
||||
match horizon_client::items::load_tags(root, registry, &overlay) {
|
||||
Ok(tags) => Some((registry, tags)),
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the world's {registry} tags are not applied: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
Some(WorldTables {
|
||||
suffocation,
|
||||
@@ -20034,6 +20093,7 @@ impl Game<'_> {
|
||||
recipes,
|
||||
stations,
|
||||
data_maps,
|
||||
pack_tags,
|
||||
}),
|
||||
health,
|
||||
))
|
||||
@@ -22355,6 +22415,19 @@ fn debug_crosshair_drawn(
|
||||
enabled && first_person && !hide_gui && !panorama
|
||||
}
|
||||
|
||||
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
|
||||
/// block, jigsaw and the test blocks.
|
||||
fn is_game_master_block(name: &str) -> bool {
|
||||
horizon_client::command_block::Mode::of_block(name).is_some()
|
||||
|| matches!(
|
||||
name,
|
||||
"minecraft:structure_block"
|
||||
| "minecraft:jigsaw"
|
||||
| "minecraft:test_block"
|
||||
| "minecraft:test_instance_block"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -209,8 +209,21 @@ impl adv::World for View<'_, '_> {
|
||||
fn smokey(&self, p: [i32; 3]) -> bool {
|
||||
horizon_client::beehive::smokey(&self.0.mob_world_base(&[]), p)
|
||||
}
|
||||
fn block_entity_nbt(&self, p: [i32; 3]) -> Option<horizon_world::nbt::Tag> {
|
||||
self.0.block_entity_nbt(p)
|
||||
}
|
||||
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
|
||||
let items = &self.0.res.items();
|
||||
// The world's packs' tags over the game's own, as the server's
|
||||
// registries bind them (`TagLoader.loadTagsForExistingRegistries`).
|
||||
if let Some(tags) = self
|
||||
.0
|
||||
.world_tables
|
||||
.as_ref()
|
||||
.and_then(|t| t.pack_tags.get(registry))
|
||||
{
|
||||
return tags.get(tag).is_some_and(|t| t.contains(id));
|
||||
}
|
||||
let items = self.0.items();
|
||||
match registry {
|
||||
"item" => items.item_in_tag(tag, id),
|
||||
"block" => items.block_in_tag(tag, id),
|
||||
@@ -871,10 +884,7 @@ impl Game<'_> {
|
||||
.recipe_book
|
||||
.add_recipes(&world_stations!(self).recipe_index, &rewards);
|
||||
for table in &a.rewards.loot {
|
||||
let items = self
|
||||
.res
|
||||
.items()
|
||||
.loot_table(table, false, None, &mut self.world.random);
|
||||
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
|
||||
for s in items {
|
||||
if let Some(rest) = self.give(s)
|
||||
&& let Some(eye) = self.eye_position()
|
||||
|
||||
@@ -1659,6 +1659,11 @@ impl Game<'_> {
|
||||
if text.is_empty() {
|
||||
return Err("command TEXT".into());
|
||||
}
|
||||
// A native mod's command first, as typed chat does; the
|
||||
// server's console cannot run a client mod's command.
|
||||
if self.client_mod_command(text) {
|
||||
return ok(json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }));
|
||||
}
|
||||
let command = format!("/{text}");
|
||||
// Single player's console is its server thread's.
|
||||
if let Some(server) = &self.integrated {
|
||||
|
||||
@@ -21,6 +21,48 @@ use horizon_core::{
|
||||
};
|
||||
use std::{collections::HashMap, path::PathBuf, sync::Arc};
|
||||
|
||||
/// Velocity `AbstractMinecart.push` adds: the cart, and a quarter on the stand.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct CartShove {
|
||||
cart_x: f64,
|
||||
cart_z: f64,
|
||||
stand_x: f64,
|
||||
stand_z: f64,
|
||||
}
|
||||
/// `ArmorStand.pushEntities` for one cart. `None` when the cart is not the
|
||||
/// plain rideable kind, the boxes miss, `distanceToSqr` is past 0.2, the
|
||||
/// stand is a passenger of the cart, or the horizontal gap is under
|
||||
/// `1.0E-4F` (where `AbstractMinecart.push` does nothing).
|
||||
fn armor_stand_cart_shove(
|
||||
stand_pos: Vec3,
|
||||
stand_box: Aabb,
|
||||
cart_pos: Vec3,
|
||||
cart_box: Aabb,
|
||||
kind: super::minecarts::CartKind,
|
||||
cart_carries_stand: bool,
|
||||
) -> Option<CartShove> {
|
||||
if kind != super::minecarts::CartKind::Rideable
|
||||
|| cart_carries_stand
|
||||
|| !stand_box.intersects(cart_box)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let (dx, dy, dz) = (
|
||||
stand_pos.x - cart_pos.x,
|
||||
stand_pos.y - cart_pos.y,
|
||||
stand_pos.z - cart_pos.z,
|
||||
);
|
||||
if dx * dx + dy * dy + dz * dz > 0.2 {
|
||||
return None;
|
||||
}
|
||||
let (xa, za) = super::minecarts::cart_push_from(stand_pos, cart_pos)?;
|
||||
Some(CartShove {
|
||||
cart_x: -xa,
|
||||
cart_z: -za,
|
||||
stand_x: xa / 4.0,
|
||||
stand_z: za / 4.0,
|
||||
})
|
||||
}
|
||||
/// `EquipmentLayerRenderer.getColorForLayer`: a dyeable layer (one with a
|
||||
/// colour when undyed) takes the stack's `minecraft:dyed_color`, else that
|
||||
/// colour; any other layer stays white, dyed or not. The result is in the
|
||||
@@ -879,6 +921,9 @@ impl Game<'_> {
|
||||
true,
|
||||
);
|
||||
}
|
||||
// LivingEntity.aiStep calls pushEntities after travel. The stand's
|
||||
// own doPush is empty; only this shove runs.
|
||||
self.push_rideable_minecarts(i);
|
||||
let a = &mut self.armor_stands[i];
|
||||
if !a.environment_tick(&self.world, self.res.facts(), self.pack.assets.registry) {
|
||||
dead.push(i);
|
||||
@@ -889,6 +934,50 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// `ArmorStand.pushEntities`: a plain minecart (`RIDABLE_MINECARTS`,
|
||||
/// `AbstractMinecart.isRideable`) whose box meets the stand's is shoved
|
||||
/// when `distanceToSqr` is at most 0.2. `AbstractMinecart.push` of a
|
||||
/// non-cart adds the push to the cart and a quarter of it to the stand
|
||||
/// (`Entity.push(double)` does not consult `isPushable`). A stand with no
|
||||
/// physics (`Marker` or `NoGravity`, `ArmorStand.hasPhysics` / `noPhysics`)
|
||||
/// or one riding that cart does not shove it.
|
||||
fn push_rideable_minecarts(&mut self, i: usize) {
|
||||
let stand = &self.armor_stands[i];
|
||||
if stand.marker || stand.no_gravity {
|
||||
return;
|
||||
}
|
||||
let stand_pos = stand.position;
|
||||
let stand_box = stand.bounds();
|
||||
let vehicle = stand.vehicle;
|
||||
let mut applied = Vec::new();
|
||||
for (j, cart) in self.carts.iter().enumerate() {
|
||||
let Some(shove) = armor_stand_cart_shove(
|
||||
stand_pos,
|
||||
stand_box,
|
||||
cart.position,
|
||||
cart.bounds(),
|
||||
cart.kind,
|
||||
vehicle == Some(cart.id),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
applied.push((j, shove));
|
||||
}
|
||||
let mut stand_x = 0.0;
|
||||
let mut stand_z = 0.0;
|
||||
for (j, shove) in applied {
|
||||
let cart = &mut self.carts[j];
|
||||
cart.velocity.x += shove.cart_x;
|
||||
cart.velocity.z += shove.cart_z;
|
||||
stand_x += shove.stand_x;
|
||||
stand_z += shove.stand_z;
|
||||
}
|
||||
if stand_x != 0.0 || stand_z != 0.0 {
|
||||
let stand = &mut self.armor_stands[i];
|
||||
stand.velocity.x += stand_x;
|
||||
stand.velocity.z += stand_z;
|
||||
}
|
||||
}
|
||||
/// An armor stand is a `LivingEntity`, so the egg under it
|
||||
/// (`getOnPosLegacy`) cracks like under a mob: `TurtleEggBlock.fallOn` one
|
||||
/// landing in 3 (`Entity.move`'s `checkFallDamage`), then `stepOn` one tick
|
||||
@@ -1683,6 +1772,84 @@ mod burn_drop_tests {
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod push_tests {
|
||||
use super::super::minecarts::{CartKind, Minecart};
|
||||
use super::*;
|
||||
|
||||
fn boxes(kind: CartKind, cart_at: Vec3) -> (Aabb, Aabb) {
|
||||
let stand = ArmorStand::new(1, Vec3::ZERO, 0.0);
|
||||
let cart = Minecart::new(2, kind, cart_at);
|
||||
(stand.bounds(), cart.bounds())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rideable_minecart_inside_the_0_2_gate_is_shoved() {
|
||||
// 0.25² is inside the 0.2 gate; 0.5² is not. The function's Some/None is the check.
|
||||
let near = Vec3::new(0.25, 0.0, 0.0);
|
||||
let far = Vec3::new(0.5, 0.0, 0.0);
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
let shove = armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false,
|
||||
)
|
||||
.expect("a rideable cart 0.25 away is shoved");
|
||||
// AbstractMinecart.push: gap 0.25, length 0.25, pow capped at 1, then 0.1F * 0.5.
|
||||
// The cart moves east, away from the stand; the stand takes a quarter west.
|
||||
let scale = f64::from(0.1_f32) * 0.5;
|
||||
assert_eq!(shove.cart_x, scale);
|
||||
assert_eq!(shove.cart_z, 0.0);
|
||||
assert_eq!(shove.stand_x, -scale / 4.0);
|
||||
assert_eq!(shove.stand_z, 0.0);
|
||||
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, far);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
far,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
false
|
||||
)
|
||||
.is_none(),
|
||||
"past 0.2 the cart stays"
|
||||
);
|
||||
for kind in [
|
||||
CartKind::Chest,
|
||||
CartKind::Hopper,
|
||||
CartKind::Furnace,
|
||||
CartKind::Tnt,
|
||||
CartKind::CommandBlock,
|
||||
CartKind::Spawner,
|
||||
] {
|
||||
let (stand_box, cart_box) = boxes(kind, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(Vec3::ZERO, stand_box, near, cart_box, kind, false)
|
||||
.is_none(),
|
||||
"{} is not a rideable minecart",
|
||||
kind.id()
|
||||
);
|
||||
}
|
||||
let (stand_box, cart_box) = boxes(CartKind::Rideable, near);
|
||||
assert!(
|
||||
armor_stand_cart_shove(
|
||||
Vec3::ZERO,
|
||||
stand_box,
|
||||
near,
|
||||
cart_box,
|
||||
CartKind::Rideable,
|
||||
true
|
||||
)
|
||||
.is_none(),
|
||||
"a cart does not shove the stand riding it"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod layer_color_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
|
||||
@@ -697,11 +697,13 @@ impl Game<'_> {
|
||||
let block = Text::translate(&format!("block.{block}"), &[]);
|
||||
Text::translate("entity.minecraft.falling_block_type", &[block])
|
||||
}
|
||||
Target::Mob(id) => self
|
||||
.mobs
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.map_or_else(|| Text::translate("entity.", &[]), |m| m.command_name()),
|
||||
Target::Mob(id) => self.mobs.iter().find(|m| m.id == id).map_or_else(
|
||||
|| Text::translate("entity.", &[]),
|
||||
|m| match self.mob_team_of(m) {
|
||||
Some(team) => team.formatted_name(m.command_name()),
|
||||
None => m.command_name(),
|
||||
},
|
||||
),
|
||||
other => {
|
||||
// Entity.getDisplayName: the CustomName component, else the type.
|
||||
let custom = match other {
|
||||
@@ -3342,10 +3344,6 @@ impl Game<'_> {
|
||||
// reopen, so it follows the command.
|
||||
self.level.set_difficulty(i);
|
||||
self.sync_rules();
|
||||
if i == 0 {
|
||||
// Peaceful removes hostile mobs.
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// MinecraftServer.setDifficulty: every player, guests included, is
|
||||
// sent the new difficulty.
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
|
||||
@@ -354,6 +354,10 @@ impl Game<'_> {
|
||||
slot.extra.tame = old.extra.tame;
|
||||
slot.extra.owner = old.extra.owner;
|
||||
}
|
||||
// Entity.load mutates the existing living entity; its damage
|
||||
// cooldown and previous-hit amount are not read from saved NBT.
|
||||
slot.invulnerable = old.invulnerable;
|
||||
slot.last_hurt = old.last_hurt;
|
||||
slot.ai = old.ai;
|
||||
// Mob.readAdditionalSaveData updates persistent home fields,
|
||||
// while retaining the rest of the live AI/runtime state.
|
||||
@@ -625,35 +629,34 @@ impl Game<'_> {
|
||||
}
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => {
|
||||
if let Some(tag) = self.moving_piston_data(*p) {
|
||||
return Ok(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(*p).or_else(|| self.pot_data(*p)) {
|
||||
return Ok(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(*p);
|
||||
let e = self
|
||||
.world
|
||||
.block_entities
|
||||
.get(&key)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
|
||||
// BlockEntity.saveWithFullMetadata: no chunk-only keepPacked,
|
||||
// and the lock and custom name the model does not hold.
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) =
|
||||
(self.world.block_names.get(&key), &mut tag)
|
||||
{
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Ok(tag)
|
||||
}
|
||||
DataRef::Block(p) => self
|
||||
.block_entity_nbt(*p)
|
||||
.ok_or_else(|| err("commands.data.block.invalid", &[])),
|
||||
DataRef::Storage(id) => Ok(self.score.storage.get(id)),
|
||||
}
|
||||
}
|
||||
/// `BlockEntity.saveWithFullMetadata` of the block entity at `p`: no
|
||||
/// chunk-only keepPacked, and the lock and custom name the model does
|
||||
/// not hold. `None` where there is no block entity.
|
||||
pub(in crate::game) fn block_entity_nbt(&self, p: [i32; 3]) -> Option<Tag> {
|
||||
// A live moving piston is the level's block entity there (#9617).
|
||||
if let Some(tag) = self.moving_piston_data(p) {
|
||||
return Some(tag);
|
||||
}
|
||||
if let Some(tag) = self.comparator_data(p).or_else(|| self.pot_data(p)) {
|
||||
return Some(tag);
|
||||
}
|
||||
let key = (p[0], p[1], p[2]);
|
||||
let name = self.data_block_name(p);
|
||||
let e = self.world.block_entities.get(&key)?;
|
||||
let mut tag = save::block_entity_data(key, e, &name, self.world.block_lock(key));
|
||||
if let (Some(custom), Tag::Compound(map)) = (self.world.block_names.get(&key), &mut tag) {
|
||||
map.entry("CustomName".into())
|
||||
.or_insert_with(|| Tag::String(custom.as_str().into()));
|
||||
}
|
||||
self.restore_loot_table(key, &mut tag);
|
||||
Some(tag)
|
||||
}
|
||||
pub(in crate::game) fn set_data_of(&mut self, r: &DataRef, tag: Tag) -> R<()> {
|
||||
match r {
|
||||
DataRef::Entity(t) => self.apply_entity_nbt(*t, tag),
|
||||
|
||||
@@ -244,7 +244,7 @@ impl Game<'_> {
|
||||
/// hand them the next jobs as soon as each one is done, rather than once
|
||||
/// a tick.
|
||||
pub(in crate::game) fn pump_chunks(&mut self, until: Instant, paused: bool) {
|
||||
// Force-loaded chunks too: decorating in vanilla's order, a chunk
|
||||
// Force-loaded chunks too, a mod's held ones as `/forceload`'s: decorating in vanilla's order, a chunk
|
||||
// waits for its neighbours far more often than once a tick allows.
|
||||
// And the players' chunks while they load, as vanilla's server runs
|
||||
// its chunk tasks while it waits for the next tick
|
||||
@@ -255,7 +255,10 @@ impl Game<'_> {
|
||||
// only asked for by the tick's own `update_world`.
|
||||
let players = !paused && self.spawn_wait.is_empty();
|
||||
while self.pregen_runs_here()
|
||||
|| ((players || !self.streaming.forced.is_empty()) && self.streaming.busy())
|
||||
|| ((players
|
||||
|| !self.streaming.forced.is_empty()
|
||||
|| !self.streaming.mod_forced.is_empty())
|
||||
&& self.streaming.busy())
|
||||
{
|
||||
let now = Instant::now();
|
||||
if now >= until {
|
||||
|
||||
@@ -917,11 +917,10 @@ impl Game<'_> {
|
||||
}
|
||||
"chat" => {
|
||||
let text = line.trim_start().strip_prefix("chat").unwrap_or("").trim();
|
||||
if self.remote.is_some() {
|
||||
self.send_chat(text);
|
||||
return ok();
|
||||
}
|
||||
self.require_world()?;
|
||||
// As if typed: a client mod's command runs before the line
|
||||
// reaches the server, and the Chat option's restrictions and
|
||||
// the recent list apply to a connected server too.
|
||||
self.handle_chat_input(text);
|
||||
Ok(Some(
|
||||
json!({ "ok": true, "chat": self.ui.chat.recent_lines(5) }),
|
||||
|
||||
@@ -327,6 +327,9 @@ impl Game<'_> {
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
chest: self.armor[1].as_ref().map(|s| s.item.clone()),
|
||||
legs: self.armor[2].as_ref().map(|s| s.item.clone()),
|
||||
feet: self.armor[3].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
spectator: self.spectator(),
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(super) mod events;
|
||||
mod management;
|
||||
mod report;
|
||||
mod status;
|
||||
mod transfer;
|
||||
|
||||
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
|
||||
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
|
||||
@@ -985,9 +986,6 @@ impl Game<'_> {
|
||||
};
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
// DedicatedServer.initServer: a legacy `announce-player-achievements`
|
||||
// sets the show_advancement_messages rule once, as the file is read.
|
||||
if let Some(announce) = props.announce_player_achievements {
|
||||
@@ -1025,6 +1023,18 @@ impl Game<'_> {
|
||||
.filter(|b| png_size(b) == Some((64, 64)));
|
||||
// Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file.
|
||||
let forwarding_secret = forwarding_secret(online.is_some())?;
|
||||
// `[transfer]` on: a player's only way in must be the proxy, or one who joins this server
|
||||
// directly finds the older copy of itself that a hand-over left here, and items double.
|
||||
let transfers = self
|
||||
.options
|
||||
.transfers
|
||||
.clone()
|
||||
.map(horizon_client::lan::arrivals::Transfers::new);
|
||||
if transfers.is_some() && forwarding_secret.is_none() {
|
||||
return Err("server.toml's [transfer] is on, but players can join this server without the proxy: \
|
||||
turn on Velocity forwarding (forwarding.secret, docs/velocity-forwarding.md) or turn [transfer] off"
|
||||
.into());
|
||||
}
|
||||
let net = NetConfig {
|
||||
bind: (!props.server_ip.is_empty())
|
||||
.then(|| props.server_ip.parse().ok())
|
||||
@@ -1053,6 +1063,7 @@ impl Game<'_> {
|
||||
gate: Some(gate),
|
||||
online,
|
||||
forwarding_secret,
|
||||
arrivals: transfers.as_ref().map(|t| t.expected.clone()),
|
||||
log: Some(NetLog {
|
||||
write: Arc::new(log_from),
|
||||
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
|
||||
@@ -1114,6 +1125,22 @@ impl Game<'_> {
|
||||
log("WARN", "Perhaps a server is already running on that port?");
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(transfers) = transfers
|
||||
&& let Some(host) = self.lan.as_mut()
|
||||
{
|
||||
let s = &transfers.settings;
|
||||
log(
|
||||
"INFO",
|
||||
&format!(
|
||||
"Transfers on (server.toml [transfer]): players expected for {} s ({} s at most, {} at once), frozen for {} ticks at most",
|
||||
s.expect.as_secs(),
|
||||
s.max_expect.as_secs(),
|
||||
s.max_expected,
|
||||
s.max_freeze_ticks
|
||||
),
|
||||
);
|
||||
host.hub.transfers = Some(transfers);
|
||||
}
|
||||
self.sync_ops(&lists);
|
||||
let anticheat = load_anticheat();
|
||||
let chat_filter = load_chat_filter();
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! (including their positions) and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
|
||||
//! `server-events <since>` answers the typed events after that number (events.rs); `management <connection>
|
||||
//! <json>` edits the lists as vanilla's management protocol does (management.rs).
|
||||
//! `player-expect <uuid> [seconds]` and `player-export <uuid> [freeze]` are a proxy's, for a player it moves
|
||||
//! between two servers (transfer.rs).
|
||||
use super::{ServerState, capture_log};
|
||||
use crate::game::Game;
|
||||
use serde_json::{Value, json};
|
||||
@@ -47,6 +49,10 @@ impl Game<'_> {
|
||||
super::events::since(since.trim().parse().unwrap_or(0))
|
||||
}
|
||||
Some(("management", rest)) => self.management(state, rest),
|
||||
// A proxy moving a player here or away (transfer.rs, lan/arrivals.rs).
|
||||
Some((verb, rest)) if verb.starts_with("player-") => {
|
||||
self.player_request(verb, rest)
|
||||
}
|
||||
Some(("console", line)) => {
|
||||
// Command feedback is logged as system chat; the reply carries the text alone.
|
||||
let output: Vec<String> = capture_log(|| self.console_command(state, line))
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! A proxy's requests on the control endpoint, for a player it moves between two servers:
|
||||
//! `player-expect <uuid> [seconds]` (the target is told who is coming) and
|
||||
//! `player-export <uuid> [freeze]` (the source answers with the player's record). They run
|
||||
//! between two ticks, on the server thread, so the player is never caught half-way through a
|
||||
//! tick. The rules are `lan/arrivals.rs`'s. All of them are refused while `server.toml`'s
|
||||
//! `[transfer]` is off.
|
||||
use crate::game::{Dim, Game, lan::LanHost};
|
||||
use horizon_client::lan::arrivals;
|
||||
use horizon_protocol::packets::play::{self, Clientbound as C};
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Instant;
|
||||
|
||||
impl Game<'_> {
|
||||
/// A proxy's `player-*` request; the reply is one JSON line, `{"ok": true, ...}` or
|
||||
/// `{"error": "..."}`.
|
||||
pub(super) fn player_request(&mut self, verb: &str, args: &str) -> Value {
|
||||
let Some(host) = self.lan.as_ref() else {
|
||||
return json!({ "error": "the server is not open to players" });
|
||||
};
|
||||
if host.hub.transfers.is_none() {
|
||||
return json!({ "error": arrivals::OFF });
|
||||
}
|
||||
match verb {
|
||||
"player-expect" => match self.lan.as_mut() {
|
||||
Some(host) => arrivals::expect_request(&mut host.hub, args, Instant::now()),
|
||||
None => json!({ "error": "the server is not open to players" }),
|
||||
},
|
||||
"player-export" => self.player_export(args),
|
||||
other => json!({ "error": format!("unknown request {other:?}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// `player-export <uuid> [freeze]`: closes the player's menus as vanilla does when a menu closes
|
||||
/// (the cursor stack and the crafting grids go back into the inventory), then answers with the
|
||||
/// player's record (`arrivals::export_record`). With `freeze`, a hand-over starts: the player's
|
||||
/// own inputs stop being acted on until it ends, and a later export answers with the same record.
|
||||
fn player_export(&mut self, args: &str) -> Value {
|
||||
let mut parts = args.split_whitespace();
|
||||
let Some(uuid) = parts.next().and_then(arrivals::parse_uuid) else {
|
||||
return json!({ "error": "player-export needs the player's unique id" });
|
||||
};
|
||||
let freeze = match parts.next() {
|
||||
None => false,
|
||||
Some("freeze") => true,
|
||||
Some(_) => return json!({ "error": "the only option of player-export is freeze" }),
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return json!({ "error": "player-export takes a unique id and at most freeze" });
|
||||
}
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return json!({ "error": "the server is not open to players" });
|
||||
};
|
||||
let reply = self.export_from(&mut host, uuid, freeze);
|
||||
self.lan = Some(host);
|
||||
reply
|
||||
}
|
||||
|
||||
fn export_from(&mut self, host: &mut LanHost, uuid: u128, freeze: bool) -> Value {
|
||||
let i = match arrivals::check_exportable(&host.hub, uuid) {
|
||||
Ok(i) => i,
|
||||
Err(why) => return json!({ "error": why }),
|
||||
};
|
||||
// A repeat while the hand-over lasts: the same record, and nothing touched.
|
||||
if let Some(h) = &host.hub.guests[i].handover {
|
||||
return arrivals::export_reply(&host.hub, i, h.record.clone());
|
||||
}
|
||||
let conn = host.hub.guests[i].conn;
|
||||
let window = host.hub.guests[i].menu.as_ref().map(|m| m.window);
|
||||
// The menu closes in the player's own level, as when it leaves (`game/lan.rs`'s close of
|
||||
// every guest): a chest's lid, a villager's trade and a dropped cursor stack belong there.
|
||||
let back = self.dim;
|
||||
let level = Dim::from_id(&host.hub.guests[i].dimension);
|
||||
if let Err(e) = self.enter_level(Some(host), level) {
|
||||
return json!({ "error": format!("the player's level could not be opened: {e}") });
|
||||
}
|
||||
// Nothing is sent to the client by this: it is for a player who is leaving.
|
||||
self.guest_close_all(host, conn);
|
||||
// The inventory's own 2x2 grid too (`InventoryMenu.removed`): `guest_close_all` empties it
|
||||
// only with a real `ContainerClose(0)`, and a frozen client's is dropped. Its items are
|
||||
// not in the player compound, so they would be lost with `player-release moved`.
|
||||
self.lan_crafting_closed(host, conn, Some(0));
|
||||
if let Err(e) = self.enter_level(Some(host), back) {
|
||||
log::error!("could not reopen {}: {e}", back.id());
|
||||
}
|
||||
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
|
||||
return json!({ "error": "that player is not connected to this server" });
|
||||
};
|
||||
// The player may stay (a hand-over that does not finish): its client closes the menu too
|
||||
// and shows the inventory as it now is.
|
||||
if let Some(container) = window {
|
||||
host.hub
|
||||
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
|
||||
}
|
||||
host.hub.send_inventory(i);
|
||||
// What a mod attached to the player since it joined, as a save writes it.
|
||||
self.guest_attachments_into(&mut host.hub.guests[i]);
|
||||
let advancements = if self.adv.tree.list.is_empty() {
|
||||
Value::Null
|
||||
} else {
|
||||
host.hub.guests[i]
|
||||
.advancements
|
||||
.to_json(&self.adv.tree, horizon_world::DATA_VERSION)
|
||||
};
|
||||
let stats = host
|
||||
.play
|
||||
.get(&conn)
|
||||
.map_or(Value::Null, |p| p.stats.to_json());
|
||||
let records = arrivals::Records {
|
||||
advancements,
|
||||
stats,
|
||||
};
|
||||
match arrivals::export_record(&mut host.hub, i, freeze, records) {
|
||||
Ok(record) => arrivals::export_reply(&host.hub, i, record),
|
||||
Err(e) => json!({ "error": e }),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -569,7 +569,9 @@ impl Dragon {
|
||||
/// (the dragon's type is fire immune) before `EnderDragon.hurt`.
|
||||
/// `direct` is the projectile that hit: a direct `AbstractArrow` or a
|
||||
/// `WindCharge` is the arrow a perched dragon shrugs off, a breeze's
|
||||
/// `BreezeWindCharge` is neither.
|
||||
/// `BreezeWindCharge` is neither. Gameplay calls `hurt_part_from`; this
|
||||
/// is the no-attacker form the tests use.
|
||||
#[cfg(test)]
|
||||
fn hurt_part(
|
||||
&mut self,
|
||||
part: usize,
|
||||
@@ -577,6 +579,19 @@ impl Dragon {
|
||||
counts: Option<HurtSource<'_>>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
) -> Hurt {
|
||||
self.hurt_part_from(part, damage, counts, kind, direct, None)
|
||||
}
|
||||
/// `hurt_part` with the melee attacker's position (`DamageSource.getSourcePosition`,
|
||||
/// the direct entity's feet). `None` for a hit that is not a player's melee.
|
||||
fn hurt_part_from(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: Option<HurtSource<'_>>,
|
||||
kind: DamageKind,
|
||||
direct: Option<&ProjectileKind>,
|
||||
from: Option<Vec3>,
|
||||
) -> Hurt {
|
||||
if is_fire(kind) {
|
||||
return Hurt::Refused;
|
||||
@@ -587,15 +602,27 @@ impl Dragon {
|
||||
ProjectileKind::Arrow { .. } | ProjectileKind::Trident | ProjectileKind::WindCharge
|
||||
)
|
||||
);
|
||||
self.hurt(part, damage, counts, arrow)
|
||||
self.hurt_from(part, damage, counts, arrow, from)
|
||||
}
|
||||
/// `EnderDragon.hurt(part, source, damage)`: whether the hit landed
|
||||
/// (an arrow is spent, else it turns back). `counts` is the source
|
||||
/// `reallyHurt` takes when `source.getEntity() instanceof Player` or its
|
||||
/// type always hurts dragons (explosions), and none otherwise; `arrow` a
|
||||
/// direct arrow or wind charge, which a perched dragon shrugs off
|
||||
/// (`AbstractDragonSittingPhase.onHurt`).
|
||||
/// (`AbstractDragonSittingPhase.onHurt`). Gameplay calls `hurt_from`.
|
||||
#[cfg(test)]
|
||||
fn hurt(&mut self, part: usize, damage: f32, counts: Option<HurtSource>, arrow: bool) -> Hurt {
|
||||
self.hurt_from(part, damage, counts, arrow, None)
|
||||
}
|
||||
/// `hurt` with the position `dealDefaultKnockback` pushes away from.
|
||||
fn hurt_from(
|
||||
&mut self,
|
||||
part: usize,
|
||||
damage: f32,
|
||||
counts: Option<HurtSource>,
|
||||
arrow: bool,
|
||||
from: Option<Vec3>,
|
||||
) -> Hurt {
|
||||
if matches!(self.phase, Phase::Dying { .. }) || !self.alive() {
|
||||
return Hurt::Refused;
|
||||
}
|
||||
@@ -652,6 +679,19 @@ impl Dragon {
|
||||
});
|
||||
}
|
||||
}
|
||||
// `LivingEntity.hurtServer` calls `dealDefaultKnockback` (strength
|
||||
// `0.4F`) on a full hit, before `EnderDragon.hurt` adds sitting damage
|
||||
// and maybe takes off. `from` is the attacker's feet for a player's
|
||||
// melee hit. Explosions are `#no_knockback`; their push is
|
||||
// `explosion_knockback`.
|
||||
if full && let Some(from) = from {
|
||||
self.knockback(
|
||||
f64::from(0.4_f32),
|
||||
from.x - self.position.x,
|
||||
from.z - self.position.z,
|
||||
source,
|
||||
);
|
||||
}
|
||||
if self.phase.sitting() {
|
||||
self.sitting_damage += before - self.health.max(0.0);
|
||||
if self.sitting_damage > 0.25 * MAX_HEALTH {
|
||||
@@ -665,6 +705,43 @@ impl Dragon {
|
||||
}
|
||||
Hurt::Landed { full }
|
||||
}
|
||||
/// `EnderDragon.knockback`: `LivingEntity.knockback` only when the phase
|
||||
/// is not sitting. `(xd, zd)` points at the hit (`dealDefaultKnockback`:
|
||||
/// the source position minus this position). Knockback resistance is the
|
||||
/// mob default, 0. `noPhysics` never sets `onGround`, so the vertical
|
||||
/// component stays.
|
||||
fn knockback(&mut self, power: f64, xd: f64, zd: f64, source: HurtSource<'_>) {
|
||||
if self.phase.sitting() {
|
||||
return;
|
||||
}
|
||||
let mut knock = horizon_client::health::Knock {
|
||||
strength: power,
|
||||
x: xd,
|
||||
z: zd,
|
||||
};
|
||||
if horizon_client::health::knockback_canceled(
|
||||
horizon_client::health::Hurting::Dragon(self.id as i32, source),
|
||||
&mut knock,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let horizon_client::health::Knock {
|
||||
strength: power,
|
||||
x: mut xd,
|
||||
z: mut zd,
|
||||
} = knock;
|
||||
if power <= 0.0 {
|
||||
return;
|
||||
}
|
||||
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
|
||||
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
|
||||
}
|
||||
let len = (xd * xd + zd * zd).sqrt();
|
||||
let (kx, kz) = (xd / len * power, zd / len * power);
|
||||
let v = self.velocity;
|
||||
self.velocity = Vec3::new(v.x / 2.0 - kx, v.y, v.z / 2.0 - kz);
|
||||
}
|
||||
/// `ServerExplosion.hurtEntities` on the dragon: the dragon itself (its
|
||||
/// body takes it), then each part, in `Level.getEntities` order, each by
|
||||
/// its distance and how much of its box the blast sees (`exposure`).
|
||||
@@ -1307,12 +1384,19 @@ impl Game<'_> {
|
||||
) -> Hurt {
|
||||
let by_id = by.map(|who| self.fight_entity(who));
|
||||
let source = counts.then(|| hurt_source(kind, by_id, direct));
|
||||
// `dealDefaultKnockback` for a player's melee: away from the attacker's
|
||||
// feet. The scope names that player to the knockback hook.
|
||||
let _attacker = (kind == DamageKind::PlayerAttack)
|
||||
.then(|| self.knockback_scope(self.player.as_ref().map_or(0, |p| p.entity_id)));
|
||||
let from = (kind == DamageKind::PlayerAttack)
|
||||
.then(|| self.player.as_ref().map(|p| p.position))
|
||||
.flatten();
|
||||
let Some(d) = self.dragon.as_mut() else {
|
||||
return Hurt::Refused;
|
||||
};
|
||||
let was_down = d.down();
|
||||
let (cooldown, last_hurt) = (d.invulnerable, d.last_hurt);
|
||||
let hurt = d.hurt_part(part, damage, source, kind, direct);
|
||||
let hurt = d.hurt_part_from(part, damage, source, kind, direct, from);
|
||||
// LivingEntity.hurtServer: damage that is applied sets lastHurtByPlayer
|
||||
// (100 ticks); a hit the damage cooldown absorbs returns before that.
|
||||
if counts
|
||||
@@ -2476,12 +2560,31 @@ impl Game<'_> {
|
||||
if drops {
|
||||
self.spawn_orbs([p.x, p.y, p.z], (xp as f32 * 0.2) as u32);
|
||||
}
|
||||
let (id, at) = self.dragon.as_ref().map_or((0, p), |d| (d.id, d.position));
|
||||
self.dragon = None;
|
||||
if fight {
|
||||
self.dragon_killed();
|
||||
}
|
||||
// remove, then gameEvent(ENTITY_DIE): with no health left,
|
||||
// `Warden.canTargetEntity` refuses the dragon, so no warden hears it.
|
||||
self.dragon_died_event(id, at, false);
|
||||
}
|
||||
}
|
||||
/// `Entity.gameEvent(GameEvent.ENTITY_DIE)` for the removed dragon: sculk
|
||||
/// sensors (and, while it has health, wardens) hear it, and the nearest
|
||||
/// catalyst blooms (`CatalystListener`). It takes no experience: the
|
||||
/// dragon's `xpReward` is 0, its orbs come from `tickDeath`.
|
||||
fn dragon_died_event(&mut self, id: u32, at: Vec3, targetable: bool) {
|
||||
let at = [at.x, at.y, at.z];
|
||||
let ctx = horizon_client::sculk::Context {
|
||||
source: Some(Who::Mob(id)),
|
||||
targetable,
|
||||
living: true,
|
||||
..Default::default()
|
||||
};
|
||||
self.post_game_event("minecraft:entity_die", at, ctx);
|
||||
self.catalyst_takes_experience(at, |_| 0);
|
||||
}
|
||||
/// `EnderDragonFight.setDragonKilled`.
|
||||
fn dragon_killed(&mut self) {
|
||||
let _ = self.activate_exit_portal();
|
||||
@@ -3594,7 +3697,10 @@ impl Game<'_> {
|
||||
if self.dragon.as_ref().is_some_and(|d| d.id == id) {
|
||||
// Only the fight's own dragon (`dragonFight`) ends it.
|
||||
let fight = self.is_fight_dragon();
|
||||
self.dragon = None;
|
||||
if let Some(d) = self.dragon.take() {
|
||||
// remove, then gameEvent(ENTITY_DIE), before the fight hears of it.
|
||||
self.dragon_died_event(d.id, d.position, d.alive());
|
||||
}
|
||||
if fight {
|
||||
self.dragon_killed();
|
||||
}
|
||||
@@ -4755,6 +4861,49 @@ mod tests {
|
||||
assert_eq!(d.health, MAX_HEALTH - 9.0);
|
||||
}
|
||||
|
||||
/// `EnderDragon.knockback` calls `LivingEntity.knockback` only when the
|
||||
/// phase is not sitting. `dealDefaultKnockback` uses `0.4F` away from the
|
||||
/// attacker's feet. Resistance is 0 and `onGround` stays false
|
||||
/// (`noPhysics`), so from velocity `(1, -0.3, 0.5)` and a source 3 by 4
|
||||
/// blocks away the push is `(0.5 - 0.6p, -0.3, 0.25 - 0.8p)` with
|
||||
/// `p = (double) 0.4F`. A perched hit, including one that makes it take
|
||||
/// off, leaves the velocity alone. A hit inside the damage cooldown is
|
||||
/// not a full hit, so it does not push either.
|
||||
#[test]
|
||||
fn a_melee_hit_knocks_a_flying_dragon_and_not_a_perched_one() {
|
||||
let from = Vec3::new(3.0, 70.0, 4.0);
|
||||
let velocity = Vec3::new(1.0, -0.3, 0.5);
|
||||
let power = f64::from(0.4_f32);
|
||||
let pushed = Vec3::new(0.5 - 0.6 * power, -0.3, 0.25 - 0.8 * power);
|
||||
|
||||
let mut perched = dragon(Phase::Scanning { time: 0 });
|
||||
perched.velocity = velocity;
|
||||
assert_eq!(
|
||||
perched.hurt_from(0, 9.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: true }
|
||||
);
|
||||
assert_eq!(perched.velocity, velocity);
|
||||
|
||||
let mut taking_off = dragon(Phase::Flaming { ticks: 0 });
|
||||
taking_off.velocity = velocity;
|
||||
taking_off.hurt_from(0, 60.0, HIT, false, Some(from));
|
||||
assert!(matches!(taking_off.phase, Phase::Takeoff { .. }));
|
||||
assert_eq!(taking_off.velocity, velocity);
|
||||
|
||||
let mut flying = dragon(flying());
|
||||
flying.velocity = velocity;
|
||||
assert_eq!(
|
||||
flying.hurt_from(0, 9.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: true }
|
||||
);
|
||||
assert_eq!(flying.velocity, pushed);
|
||||
assert_eq!(
|
||||
flying.hurt_from(0, 20.0, HIT, false, Some(from)),
|
||||
Hurt::Landed { full: false }
|
||||
);
|
||||
assert_eq!(flying.velocity, pushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_part_refuses_fire() {
|
||||
// EnderDragonPart.hurtServer: the dragon is fire immune, so a
|
||||
|
||||
@@ -62,7 +62,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn sound_entity_state(&self, id: u32) -> Option<SoundEntityState> {
|
||||
pub(super) fn sound_entity_state(&mut self, id: u32) -> Option<SoundEntityState> {
|
||||
if self.remote.is_some() {
|
||||
return self.remote_sound_entity_state(id as i32);
|
||||
}
|
||||
|
||||
@@ -345,13 +345,23 @@ impl Game<'_> {
|
||||
self.particles
|
||||
.add(&ParticleOptions::simple(ParticleType::Firework), at, v);
|
||||
}
|
||||
/// `FireworkRocketItem.use` while gliding: a rocket attached to the player.
|
||||
/// `FireworkRocketItem.use` while gliding: drop the player's leads, then a
|
||||
/// rocket attached to the player.
|
||||
pub(super) fn boost_firework(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:firework_rocket"
|
||||
|| !self.player.as_ref().is_some_and(|p| p.fall_flying)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Server path of `use`: `dropAllLeashConnections(null)`, and when that
|
||||
// drops anything `playSound(null, player, LEAD_BREAK, NEUTRAL, 1, 1)`.
|
||||
// `SoundEvents.LEAD_BREAK` is `minecraft:item.lead.break`. A position
|
||||
// is what the server thread forwards, so the window hears the snap.
|
||||
if self.drop_all_leash_connections()
|
||||
&& let Some(p) = self.player.as_ref()
|
||||
{
|
||||
self.lead_sound("break", [p.position.x, p.position.y, p.position.z]);
|
||||
}
|
||||
let at = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
|
||||
let mut rocket = self.new_rocket(at, stack, true);
|
||||
rocket.owner = Some(Who::Player);
|
||||
@@ -759,5 +769,102 @@ mod tests {
|
||||
assert!(super::use_on_passes(true));
|
||||
assert!(!super::use_on_passes(false));
|
||||
}
|
||||
|
||||
/// `FireworkRocketItem.use` on the server, while fall-flying: a lead on a
|
||||
/// mob within the 32-block box drops as an item and one
|
||||
/// `minecraft:item.lead.break` plays at the player's feet. A lead outside
|
||||
/// that box, or held for another player, stays. Standing still does neither.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_gliding_firework_drops_leads_and_plays_lead_break() {
|
||||
use crate::game::{Audio, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::mobs::{Mob, leash::Holder};
|
||||
use horizon_core::geometry::Vec3;
|
||||
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
|
||||
let res: &'static _ = Box::leak(Box::new(loaded.resources));
|
||||
let mut game = super::Game::new(
|
||||
res,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
game.player = Some(
|
||||
horizon_client::motion::PlayerMotion::new(game.motion, Vec3::new(10.5, 64.0, -3.25))
|
||||
.unwrap(),
|
||||
);
|
||||
let feet = {
|
||||
let p = game.player.as_ref().expect("a player").position;
|
||||
[p.x, p.y, p.z]
|
||||
};
|
||||
let kind = game
|
||||
.res
|
||||
.mob_types()
|
||||
.get("minecraft:cow")
|
||||
.expect("cow")
|
||||
.clone();
|
||||
let me = game.acting_uuid();
|
||||
let someone_else = [me[0].wrapping_add(1), me[1], me[2], me[3]];
|
||||
let mut leashed = |id, at: Vec3, owner: [i32; 4]| {
|
||||
let mut mob = Mob::new(id, kind.clone(), at, 0.0);
|
||||
mob.extra.leash = Some(Holder::Player);
|
||||
mob.extra.leash_player = Some(owner);
|
||||
game.mobs.push(mob);
|
||||
};
|
||||
let here = Vec3::new(feet[0] + 1.0, feet[1], feet[2]);
|
||||
leashed(1, here, me);
|
||||
leashed(2, Vec3::new(feet[0] + 40.0, feet[1], feet[2]), me);
|
||||
leashed(3, here, someone_else);
|
||||
game.inventory[game.selected] = Some(super::ItemStack::new("minecraft:firework_rocket", 1));
|
||||
let stack = game.inventory[game.selected].clone().unwrap();
|
||||
game.audio.capture = Some(Vec::new());
|
||||
|
||||
assert!(!game.boost_firework(&stack));
|
||||
assert_eq!(game.mobs[0].extra.leash, Some(Holder::Player));
|
||||
assert!(game.dropped.is_empty());
|
||||
assert!(game.audio.capture.as_ref().unwrap().is_empty());
|
||||
|
||||
game.player.as_mut().unwrap().fall_flying = true;
|
||||
assert!(game.boost_firework(&stack));
|
||||
assert!(
|
||||
game.mobs[0].extra.leash.is_none(),
|
||||
"the mob on the lead comes free"
|
||||
);
|
||||
assert_eq!(game.mobs[1].extra.leash, Some(Holder::Player));
|
||||
assert_eq!(game.mobs[2].extra.leash, Some(Holder::Player));
|
||||
let leads: Vec<_> = game
|
||||
.dropped
|
||||
.iter()
|
||||
.filter(|d| &*d.stack.item == "minecraft:lead")
|
||||
.collect();
|
||||
assert_eq!(leads.len(), 1);
|
||||
let heard = game.audio.capture.take().unwrap_or_default();
|
||||
let snaps: Vec<_> = heard
|
||||
.into_iter()
|
||||
.filter(|s| s.event == "minecraft:item.lead.break")
|
||||
.map(|s| (s.position, s.volume, s.pitch))
|
||||
.collect();
|
||||
assert_eq!(snaps, vec![(feet, 1.0, 1.0)]);
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Rocket);
|
||||
@@ -10,8 +10,6 @@ use horizon_core::{
|
||||
position::BlockPos,
|
||||
random::RandomSource,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A baked frame model: (corners in block units, unit UVs, sprite) per quad.
|
||||
pub(super) type FrameModel = Vec<([[f32; 3]; 4], [[f32; 2]; 4], String)>;
|
||||
|
||||
@@ -165,12 +163,21 @@ impl Game<'_> {
|
||||
"minecraft:glow_item_frame" => true,
|
||||
_ => return false,
|
||||
};
|
||||
let invisible = stack.extra.as_ref().is_some_and(|extra| {
|
||||
extra.other.iter().any(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data")
|
||||
&& horizon_client::save::number(v.get("Invisible")).unwrap_or(0) != 0
|
||||
let entity_data = stack.extra.as_ref().and_then(|extra| {
|
||||
extra.other.iter().find_map(|(k, v)| {
|
||||
(k == "minecraft:entity_data" || k == "entity_data").then_some(v)
|
||||
})
|
||||
});
|
||||
let invisible = entity_data
|
||||
.and_then(|v| horizon_client::save::number(v.get("Invisible")))
|
||||
.unwrap_or(0)
|
||||
!= 0;
|
||||
// `applyComponentsFromItemStack` sets `CUSTOM_NAME`, then `entity_data`
|
||||
// loads (`updateCustomEntityTag`): a missing `CustomName` clears it.
|
||||
let custom_name = match entity_data {
|
||||
Some(data) => data.get("CustomName").cloned(),
|
||||
None => stack.data().and_then(|d| d.custom_name.clone()),
|
||||
};
|
||||
let pos: [i32; 3] = std::array::from_fn(|i| hit.pos[i] + hit.face[i]);
|
||||
// `ItemFrameItem.mayPlace`: `Level.isInsideBuildHeight(blockPos)`, so a frame is not
|
||||
// placed on the top face of the highest block or under the lowest one.
|
||||
@@ -191,6 +198,7 @@ impl Game<'_> {
|
||||
fixed: false,
|
||||
invisible,
|
||||
check_interval: 0,
|
||||
custom_name,
|
||||
};
|
||||
if !self.frame_survives(&frame) {
|
||||
return false;
|
||||
@@ -384,12 +392,8 @@ impl Game<'_> {
|
||||
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
|
||||
let mut drops = Vec::new();
|
||||
if with_frame {
|
||||
drops.push(ItemStack {
|
||||
item: Arc::from(f.kind()),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
// `ItemFrame.getFrameItemStackWithData` (glow frames share it).
|
||||
drops.push(f.item_stack_with_data());
|
||||
}
|
||||
if let Some(item) = f.item.clone()
|
||||
&& self.world.random.next_float() < f.drop_chance
|
||||
|
||||
@@ -579,6 +579,13 @@ fn mob_meta(
|
||||
}
|
||||
let e = &m.extra;
|
||||
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (18, the first entry
|
||||
// after AgeableMob's two). The total `boost()` stored in `counter2`.
|
||||
// The default 0 is not registered: vanilla never writes it back, and the
|
||||
// rider's client finishes the timer `onSynced` started.
|
||||
if matches!(kind, "minecraft:pig" | "minecraft:strider") && e.counter2 > 0 {
|
||||
meta.push((18, serializer::INT, Value::Int(e.counter2)));
|
||||
}
|
||||
mob_variant_meta(m, hub, meta, defaults);
|
||||
mob_raider_meta(m, meta, defaults);
|
||||
match kind {
|
||||
@@ -2829,10 +2836,13 @@ impl Game<'_> {
|
||||
if event == 21 && self.options.execution_role.is_client() {
|
||||
self.start_guardian_sound(id as u32);
|
||||
}
|
||||
// Event 53: a mob sliding down a honey block sheds honey particles
|
||||
// (HoneyBlock.showSlideParticles), drawn by the client that runs the world.
|
||||
if event == 53 && self.options.execution_role.is_client() {
|
||||
self.honey_slide_particles(at);
|
||||
// Events 53 and 54: honey crumbs while sliding, and the landing burst
|
||||
// (`HoneyBlock.showSlideParticles` / `showJumpParticles`), drawn by the
|
||||
// client that runs the world.
|
||||
if self.options.execution_role.is_client()
|
||||
&& horizon_client::landing::honey_particle_count(event).is_some()
|
||||
{
|
||||
self.honey_entity_particles(at, event);
|
||||
}
|
||||
// Event 2: a mob's spear hit felt; the same window hears its own mob's hit.
|
||||
if event == 2 && self.options.execution_role.is_client() {
|
||||
|
||||
@@ -2493,9 +2493,6 @@ impl Game<'_> {
|
||||
// survives the reopen, so it follows the change.
|
||||
self.level.set_difficulty(self.ui.difficulty);
|
||||
self.sync_rules();
|
||||
if self.ui.difficulty == 0 {
|
||||
self.mobs.retain(|m| !m.hostile());
|
||||
}
|
||||
host.hub
|
||||
.broadcast(&C::ChangeDifficulty(play::ChangeDifficulty {
|
||||
difficulty: i32::from(self.ui.difficulty),
|
||||
@@ -4669,20 +4666,33 @@ impl Game<'_> {
|
||||
host.hub.sound(name, at, volume, pitch, source, None);
|
||||
}
|
||||
}
|
||||
// LivingEntity.causeFallDamage: Player.playSound skips the
|
||||
// guest, whose own client plays it; everyone else hears it.
|
||||
// LivingEntity.causeFallDamage, and HoneyBlock.fallOn's
|
||||
// `entity.playSound`: Player.playSound skips the guest, whose
|
||||
// own client plays it; everyone else hears it.
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
volume,
|
||||
pitch,
|
||||
..
|
||||
}) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
if let Some(name) = sound
|
||||
.player_fall_sound()
|
||||
.or_else(|| sound.honey_landing_sound())
|
||||
{
|
||||
let source = horizon_client::settings::sound_source(name) as i32;
|
||||
self.audio.play_unshared(name, Some(at), volume, pitch);
|
||||
host.hub.sound(name, at, volume, pitch, source, Some(conn));
|
||||
}
|
||||
}
|
||||
// HoneyBlock.fallOn: entity event 54, including the player who
|
||||
// landed (`broadcastEntityEvent` has no excluded player).
|
||||
MotionEvent::Fall(horizon_client::landing::FallEvent::Broadcast(event))
|
||||
if horizon_client::landing::honey_particle_count(event as i8).is_some() =>
|
||||
{
|
||||
if let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) {
|
||||
host.hub.entity_event(id, event as i8, at);
|
||||
}
|
||||
}
|
||||
MotionEvent::Fall(event) => self.landing_block_updates(&event),
|
||||
// PowderSnowBlock.entityInside: every player around sees the
|
||||
// snowflake (`ServerLevel.sendParticles`).
|
||||
|
||||
@@ -476,6 +476,9 @@ mod tests {
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -960,6 +960,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx_near, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
1,
|
||||
[5.0, 64.0, 5.0],
|
||||
@@ -981,6 +982,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx_far, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25566".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
2,
|
||||
[100.0, 64.0, 100.0],
|
||||
|
||||
@@ -197,6 +197,38 @@ impl Game<'_> {
|
||||
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
|
||||
self.drop_leash(id, drop, sound, false);
|
||||
}
|
||||
/// `Entity.dropAllLeashConnections`: every mob and boat leashed to the
|
||||
/// acting player, inside the 32-block box around the player's centre,
|
||||
/// drops its lead. Then `GameEvent.SHEAR`. Whether anything dropped.
|
||||
/// The player is not `Leashable`, so there is no lead of their own.
|
||||
pub(super) fn drop_all_leash_connections(&mut self) -> bool {
|
||||
let center = self.player_center();
|
||||
let mobs: Vec<u32> = self
|
||||
.player_leashed(center)
|
||||
.into_iter()
|
||||
.map(|i| self.mobs[i].id)
|
||||
.collect();
|
||||
let boats = self.player_led_boats(center);
|
||||
if mobs.is_empty() && boats.is_empty() {
|
||||
return false;
|
||||
}
|
||||
for id in mobs {
|
||||
// One snap is played by the caller (`SoundEvents.LEAD_BREAK`),
|
||||
// not a break sound on each leashed entity.
|
||||
self.break_leash(id, true, false);
|
||||
}
|
||||
self.drop_boat_leashes(&boats);
|
||||
let at = self
|
||||
.player
|
||||
.as_ref()
|
||||
.map_or([center.x, center.y, center.z], |p| {
|
||||
[p.position.x, p.position.y, p.position.z]
|
||||
});
|
||||
// `gameEvent(GameEvent.SHEAR, null)`: the player is the position, not the source.
|
||||
let ctx = self.who_context(None, None);
|
||||
self.post_game_event("minecraft:shear", at, ctx);
|
||||
true
|
||||
}
|
||||
/// `break_leash` by the acting player's use (untying it, shears).
|
||||
fn break_leash_used(&mut self, id: u32, drop: bool) {
|
||||
self.drop_leash(id, drop, false, true);
|
||||
|
||||
@@ -492,9 +492,8 @@ impl Game<'_> {
|
||||
self.tick()
|
||||
} else {
|
||||
let running = running.unwrap_or_else(|| self.clock.world_runs());
|
||||
if running {
|
||||
self.tick_weather_effects();
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.tick_weather_effects();
|
||||
self.tick_world(running)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -227,7 +227,7 @@ fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
|
||||
}
|
||||
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
|
||||
/// the push the cart loses (the entity gets a quarter of it back).
|
||||
fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
|
||||
let (xa, za) = (feet.x - cart.x, feet.z - cart.z);
|
||||
let dd = xa * xa + za * za;
|
||||
if dd < f64::from(1.0e-4f32) {
|
||||
|
||||
@@ -148,19 +148,9 @@ impl Game<'_> {
|
||||
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
|
||||
/// carries on.
|
||||
pub(super) fn tick_sound_engine(&mut self) {
|
||||
for entity in self.audio.engine.bound_entities() {
|
||||
let previous = entity.state();
|
||||
if previous.removed {
|
||||
continue;
|
||||
}
|
||||
let state = self.sound_entity_state(entity.id);
|
||||
entity.update(
|
||||
state.unwrap_or(horizon_client::sound_engine::SoundEntityState {
|
||||
removed: true,
|
||||
..previous
|
||||
}),
|
||||
);
|
||||
}
|
||||
update_bound_entities(self.audio.engine.bound_entities(), |id| {
|
||||
self.sound_entity_state(id)
|
||||
});
|
||||
|
||||
if self.pause != self.sound_paused {
|
||||
if self.pause {
|
||||
@@ -340,3 +330,79 @@ impl Game<'_> {
|
||||
self.ambience.tick(&ctx, &mut self.audio);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve each live entity once per sound tick, even when several sounds name it.
|
||||
fn update_bound_entities(
|
||||
entities: Vec<horizon_client::sound_engine::SoundEntity>,
|
||||
mut resolve: impl FnMut(u32) -> Option<horizon_client::sound_engine::SoundEntityState>,
|
||||
) {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
let mut states = std::collections::HashMap::new();
|
||||
for entity in entities {
|
||||
let previous = entity.state();
|
||||
if previous.removed {
|
||||
continue;
|
||||
}
|
||||
let state = *states
|
||||
.entry(entity.id)
|
||||
.or_insert_with(|| resolve(entity.id));
|
||||
entity.update(state.unwrap_or(SoundEntityState {
|
||||
removed: true,
|
||||
..previous
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bound_lookup_tests {
|
||||
use super::update_bound_entities;
|
||||
use horizon_client::sound_engine::{SoundEntity, SoundEntityState};
|
||||
|
||||
#[test]
|
||||
fn simultaneous_sounds_share_a_lookup_but_follow_each_new_tick() {
|
||||
let a = SoundEntity::new(7, [0.0; 3], false);
|
||||
let b = SoundEntity::new(7, [1.0; 3], false);
|
||||
let mut calls = Vec::new();
|
||||
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
|
||||
update_bound_entities(vec![a.clone(), b.clone()], |id| {
|
||||
calls.push(id);
|
||||
Some(SoundEntityState {
|
||||
position,
|
||||
silent: true,
|
||||
removed: false,
|
||||
})
|
||||
});
|
||||
assert_eq!(a.state().position, position);
|
||||
assert_eq!(b.state().position, position);
|
||||
assert!(a.state().silent && b.state().silent);
|
||||
}
|
||||
assert_eq!(calls, vec![7, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_entities_stop_all_their_sounds_without_reviving_reused_ids() {
|
||||
let a = SoundEntity::new(7, [1.0; 3], false);
|
||||
let b = SoundEntity::new(7, [2.0; 3], false);
|
||||
let mut calls = 0;
|
||||
update_bound_entities(vec![a.clone(), b.clone()], |_| {
|
||||
calls += 1;
|
||||
None
|
||||
});
|
||||
assert_eq!(calls, 1);
|
||||
assert!(a.state().removed && b.state().removed);
|
||||
let replacement = SoundEntity::new(7, [3.0; 3], false);
|
||||
update_bound_entities(vec![a.clone(), replacement.clone(), b.clone()], |_| {
|
||||
calls += 1;
|
||||
Some(SoundEntityState {
|
||||
position: [4.0; 3],
|
||||
silent: false,
|
||||
removed: false,
|
||||
})
|
||||
});
|
||||
assert_eq!(calls, 2);
|
||||
assert!(a.state().removed && b.state().removed);
|
||||
assert_eq!(a.state().position, [1.0; 3]);
|
||||
assert_eq!(replacement.state().position, [4.0; 3]);
|
||||
assert!(!replacement.state().removed);
|
||||
}
|
||||
}
|
||||
@@ -248,9 +248,12 @@ impl BlockEntityDraw {
|
||||
*pose *= m;
|
||||
}
|
||||
}
|
||||
fn pose(&self) -> Mat4 {
|
||||
pub(super) fn pose(&self) -> Mat4 {
|
||||
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
|
||||
}
|
||||
pub(super) fn vertices_mut(&mut self) -> &mut Vec<Vertex> {
|
||||
&mut self.vertices
|
||||
}
|
||||
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
|
||||
/// vanilla's block entity models are (`Lighting`'s two lights).
|
||||
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
|
||||
|
||||
@@ -18,13 +18,17 @@ impl Game<'_> {
|
||||
/// stays open with nobody there. Tickets are kept until the owner lets go
|
||||
/// or the server stops, not saved: keep what you hold (a level
|
||||
/// attachment) and hold it again in `native_level_load`. True when the
|
||||
/// set of owners changed; false for an owner without a namespace and off
|
||||
/// a server. API @API:chunk_tickets@.
|
||||
/// set of owners changed; false for an owner without a namespace, a chunk
|
||||
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
|
||||
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
|
||||
let namespaced = owner
|
||||
.split_once(':')
|
||||
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
|
||||
if !namespaced || !self.native_authoritative() {
|
||||
// `/forceload`'s world: blocks -30,000,000 to 29,999,999, so a mod's
|
||||
// corrupt saved list asks for nothing past the edge (where the ring's
|
||||
// arithmetic would overflow).
|
||||
let in_world = chunk.iter().all(|c| (-1_875_000..1_875_000).contains(c));
|
||||
if !namespaced || !self.native_authoritative() || (hold && !in_world) {
|
||||
return false;
|
||||
}
|
||||
let key = (chunk[0], chunk[1]);
|
||||
|
||||
@@ -392,4 +392,94 @@ mod client_tests {
|
||||
assert_eq!(game.native_entity_position(stand), None);
|
||||
assert_eq!(game.native_entity_kind(display), None);
|
||||
}
|
||||
|
||||
/// #8589: the server sends entity event 42 (the raid sweat) for a villager;
|
||||
/// the client must draw the particles, as it does for the events it let
|
||||
/// through before. An event with no particle table row (2) draws nothing.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_villager_raid_sweat_event_draws_particles() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let (pipe, _server) = horizon_protocol::connection::Pipe::pair();
|
||||
game.connect_local(pipe, "Steve").unwrap();
|
||||
let builtin = game.remote.as_ref().unwrap().builtin.clone();
|
||||
let kind = builtin
|
||||
.get("minecraft:entity_type")
|
||||
.unwrap()
|
||||
.id("minecraft:villager")
|
||||
.unwrap();
|
||||
game.handle_packet(C::Login(play::Login {
|
||||
player_id: 7,
|
||||
hardcore: false,
|
||||
levels: vec!["minecraft:overworld".into()],
|
||||
max_players: 20,
|
||||
chunk_radius: 8,
|
||||
simulation_distance: 8,
|
||||
reduced_debug_info: false,
|
||||
show_death_screen: true,
|
||||
limited_crafting: false,
|
||||
spawn: spawn("minecraft:overworld"),
|
||||
online_mode: false,
|
||||
enforces_secure_chat: false,
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::PlayerPosition(play::PlayerPosition {
|
||||
teleport: 1,
|
||||
position: [0.5, 65.0, 0.5],
|
||||
delta: [0.0; 3],
|
||||
yaw: 45.0,
|
||||
pitch: 10.0,
|
||||
relatives: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
game.handle_packet(C::AddEntity(play::AddEntity {
|
||||
id: 21,
|
||||
uuid: 0x0011_2233_4455_6677_8899_aabb_ccdd_eeff,
|
||||
kind,
|
||||
// Within 32 blocks of the camera, which starts at the origin: the
|
||||
// particle engine drops a particle farther away than that.
|
||||
position: [1.5, 1.0, 2.5],
|
||||
velocity: [0.0; 3],
|
||||
pitch: 0,
|
||||
yaw: 64,
|
||||
head_yaw: 64,
|
||||
data: 0,
|
||||
}))
|
||||
.unwrap();
|
||||
// `len` counts only particles already ticked in; `made` counts every
|
||||
// particle created, which is what the event handler does at once.
|
||||
let made = |g: &Game| g.particles.made().values().sum::<u64>();
|
||||
let before = made(&game);
|
||||
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 2 }))
|
||||
.unwrap();
|
||||
assert_eq!(made(&game), before, "event 2 has no particle row");
|
||||
game.handle_packet(C::EntityEvent(play::EntityEvent { id: 21, event: 42 }))
|
||||
.unwrap();
|
||||
assert!(made(&game) > before, "event 42 draws the sweat");
|
||||
}
|
||||
}
|
||||
@@ -988,6 +988,7 @@ use super::{
|
||||
native_mob_draw::{NativeMobRender, NativePlayerRender},
|
||||
native_panel_recipes::RecipePage,
|
||||
native_reload_listeners::NativeReloadListener,
|
||||
native_world_draw::NativeWorldDraw,
|
||||
};
|
||||
use horizon_assets::item_model::StackView;
|
||||
use horizon_client::mod_api::{
|
||||
@@ -1015,6 +1016,7 @@ use horizon_client::{
|
||||
first_person::Hand,
|
||||
health::{HurtSource, Hurting, Knock},
|
||||
hud::{HudBatch, rgba},
|
||||
hud_layout::HealthHud,
|
||||
items::ItemStack,
|
||||
keybinds,
|
||||
lan::menu::Slots,
|
||||
@@ -3304,6 +3306,14 @@ impl Game<'_> {
|
||||
bounds,
|
||||
);
|
||||
}
|
||||
/// Submit depth-tested world text and textured quads, after particles and
|
||||
/// before the hand/HUD. The pose starts camera-relative; use
|
||||
/// `draw.translate_world` for a world position. Called independently of
|
||||
/// block entities and chunks, so transient overlays may outlive their
|
||||
/// source block. API @API:world_canvas@.
|
||||
pub(super) fn native_world_submit(&self, draw: &mut NativeWorldDraw) {
|
||||
mod_api::dispatch_world_submit(self, mod_api::mods(), draw);
|
||||
}
|
||||
/// Draw the mob `mob` (its id, kind, custom name and the frame's body
|
||||
/// and head turns and walk cycle, `LivingEntityRenderState`) on this
|
||||
/// client with a model of your own, once a frame while it is in view
|
||||
@@ -9100,6 +9110,24 @@ impl Game<'_> {
|
||||
let screen = self.shown_native_screen()?;
|
||||
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
|
||||
}
|
||||
/// Horizon's own health HUD state, for a mod answering
|
||||
/// `native_health_hud`. `left_height`/`right_height` as they stand when
|
||||
/// the hook returns place the selected item's name and the action bar
|
||||
/// (vanilla's `Hud.leftHeight`/`rightHeight`): set them when you draw
|
||||
/// more or fewer rows. `layout` draws Horizon's rows and advances their
|
||||
/// frame state (the hearts' blink, the bubble pop sound), so call it at
|
||||
/// most once a frame, and only when you keep some of its rows.
|
||||
/// API @API:health_hud_state@.
|
||||
pub(super) fn native_health_hud_state(&mut self) -> &mut HealthHud {
|
||||
&mut self.health_hud
|
||||
}
|
||||
/// The current value, base plus modifiers, of this client's player's
|
||||
/// attribute `name` (`minecraft:armor_toughness`), as vanilla's
|
||||
/// `LivingEntity.getAttributeValue`. None with no player, or for an id
|
||||
/// the player has no attribute for. API @API:health_hud_state@.
|
||||
pub(super) fn native_local_attribute(&mut self, name: &str) -> Option<f64> {
|
||||
self.player.as_mut()?.attribute(name).ok()
|
||||
}
|
||||
/// A data file of the game's resources, content mods' included
|
||||
/// (`data/mymod/machines.json`), when there is one: what a mod's content
|
||||
/// says, read at runtime. Only relative paths of plain names. API 20.
|
||||
|
||||
@@ -97,7 +97,9 @@ impl Game<'_> {
|
||||
/// which a player has none of. API @API:host_bridge@.
|
||||
pub(super) fn native_equipment(&self, slot: EquipmentSlot) -> Option<ItemStack> {
|
||||
match slot {
|
||||
EquipmentSlot::MainHand => self.held().cloned(),
|
||||
// The selected slot, not `held`: that is the acting hand, the off hand's stack during
|
||||
// the off-hand pass of a use (`with_hand(Hand::Off, ..)`).
|
||||
EquipmentSlot::MainHand => self.inventory.get(self.selected).cloned().flatten(),
|
||||
EquipmentSlot::OffHand => self.offhand.clone(),
|
||||
EquipmentSlot::Head => self.armor[0].clone(),
|
||||
EquipmentSlot::Chest => self.armor[1].clone(),
|
||||
@@ -118,3 +120,64 @@ impl Game<'_> {
|
||||
self.keys.contains(&KeyCode::ShiftLeft)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
|
||||
use horizon_client::{first_person::Hand, items::ItemStack};
|
||||
use horizon_world::player_inventory::EquipmentSlot;
|
||||
|
||||
/// `LivingEntity.getItemBySlot` doesn't depend on the hand acting: in the
|
||||
/// off-hand pass of a use, the main hand is still the selected slot's.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn equipment_reads_the_main_hand_in_an_off_hand_pass() {
|
||||
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let name = |stack: Option<ItemStack>| stack.map(|s| s.item.to_string());
|
||||
game.inventory[game.selected] = Some(ItemStack::new("minecraft:stick", 1));
|
||||
game.offhand = Some(ItemStack::new("minecraft:torch", 1));
|
||||
for hand in [Hand::Main, Hand::Off] {
|
||||
game.with_hand(hand, |g| {
|
||||
assert_eq!(
|
||||
name(g.native_equipment(EquipmentSlot::MainHand)).as_deref(),
|
||||
Some("minecraft:stick"),
|
||||
"{hand:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
name(g.native_equipment(EquipmentSlot::OffHand)).as_deref(),
|
||||
Some("minecraft:torch"),
|
||||
"{hand:?}"
|
||||
);
|
||||
});
|
||||
}
|
||||
game.inventory[game.selected] = None;
|
||||
game.with_hand(Hand::Off, |g| {
|
||||
assert!(g.native_equipment(EquipmentSlot::MainHand).is_none());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,9 @@ impl Game<'_> {
|
||||
/// (`Block.dropResources(state, level, pos, blockEntity, null, tool)`):
|
||||
/// the break particles and sound, the block's drops with what its block
|
||||
/// entity copies into them (a shulker box's contents), experience for an
|
||||
/// ore as with no tool, then the block's fluid in its place. `tool` picks
|
||||
/// ore unless `tool` has silk touch (`EnchantmentHelper.processBlockExperience`),
|
||||
/// then the block's fluid in its place. Blocks that pop off because of
|
||||
/// the break drop as with no tool, and pop whatever `collect` says. `tool` picks
|
||||
/// the drops as a player's held item does (silk touch, fortune, shears);
|
||||
/// `None` is an empty hand. The mods' say on drops (`native_loot_drops`)
|
||||
/// applies. With `collect`, the drops are returned and nothing is popped
|
||||
@@ -47,7 +49,7 @@ impl Game<'_> {
|
||||
let at = BlockPos::new(pos[0], pos[1], pos[2]);
|
||||
let tool_kept = tool.cloned();
|
||||
let mut drops = self.with_level(|b, level, ticks| {
|
||||
level.drop_tool = tool_kept;
|
||||
level.drop_tool = Some(((pos[0], pos[1], pos[2]), tool_kept));
|
||||
level.drop_into = Some(Vec::new());
|
||||
b.destroy_block(level, ticks, at, true);
|
||||
level.drop_tool = None;
|
||||
|
||||
@@ -21,6 +21,10 @@ struct SceneQuad {
|
||||
uv: [[f32; 2]; 4],
|
||||
color: u32,
|
||||
normal: Vec3,
|
||||
/// Dropped when it faces away: block model faces, and the quads a block
|
||||
/// entity marks for culling as `entity.wgsl` does (`CULL_BACK`,
|
||||
/// `SHADED_CULL_W`). Other quads draw both sides, as in the world.
|
||||
cull: bool,
|
||||
}
|
||||
|
||||
/// The GUI transform of `view` in the box `rect` (x0, y0, x1, y1): the view's
|
||||
@@ -82,8 +86,14 @@ impl Game<'_> {
|
||||
if !self.native_window() || !view.scale.is_finite() || view.scale <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let occupied: std::collections::HashMap<[i32; 3], _> =
|
||||
blocks.iter().map(|b| (b.pos, b.state)).collect();
|
||||
// A state the registry doesn't know (a mod's stale id) culls nothing
|
||||
// instead of panicking in the block facts.
|
||||
let registry = &self.pack.assets.registry;
|
||||
let occupied: std::collections::HashMap<[i32; 3], _> = blocks
|
||||
.iter()
|
||||
.filter(|b| registry.state(b.state).is_some())
|
||||
.map(|b| (b.pos, b.state))
|
||||
.collect();
|
||||
let solid = |pos: [i32; 3]| {
|
||||
occupied.get(&pos).is_some_and(|&state| {
|
||||
self.res.behaviours().facts(state).flags & state_flags::SOLID_RENDER != 0
|
||||
@@ -140,6 +150,7 @@ impl Game<'_> {
|
||||
}),
|
||||
color,
|
||||
normal,
|
||||
cull: true,
|
||||
});
|
||||
}
|
||||
if let Some(entity) = block.entity {
|
||||
@@ -166,14 +177,27 @@ impl Game<'_> {
|
||||
);
|
||||
for q in placed.as_chunks::<4>().0 {
|
||||
let corners = q.each_ref().map(|v| Vec3::from_array(v.pos));
|
||||
let normal = (corners[1] - corners[0])
|
||||
let [nx, ny, nz, w] = q[0].normal;
|
||||
let mut normal = (corners[1] - corners[0])
|
||||
.cross(corners[2] - corners[0])
|
||||
.normalize_or_zero();
|
||||
if normal == Vec3::ZERO {
|
||||
normal = Vec3::new(f32::from(nx), f32::from(ny), f32::from(nz))
|
||||
.normalize_or_zero();
|
||||
}
|
||||
// `entity.wgsl`: |w| >= 0.5 is shaded by the normal; smaller
|
||||
// keeps the colour baked in (quads, cubes, items, text).
|
||||
let unbaked = w.unsigned_abs() >= 64;
|
||||
quads.push(SceneQuad {
|
||||
corners,
|
||||
uv: q.each_ref().map(|v| v.uv),
|
||||
color: shaded(q[0].color, face_shade(normal)),
|
||||
color: if unbaked {
|
||||
shaded(q[0].color, face_shade(normal))
|
||||
} else {
|
||||
q[0].color
|
||||
},
|
||||
normal,
|
||||
cull: (1..64).contains(&w) || (-126..0).contains(&w),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -181,7 +205,7 @@ impl Game<'_> {
|
||||
let m = scene_matrix(rect, view);
|
||||
let mut seen: Vec<(f32, [[f32; 3]; 4], [[f32; 2]; 4], u32)> = quads
|
||||
.into_iter()
|
||||
.filter(|q| m.transform_vector3(q.normal).z > 0.0)
|
||||
.filter(|q| !q.cull || m.transform_vector3(q.normal).z > 0.0)
|
||||
.map(|q| {
|
||||
let p = q.corners.map(|c| m.transform_point3(c));
|
||||
let depth = p.iter().map(|v| v.z).sum::<f32>() / 4.0;
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
//! A native mod's world-space text and atlas quads, through the normal renderer.
|
||||
use super::{Game, native_block_entity_draw::BlockEntityDraw};
|
||||
use glam::{DVec3, Mat4, Vec3};
|
||||
use horizon_client::{
|
||||
font::Patch,
|
||||
gfx::{WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY},
|
||||
hud::{HudBatch, Rect, rgba},
|
||||
mesher::Vertex,
|
||||
text::Text,
|
||||
};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
|
||||
/// One world frame, independently of block entities or source blocks. Its pose
|
||||
/// starts camera-relative and shares `BlockEntityDraw`'s push/pop, translate,
|
||||
/// rotate and scale operations, partial tick, game time and packed light.
|
||||
/// `keep` does not cache a world canvas. API @API:world_canvas@.
|
||||
pub struct NativeWorldDraw {
|
||||
pub camera: [f64; 3],
|
||||
draw: BlockEntityDraw,
|
||||
patches: Vec<Patch>,
|
||||
profiles: Vec<serde_json::Value>,
|
||||
}
|
||||
impl Deref for NativeWorldDraw {
|
||||
type Target = BlockEntityDraw;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.draw
|
||||
}
|
||||
}
|
||||
impl DerefMut for NativeWorldDraw {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
&mut self.draw
|
||||
}
|
||||
}
|
||||
impl NativeWorldDraw {
|
||||
fn new(camera: [f64; 3], partial: f32, time: i64) -> Self {
|
||||
Self {
|
||||
camera,
|
||||
draw: BlockEntityDraw::posed(
|
||||
partial,
|
||||
time,
|
||||
240 | 240 << 8,
|
||||
[0; 3],
|
||||
Mat4::IDENTITY,
|
||||
(Vec3::Y, Vec3::Y),
|
||||
Vec::new(),
|
||||
),
|
||||
patches: Vec::new(),
|
||||
profiles: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Translate to a world position, subtracting the camera in f64 first.
|
||||
/// Returns false without changing the pose for a non-finite offset.
|
||||
pub fn translate_world(&mut self, position: [f64; 3]) -> bool {
|
||||
let offset = (DVec3::from_array(position) - DVec3::from_array(self.camera)).as_vec3();
|
||||
if !offset.is_finite() {
|
||||
return false;
|
||||
}
|
||||
self.draw.translate(offset.to_array());
|
||||
true
|
||||
}
|
||||
fn quad(&mut self, sprite: Rect, corners: [[f32; 3]; 4], uv: [f32; 4], color: [u8; 4]) -> bool {
|
||||
let pose = self.draw.pose();
|
||||
if !pose.is_finite()
|
||||
|| !corners.iter().flatten().all(|v| v.is_finite())
|
||||
|| !uv.iter().all(|v| v.is_finite())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let [u0, v0, u1, v1] = uv.map(|v| v / 16.0);
|
||||
let points = corners.map(|point| pose.transform_point3(Vec3::from_array(point)));
|
||||
if points.iter().any(|point| !point.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let light = self.draw.light;
|
||||
for (point, [u, v]) in points
|
||||
.into_iter()
|
||||
.zip([[u0, v0], [u0, v1], [u1, v1], [u1, v0]])
|
||||
{
|
||||
self.draw.vertices_mut().push(Vertex {
|
||||
pos: point.to_array(),
|
||||
uv: [
|
||||
sprite.u0 + (sprite.u1 - sprite.u0) * u,
|
||||
sprite.v0 + (sprite.v1 - sprite.v0) * v,
|
||||
],
|
||||
layer: WORLD_TEXT_OVERLAY,
|
||||
color: u32::from_le_bytes(color),
|
||||
light,
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
true
|
||||
}
|
||||
fn text(&mut self, batch: HudBatch, front: Option<usize>) {
|
||||
let pose = self.draw.pose();
|
||||
let light = self.draw.light;
|
||||
if pose.is_finite() {
|
||||
for (index, quad) in batch.vertices.as_chunks::<6>().0.iter().enumerate() {
|
||||
for i in horizon_client::text_display::CORNERS {
|
||||
let vertex = quad[i];
|
||||
self.draw.vertices_mut().push(Vertex {
|
||||
pos: pose
|
||||
.transform_point3(Vec3::new(vertex.pos[0], vertex.pos[1], vertex.depth))
|
||||
.to_array(),
|
||||
uv: vertex.uv,
|
||||
color: vertex.color,
|
||||
light,
|
||||
layer: if front.is_some_and(|start| index * 6 >= start) {
|
||||
WORLD_TEXT_POLYGON_OFFSET_OVERLAY
|
||||
} else {
|
||||
WORLD_TEXT_OVERLAY
|
||||
},
|
||||
normal: [0; 4],
|
||||
glint_uv: [0.0; 2],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.patches.extend(batch.patches);
|
||||
self.profiles.extend(batch.inline_profiles);
|
||||
}
|
||||
}
|
||||
impl Game<'_> {
|
||||
pub(super) fn native_world_geometry(
|
||||
&mut self,
|
||||
camera: DVec3,
|
||||
partial: f32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
if !horizon_client::gfx::native_render::world_drawn_hooked()
|
||||
&& crate::mod_api::mods().is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let mut draw = NativeWorldDraw::new(camera.to_array(), partial, self.world.game_time);
|
||||
// NONE keeps these quads out of shader-pack entity programs.
|
||||
self.mark_stage(out.len(), "NONE");
|
||||
self.native_world_submit(&mut draw);
|
||||
self.resolve_inline_profiles(&draw.profiles);
|
||||
self.map_patches.extend(draw.patches);
|
||||
out.extend(draw.draw.into_vertices());
|
||||
}
|
||||
/// Normal, depth-tested atlas quad under the current pose. UVs use 16
|
||||
/// texels per sprite side; color is [red, green, blue, alpha]. No model
|
||||
/// diffuse lighting is applied; `draw.light` still selects lightmap light.
|
||||
/// False for an unknown sprite or non-finite geometry. API @API:world_canvas@.
|
||||
pub(super) fn native_world_quad(
|
||||
&self,
|
||||
draw: &mut NativeWorldDraw,
|
||||
sprite: &str,
|
||||
corners: [[f32; 3]; 4],
|
||||
uv: [f32; 4],
|
||||
color: [u8; 4],
|
||||
) -> bool {
|
||||
self.gui
|
||||
.atlas
|
||||
.sprites
|
||||
.get(sprite)
|
||||
.copied()
|
||||
.is_some_and(|sprite| draw.quad(sprite, corners, uv, color))
|
||||
}
|
||||
/// Actual styled world font at (0,0), with ARGB default color and optional
|
||||
/// eight-offset ARGB outline. Returns the text advance in font pixels.
|
||||
/// The front of outlined text uses POLYGON_OFFSET. API @API:world_canvas@.
|
||||
pub(super) fn native_world_text(
|
||||
&self,
|
||||
draw: &mut NativeWorldDraw,
|
||||
text: &Text,
|
||||
color: u32,
|
||||
outline: Option<u32>,
|
||||
) -> f32 {
|
||||
let packed = |c: u32| rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, (c >> 24) as u8);
|
||||
let mut batch = HudBatch::new([1, 1]);
|
||||
let (advance, front) = if let Some(outline) = outline {
|
||||
let (advance, front) = batch.text_styled_outline_world(
|
||||
&self.gui.atlas,
|
||||
text,
|
||||
0.0,
|
||||
0.0,
|
||||
packed(color),
|
||||
packed(outline),
|
||||
);
|
||||
(advance, Some(front))
|
||||
} else {
|
||||
(
|
||||
batch.text_styled_world(&self.gui.atlas, text, 0.0, 0.0, packed(color), false),
|
||||
None,
|
||||
)
|
||||
};
|
||||
draw.text(batch, front);
|
||||
advance
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn world_quad_preserves_sub_block_precision_rgba_uv_and_pose() {
|
||||
let mut frame = NativeWorldDraw::new([30_000_000.125, 64.0, -30_000_000.5], 0.5, 123);
|
||||
assert!(frame.translate_world([30_000_000.625, 65.0, -30_000_000.25]));
|
||||
frame.scale([2.0, 3.0, 4.0]);
|
||||
frame.light = 0x12_34;
|
||||
assert!(frame.quad(
|
||||
Rect {
|
||||
w: 16,
|
||||
h: 16,
|
||||
u0: 0.25,
|
||||
v0: 0.5,
|
||||
u1: 0.75,
|
||||
v1: 1.0
|
||||
},
|
||||
[
|
||||
[0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0],
|
||||
[1.0, 1.0, 0.0],
|
||||
[1.0, 0.0, 0.0]
|
||||
],
|
||||
[4.0, 0.0, 12.0, 8.0],
|
||||
[12, 34, 56, 78]
|
||||
));
|
||||
let vertices = frame.draw.into_vertices();
|
||||
assert_eq!(
|
||||
vertices.iter().map(|v| v.pos).collect::<Vec<_>>(),
|
||||
[
|
||||
[0.5, 1.0, 0.25],
|
||||
[0.5, 4.0, 0.25],
|
||||
[2.5, 4.0, 0.25],
|
||||
[2.5, 1.0, 0.25]
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
vertices.iter().map(|v| v.uv).collect::<Vec<_>>(),
|
||||
[[0.375, 0.5], [0.375, 0.75], [0.625, 0.75], [0.625, 0.5]]
|
||||
);
|
||||
assert!(
|
||||
vertices
|
||||
.iter()
|
||||
.all(|v| v.color.to_le_bytes() == [12, 34, 56, 78]
|
||||
&& v.light == 0x12_34
|
||||
&& v.normal == [0; 4])
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn invalid_world_offsets_leave_the_pose_usable() {
|
||||
let mut frame = NativeWorldDraw::new([0.0; 3], 0.0, 0);
|
||||
assert!(!frame.translate_world([f64::NAN, 0.0, 0.0]));
|
||||
assert!(!frame.translate_world([f64::MAX, 0.0, 0.0]));
|
||||
assert!(frame.translate_world([1.0, 2.0, 3.0]));
|
||||
assert_eq!(
|
||||
frame.draw.pose().transform_point3(Vec3::ZERO),
|
||||
Vec3::new(1.0, 2.0, 3.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,8 +171,9 @@ fn hurt_sound_for(
|
||||
fn entity_sound_state(
|
||||
id: i32,
|
||||
player: Option<(i32, [f64; 3], bool)>,
|
||||
mobs: &[Mob],
|
||||
mob: Option<horizon_client::sound_engine::SoundEntityState>,
|
||||
tracked: &HashMap<i32, Tracked>,
|
||||
dragon: Option<(i32, [f64; 3])>,
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
if let Some((player_id, position, silent)) = player
|
||||
@@ -184,18 +185,20 @@ fn entity_sound_state(
|
||||
removed: false,
|
||||
});
|
||||
}
|
||||
if let Some(m) = mobs.iter().find(|m| m.id as i32 == id) {
|
||||
return Some(SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
});
|
||||
let mut state = mob.or_else(|| {
|
||||
tracked.get(&id).map(|t| SoundEntityState {
|
||||
position: t.position,
|
||||
silent: t.silent,
|
||||
removed: false,
|
||||
})
|
||||
})?;
|
||||
// The dragon's Mob is metadata storage; only Dragon follows its movement.
|
||||
if let Some((dragon_id, position)) = dragon
|
||||
&& dragon_id == id
|
||||
{
|
||||
state.position = position;
|
||||
}
|
||||
tracked.get(&id).map(|t| SoundEntityState {
|
||||
position: t.position,
|
||||
silent: t.silent,
|
||||
removed: false,
|
||||
})
|
||||
Some(state)
|
||||
}
|
||||
|
||||
/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the
|
||||
@@ -489,6 +492,9 @@ pub(super) struct Remote {
|
||||
pub(super) builtin: Arc<Builtin>,
|
||||
lang: Arc<Language>,
|
||||
pub(super) registries: Arc<Dynamic>,
|
||||
/// The game's enchantments and the server's that it lacks (a data
|
||||
/// pack's), from their synced data; `None` when the server has none.
|
||||
pub(super) enchantments: Option<Arc<horizon_client::enchant::Enchantments>>,
|
||||
pub(super) joined: bool,
|
||||
/// `ServerReconfigScreen` is up since this tick.
|
||||
pub(super) reconfiguring: Option<u64>,
|
||||
@@ -776,7 +782,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
pub(super) fn remote_sound_entity_state(
|
||||
&self,
|
||||
&mut self,
|
||||
id: i32,
|
||||
) -> Option<horizon_client::sound_engine::SoundEntityState> {
|
||||
let r = self.remote.as_ref()?;
|
||||
@@ -787,9 +793,29 @@ impl Game<'_> {
|
||||
r.local_entity_silent,
|
||||
)
|
||||
});
|
||||
let mut state = entity_sound_state(id, player, &self.mobs, &r.entities)?;
|
||||
// Vehicles, projectiles and other simulated objects may have moved since
|
||||
// the last tracking packet, just as the mob interpolation has.
|
||||
// The local player is not in mobs, so avoid a guaranteed index miss.
|
||||
if player.is_some_and(|p| p.0 == id) {
|
||||
return entity_sound_state(id, player, None, &r.entities, None);
|
||||
}
|
||||
let mob = self.mob_index.get(&self.mobs, id as u32).map(|m| {
|
||||
horizon_client::sound_engine::SoundEntityState {
|
||||
position: [m.position.x, m.position.y, m.position.z],
|
||||
silent: m.silent,
|
||||
removed: m.removed,
|
||||
}
|
||||
});
|
||||
let dragon = self
|
||||
.dragon
|
||||
.as_ref()
|
||||
.map(|d| (d.id as i32, [d.position.x, d.position.y, d.position.z]));
|
||||
let mut state = entity_sound_state(id, player, mob, &r.entities, dragon)?;
|
||||
// Mob states already use the live position. In particular,
|
||||
// level_entity would find the dragon's stale Mob before its Dragon.
|
||||
if mob.is_some() || dragon.is_some_and(|d| d.0 == id) {
|
||||
return Some(state);
|
||||
}
|
||||
// Vehicles, projectiles and other simulated objects may have moved
|
||||
// since the last tracking packet.
|
||||
if let Some(position) = self
|
||||
.level_entity(id)
|
||||
.and_then(|target| self.target_position(target))
|
||||
@@ -1044,6 +1070,7 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| "Connecting to the server...".into()),
|
||||
lang,
|
||||
registries: Arc::new(Dynamic::default()),
|
||||
enchantments: None,
|
||||
joined: false,
|
||||
reconfiguring: None,
|
||||
logged_in: false,
|
||||
@@ -2151,7 +2178,25 @@ impl Game<'_> {
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let entries: Vec<(&str, serde_json::Value)> = registries
|
||||
.registries
|
||||
.get("minecraft:enchantment")
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(name, data)| {
|
||||
Some((
|
||||
name.as_str(),
|
||||
horizon_client::scoreboard::tag_to_json(data.as_ref()?),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
let enchantments = self
|
||||
.stations()
|
||||
.enchantments
|
||||
.with_entries(entries.iter().map(|(n, v)| (*n, v)))
|
||||
.map(Arc::new);
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.enchantments = enchantments;
|
||||
let bits = ceil_log2(biomes.len());
|
||||
let column = r.chunk_format.layout();
|
||||
r.chunk_format =
|
||||
@@ -4237,11 +4282,11 @@ impl Game<'_> {
|
||||
if p.event == 20 && self.mobs.iter().any(|m| m.id == p.id as u32) {
|
||||
self.poof_particles(p.id as u32);
|
||||
}
|
||||
// Villager 12, 13 and 14, TamableAnimal and AbstractHorse 6 and
|
||||
// 7, Animal and Allay 18: the particles about the mob.
|
||||
if matches!(p.event, 6 | 7 | 12 | 13 | 14 | 18 | 40 | 41) {
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
}
|
||||
// The particle bursts of Villager 12 to 14 and 42, TamableAnimal
|
||||
// and AbstractHorse 6 and 7, Animal and Allay 18, Ocelot 40 and
|
||||
// 41, Dolphin 38: the table in `entity_event_burst` is the one
|
||||
// list of them; any other event draws nothing here.
|
||||
self.entity_event_particles(p.id as u32, p.event);
|
||||
// Snowball and ThrownEgg.handleEntityEvent 3: the crumbs where
|
||||
// the projectile is (the server removes it right after).
|
||||
if p.event == 3 {
|
||||
@@ -4268,9 +4313,10 @@ impl Game<'_> {
|
||||
if p.event == super::combat::DROWN_EVENT {
|
||||
self.remote_drown(p.id);
|
||||
}
|
||||
// Entity event 53: the honey slide particles of a player
|
||||
// sliding down a honey block (HoneyBlock.showSlideParticles).
|
||||
if p.event == 53 {
|
||||
// Entity events 53 and 54: honey crumbs while sliding, and the
|
||||
// ten from a landing (`HoneyBlock.showSlideParticles` /
|
||||
// `showJumpParticles`).
|
||||
if horizon_client::landing::honey_particle_count(p.event).is_some() {
|
||||
let at = if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
|
||||
self.player
|
||||
.as_ref()
|
||||
@@ -4282,7 +4328,7 @@ impl Game<'_> {
|
||||
.map(|m| [m.position.x, m.position.y, m.position.z])
|
||||
};
|
||||
if let Some(at) = at {
|
||||
self.honey_slide_particles(at);
|
||||
self.honey_entity_particles(at, p.event);
|
||||
}
|
||||
}
|
||||
if p.event == super::consume::USE_ITEM_COMPLETE_EVENT
|
||||
@@ -5629,7 +5675,10 @@ impl Game<'_> {
|
||||
return;
|
||||
};
|
||||
let at = Vec3::new(p[0], p[1], p[2]);
|
||||
let bob_offset = self.world.random.next_float() * std::f32::consts::PI * 2.0;
|
||||
// ItemEntity's constructor draws bobOffs from the item's own random.
|
||||
let bob_offset = horizon_client::mobs::entity_random(id as u32, at).next_float()
|
||||
* std::f32::consts::PI
|
||||
* 2.0;
|
||||
self.dropped.push(ItemEntity {
|
||||
rotation: [0.0, 0.0],
|
||||
join: Default::default(),
|
||||
@@ -5985,8 +6034,10 @@ impl Game<'_> {
|
||||
for pop in &mut self.hotbar_pop {
|
||||
*pop = pop.saturating_sub(1);
|
||||
}
|
||||
// ClientLevel.tick: the border's size change.
|
||||
self.border.tick();
|
||||
// ClientLevel.tick: the border's size change, while the level runs.
|
||||
if running {
|
||||
self.border.tick();
|
||||
}
|
||||
self.player_border();
|
||||
self.tick_remote_entities(running);
|
||||
self.tick_random_textures();
|
||||
@@ -6287,9 +6338,10 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play("minecraft:block.honey_block.slide", Some(feet), 1.0, 1.0);
|
||||
}
|
||||
// LivingEntity.causeFallDamage on the client's own level: the
|
||||
// fall sound is heard here (LocalPlayer.playSound); the
|
||||
// server's copy skips this player (Player.playSound).
|
||||
// LivingEntity.causeFallDamage and HoneyBlock.fallOn on the
|
||||
// client's own level: the fall and honey sounds are heard here
|
||||
// (LocalPlayer.playSound); the server's copy skips this player
|
||||
// (Player.playSound).
|
||||
horizon_client::motion::MotionEvent::Fall(
|
||||
horizon_client::landing::FallEvent::Sound {
|
||||
sound,
|
||||
@@ -6298,7 +6350,10 @@ impl Game<'_> {
|
||||
..
|
||||
},
|
||||
) => {
|
||||
if let Some(name) = sound.player_fall_sound() {
|
||||
if let Some(name) = sound
|
||||
.player_fall_sound()
|
||||
.or_else(|| sound.honey_landing_sound())
|
||||
{
|
||||
self.audio.play(name, Some(feet), volume, pitch);
|
||||
}
|
||||
}
|
||||
@@ -6972,6 +7027,8 @@ impl Game<'_> {
|
||||
for (item, ticks, at, eye, pitch, yaw) in using {
|
||||
self.remote_player_eating(&item, ticks, at, eye, pitch, yaw);
|
||||
}
|
||||
// ClientLevel.tick: the sky flash fades in a frozen level too.
|
||||
self.flash = self.flash.saturating_sub(1);
|
||||
if running {
|
||||
self.guardian_bubbles();
|
||||
// The dragon: LivingEntity's interpolation, inside its aiStep.
|
||||
@@ -7035,8 +7092,7 @@ impl Game<'_> {
|
||||
},
|
||||
);
|
||||
self.tick_remote_projectiles(&mut step);
|
||||
// LightningBolt.tick on a client, and the sky flash fading.
|
||||
self.flash = self.flash.saturating_sub(1);
|
||||
// LightningBolt.tick on a client.
|
||||
self.tick_bolts();
|
||||
}
|
||||
lerps.retain(|_, l| l.steps > 0);
|
||||
@@ -7652,7 +7708,11 @@ impl Game<'_> {
|
||||
.is_some_and(|r| r.level_players.contains_key(&id));
|
||||
if !player && self.remote_mob_takes(i, hand) {
|
||||
self.remote_feed_particles(i, hand);
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
// As for the objects above, only SUCCESS swings the hand: a
|
||||
// CONSUME ends the click without one.
|
||||
if self.remote_mob_swings(i, hand) {
|
||||
self.with_hand(hand, Game::swing_arm);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -8527,8 +8587,8 @@ impl Game<'_> {
|
||||
}
|
||||
// TridentItem.use: not about to break, Riptide only in water or rain.
|
||||
"minecraft:trident" => {
|
||||
self.use_trident(held);
|
||||
return true;
|
||||
// FAIL: no dip, and the off hand gets its turn.
|
||||
return self.use_trident(held);
|
||||
}
|
||||
// SpyglassItem.use (its sound is the server's).
|
||||
"minecraft:spyglass" => true,
|
||||
@@ -11413,28 +11473,28 @@ mod tests {
|
||||
);
|
||||
let player = Some((2, [0.5, 70.0, 0.5], false));
|
||||
assert_eq!(
|
||||
super::entity_sound_state(2, player, &[], &tracked)
|
||||
super::entity_sound_state(2, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([0.5, 70.0, 0.5]),
|
||||
"the local player is named by Remote.entity_id"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(7, player, &[], &tracked)
|
||||
super::entity_sound_state(7, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
Some([8.5, 64.0, -3.5]),
|
||||
"another entity the server spawned"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(9, player, &[], &tracked)
|
||||
super::entity_sound_state(9, player, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
"getEntity is null: handleSoundEntityEvent returns"
|
||||
);
|
||||
assert_eq!(
|
||||
super::entity_sound_state(2, None, &[], &tracked)
|
||||
super::entity_sound_state(2, None, None, &tracked, None)
|
||||
.filter(|s| !s.silent)
|
||||
.map(|s| s.position),
|
||||
None,
|
||||
@@ -11453,15 +11513,40 @@ mod tests {
|
||||
silent: true,
|
||||
},
|
||||
);
|
||||
let state = super::entity_sound_state(7, None, &[], &tracked).unwrap();
|
||||
let state = super::entity_sound_state(7, None, None, &tracked, None).unwrap();
|
||||
assert!(state.silent);
|
||||
assert!(!state.removed);
|
||||
assert_eq!(state.position, [8.5, 64.0, -3.5]);
|
||||
let player =
|
||||
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), &[], &tracked).unwrap();
|
||||
super::entity_sound_state(2, Some((2, [1.0, 2.0, 3.0], true)), None, &tracked, None)
|
||||
.unwrap();
|
||||
assert!(player.silent);
|
||||
assert!(super::entity_sound_state(9, None, &[], &tracked).is_none());
|
||||
assert!(super::entity_sound_state(9, None, None, &tracked, None).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn a_dragon_sound_uses_live_movement_without_losing_mob_metadata() {
|
||||
use horizon_client::sound_engine::SoundEntityState;
|
||||
let stale = SoundEntityState {
|
||||
position: [0.0, 64.0, 0.0],
|
||||
silent: true,
|
||||
removed: false,
|
||||
};
|
||||
let tracked = std::collections::HashMap::new();
|
||||
for position in [[24.0, 80.0, -16.0], [25.5, 81.0, -18.0]] {
|
||||
let state =
|
||||
super::entity_sound_state(7, None, Some(stale), &tracked, Some((7, position)))
|
||||
.unwrap();
|
||||
assert_eq!(state.position, position);
|
||||
assert!(state.silent);
|
||||
assert!(!state.removed);
|
||||
}
|
||||
let unrelated =
|
||||
super::entity_sound_state(7, None, Some(stale), &tracked, Some((8, [99.0; 3])))
|
||||
.unwrap();
|
||||
assert_eq!(unrelated.position, stale.position);
|
||||
assert!(super::entity_sound_state(7, None, None, &tracked, Some((7, [99.0; 3]))).is_none());
|
||||
}
|
||||
|
||||
/// Q over a slot of the inventory on a server (#3357): the predicted THROW
|
||||
/// drops the stack, so the player's hand swings, as `LivingEntity.drop`
|
||||
/// does for `Prediction.PREDICTED`; a plain pick up drops nothing.
|
||||
|
||||
@@ -148,6 +148,14 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
|
||||
(Species::Mooshroom, 18, Value::Int(variant)) => {
|
||||
m.extra.variant = (*variant == 1) as u8
|
||||
}
|
||||
// Pig.DATA_BOOST_TIME and Strider.DATA_BOOST_TIME (the first entry
|
||||
// after AgeableMob's two; the variant and DATA_SUFFOCATING are 19).
|
||||
// ItemBasedSteering.onSynced: boosting starts, boostTime is 0, and
|
||||
// the total is this int (140 to 980 from boost()).
|
||||
(Species::Pig | Species::Strider, 18, Value::Int(total)) => {
|
||||
m.extra.counter = 0;
|
||||
m.extra.counter2 = *total;
|
||||
}
|
||||
// Piglin.DATA_IS_DANCING: the dance pose.
|
||||
(Species::Piglin, 19, Value::Bool(dancing)) => m.extra.scratch[0] = i32::from(*dancing),
|
||||
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
|
||||
@@ -1430,4 +1438,66 @@ mod tests {
|
||||
// Last fuse tick: still primed at 0 until the server removes it.
|
||||
assert_eq!(horizon_client::mobs::sulfur_cube::fuse(&m), 0);
|
||||
}
|
||||
|
||||
/// `Pig.onSyncedDataUpdated` and `Strider.onSyncedDataUpdated`: a synced
|
||||
/// `DATA_BOOST_TIME` (index 18, after AgeableMob's two) calls
|
||||
/// `ItemBasedSteering.onSynced`. Boosting starts, `boostTime` is 0, and
|
||||
/// the total is the int (`boost()`'s minimum is 140). The next
|
||||
/// `tickBoost` then `boostFactor` is `1 + 1.15 * sin(boostTime / total * PI)`.
|
||||
#[test]
|
||||
fn a_synced_boost_starts_the_client_timer() {
|
||||
use horizon_client::mobs::MobTypes;
|
||||
use horizon_core::geometry::trig::Mth;
|
||||
use std::{fs, sync::Arc};
|
||||
|
||||
let root = std::env::temp_dir().join(format!("horizon-boost-sync-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
fs::write(
|
||||
root.join("entity-types.json"),
|
||||
r#"{"minecraft:pig": {}, "minecraft:strider": {}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("entity-models.json"),
|
||||
r#"{"minecraft:pig#main": {}, "minecraft:strider#main": {}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut types = MobTypes::load(&root).unwrap();
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
|
||||
// ItemBasedSteering: nextInt(841) + 140, and 1.15 in boostFactor.
|
||||
const TOTAL: i32 = 140;
|
||||
const FACTOR: f32 = 1.15;
|
||||
for (kind, steer) in [
|
||||
("minecraft:pig", 0.225_f64),
|
||||
("minecraft:strider", 0.55_f64),
|
||||
] {
|
||||
let t = Arc::new(types.types.remove(kind).unwrap());
|
||||
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
|
||||
m.attrs.speed = 0.25;
|
||||
m.extra.saddled = true;
|
||||
m.extra.counter = 5;
|
||||
m.extra.counter2 = 3;
|
||||
// The next entry (pig variant, strider suffocating) is not the boost.
|
||||
apply(&mut m, &[(19, s::INT, Value::Int(TOTAL))]);
|
||||
assert_eq!((m.extra.counter, m.extra.counter2), (5, 3), "{kind}");
|
||||
apply(&mut m, &[(18, s::INT, Value::Int(TOTAL))]);
|
||||
assert_eq!(m.extra.counter, 0, "{kind}: onSynced sets boostTime to 0");
|
||||
assert_eq!(m.extra.counter2, TOTAL, "{kind}: the synced total");
|
||||
m.rider = Some(equine::Rider {
|
||||
steer: true,
|
||||
..Default::default()
|
||||
});
|
||||
equine::ridden_step(&mut m);
|
||||
// tickBoost post-increments, then boostFactor reads boostTime 1.
|
||||
let boost = 1.0 + FACTOR * (1.0_f32 / TOTAL as f32 * std::f32::consts::PI).msin();
|
||||
let expected = (0.25_f64 * steer) as f32 * boost;
|
||||
assert_eq!(m.extra.counter, 1, "{kind}");
|
||||
assert!(
|
||||
(m.ai.speed - expected).abs() < 1.0e-5,
|
||||
"{kind}: {} != {expected}",
|
||||
m.ai.speed
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,23 @@ impl Game<'_> {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
/// Whether that answer is `InteractionResult.SUCCESS`, the only result that
|
||||
/// swings the hand: `CONSUME` ends the click without one. An adult animal's
|
||||
/// food (`Animal.mobInteract`), a zombie villager's golden apple and a
|
||||
/// sheep's shears (the server alone checks `readyForShearing`) answer
|
||||
/// CONSUME on a client, and a horse's or panda's food SUCCESS_SERVER
|
||||
/// (#9437). A tame wolf, cat or parrot sitting down is SUCCESS.
|
||||
pub(super) fn remote_mob_swings(&self, i: usize, hand: Hand) -> bool {
|
||||
let Some(m) = self.mobs.get(i).filter(|m| m.alive()) else {
|
||||
return false;
|
||||
};
|
||||
let stack = match hand {
|
||||
Hand::Main => self.stack_in(self.selected),
|
||||
Hand::Off => self.offhand.as_ref(),
|
||||
};
|
||||
let item = stack.map_or("", |s| &*s.item);
|
||||
mob_swings(m, item)
|
||||
}
|
||||
/// `AbstractHorse.handleEating` (`Llama`'s and `Camel`'s too) on this
|
||||
/// client's copy: a baby fed something that ages it draws one happy
|
||||
/// villager particle. The server draws it on its own level only, so no
|
||||
@@ -208,6 +225,48 @@ pub(super) fn ages_on_food(m: &Mob, item: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Game::remote_mob_swings`] for mob `m` and the held `item`.
|
||||
fn mob_swings(m: &Mob, item: &str) -> bool {
|
||||
let food = m.kind.food.iter().any(|f| f.as_str() == item);
|
||||
let hurt = m.health < m.attrs.max_health;
|
||||
match m.species() {
|
||||
// AbstractHorse.fedFood (Camel's, Llama's too): SUCCESS_SERVER,
|
||||
// which swings on the server's side only.
|
||||
s if food
|
||||
&& (equine::is_equine(s)
|
||||
|| llama::is_llama(s)
|
||||
|| matches!(s, Species::Camel | Species::CamelHusk)) =>
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Panda.mobInteract: its bamboo answers SUCCESS_SERVER.
|
||||
Species::Panda if food => return false,
|
||||
// Cat.mobInteract: food tames an untamed cat, and heals a hurt
|
||||
// tame one: SUCCESS before Animal.mobInteract is reached.
|
||||
Species::Cat if food && (!m.extra.tame || hurt) => return true,
|
||||
// Wolf.mobInteract: food heals a hurt tame wolf (SUCCESS).
|
||||
Species::Wolf if food && m.extra.tame && hurt => return true,
|
||||
_ => {}
|
||||
}
|
||||
// Animal.mobInteract: a baby's food is SUCCESS, since it ages up; an
|
||||
// adult's is CONSUME, as only the server knows whether it can breed.
|
||||
if m.kind.flags.animal
|
||||
&& !matches!(m.species(), Species::Nautilus | Species::ZombieNautilus)
|
||||
&& food
|
||||
{
|
||||
return m.is_baby() && !m.extra.age_locked;
|
||||
}
|
||||
// A tame wolf, cat or parrot sits or stands up: SUCCESS
|
||||
// (TamableAnimal's subclasses), so it swings.
|
||||
let consumes = match m.species() {
|
||||
// ZombieVillager.mobInteract: a golden apple cures it on the server.
|
||||
Species::ZombieVillager => item == "minecraft:golden_apple",
|
||||
// Sheep.mobInteract: shears, sheared or not.
|
||||
Species::Sheep => item == "minecraft:shears",
|
||||
_ => false,
|
||||
};
|
||||
!consumes
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -230,6 +289,50 @@ mod tests {
|
||||
))
|
||||
}
|
||||
|
||||
/// Tamables' and horses' own `mobInteract` answer before `Animal.mobInteract`'s
|
||||
/// CONSUME (#9450): sitting down and healing are SUCCESS, a horse's food
|
||||
/// SUCCESS_SERVER.
|
||||
#[test]
|
||||
fn only_a_client_success_swings_the_hand() {
|
||||
let Some(mut wolf) = mob("minecraft:wolf") else {
|
||||
return;
|
||||
};
|
||||
wolf.extra.tame = true;
|
||||
assert!(
|
||||
mob_swings(&wolf, ""),
|
||||
"a tame wolf sits: SUCCESS.withoutItem"
|
||||
);
|
||||
assert!(
|
||||
!mob_swings(&wolf, "minecraft:beef"),
|
||||
"full health: Animal's CONSUME"
|
||||
);
|
||||
wolf.health = wolf.attrs.max_health - 1.0;
|
||||
assert!(
|
||||
mob_swings(&wolf, "minecraft:beef"),
|
||||
"hurt: Wolf.feed is SUCCESS"
|
||||
);
|
||||
let mut cat = mob("minecraft:cat").unwrap();
|
||||
assert!(mob_swings(&cat, "minecraft:cod"), "taming: SUCCESS");
|
||||
cat.extra.tame = true;
|
||||
assert!(
|
||||
!mob_swings(&cat, "minecraft:cod"),
|
||||
"tame, full health: CONSUME"
|
||||
);
|
||||
assert!(mob_swings(&cat, ""), "a tame cat sits: SUCCESS");
|
||||
let mut parrot = mob("minecraft:parrot").unwrap();
|
||||
parrot.extra.tame = true;
|
||||
assert!(mob_swings(&parrot, ""), "a tame parrot sits: SUCCESS");
|
||||
let horse = mob("minecraft:horse").unwrap();
|
||||
assert!(
|
||||
!mob_swings(&horse, "minecraft:apple"),
|
||||
"AbstractHorse.fedFood: SUCCESS_SERVER"
|
||||
);
|
||||
let cow = mob("minecraft:cow").unwrap();
|
||||
assert!(!mob_swings(&cow, "minecraft:wheat"), "adult: CONSUME");
|
||||
let sheep = mob("minecraft:sheep").unwrap();
|
||||
assert!(!mob_swings(&sheep, "minecraft:shears"), "Sheep: CONSUME");
|
||||
}
|
||||
|
||||
/// `AbstractNautilus.mobInteract` (#8595): an untamed adult takes its taming
|
||||
/// items only, and a tame adult is mounted by any click.
|
||||
#[test]
|
||||
|
||||
@@ -112,6 +112,7 @@ impl Game<'_> {
|
||||
fixed: false,
|
||||
invisible: false,
|
||||
check_interval: 0,
|
||||
custom_name: None,
|
||||
});
|
||||
} else if kind == "minecraft:cushion" {
|
||||
// BlockAttachedEntity.pos: `BlockPos.containing` of the spawn.
|
||||
|
||||
@@ -360,9 +360,11 @@ impl Game<'_> {
|
||||
// FishingHook.recreateFromPacket: the owner's id is the data.
|
||||
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, velocity, data),
|
||||
"minecraft:end_crystal" => {
|
||||
// EndCrystal's constructor starts `time` random, so each
|
||||
// placed crystal spins from its own phase.
|
||||
let time = self.world.random.next_int_bound(100_000).unwrap_or(0);
|
||||
// EndCrystal's constructor starts `time` from the crystal's own
|
||||
// random, so each placed crystal spins from its own phase.
|
||||
let time = horizon_client::mobs::entity_random(id, at)
|
||||
.next_int_bound(100_000)
|
||||
.unwrap_or(0);
|
||||
self.crystals.push(super::dragon::Crystal {
|
||||
id,
|
||||
position: at,
|
||||
|
||||
@@ -975,6 +975,7 @@ mod tab_list_tests {
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(42)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
uuid as i32,
|
||||
[0.0, 64.0, 0.0],
|
||||
|
||||
@@ -225,6 +225,16 @@ impl Game<'_> {
|
||||
.get("separateAo")
|
||||
.is_some_and(|v| v.trim() == "true");
|
||||
renderer.set_separate_ao(separate_ao);
|
||||
// Lava's faces draw with the solid terrain's program.
|
||||
renderer.set_pack_lava_layers(
|
||||
["lava_still", "lava_flow"]
|
||||
.iter()
|
||||
.filter_map(|f| {
|
||||
let name = format!("minecraft:block/{f}");
|
||||
self.pack.render.sprite_layers.get(&name).map(|&l| l as u16)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
let folder = dimension_folder_named(&pack, self.dim.id(), dimension);
|
||||
let inputs = renderer.pack_inputs();
|
||||
let mut post = load_post(&pack, &folder, &properties, ¯os, &inputs);
|
||||
|
||||
@@ -413,7 +413,18 @@ impl GameLevel<'_> {
|
||||
self.pending.record_saved(at, &tag);
|
||||
}
|
||||
let dynamic = self.pending.dynamic(at);
|
||||
let tool = self.drop_tool.as_ref();
|
||||
// Only the block a machine breaks: a neighbour that pops off with it
|
||||
// drops as with no tool, and pops (`Level.destroyBlock` for it).
|
||||
let machine = self
|
||||
.drop_tool
|
||||
.as_ref()
|
||||
.is_some_and(|(target, _)| *target == key);
|
||||
let tool = self
|
||||
.drop_tool
|
||||
.as_ref()
|
||||
.filter(|_| machine)
|
||||
.and_then(|(_, tool)| tool.as_ref());
|
||||
let silk = tool.is_some_and(|t| t.enchantment_level("minecraft:silk_touch") > 0);
|
||||
let drops = match self.items.block_loot_sequence(self.registry, state) {
|
||||
Some(sequence) => {
|
||||
let random = self.rand_sequences.get(&sequence, self.seed);
|
||||
@@ -432,7 +443,7 @@ impl GameLevel<'_> {
|
||||
if &*stack.item == "minecraft:air" || stack.count == 0 {
|
||||
continue;
|
||||
}
|
||||
match self.drop_into.as_mut() {
|
||||
match self.drop_into.as_mut().filter(|_| machine) {
|
||||
Some(kept) => {
|
||||
self.pending.attach(at, &mut stack);
|
||||
kept.push(stack);
|
||||
@@ -441,10 +452,15 @@ impl GameLevel<'_> {
|
||||
}
|
||||
}
|
||||
self.pending.clear(at);
|
||||
// BlockBehaviour.spawnAfterBreak with no tool: ores, sculk and spawners
|
||||
// award experience at the block's centre.
|
||||
// BlockBehaviour.spawnAfterBreak: ores, sculk and spawners award
|
||||
// experience at the block's centre, except to a machine's silk touch
|
||||
// tool (`processBlockExperience`; a spawner keeps its own), as the
|
||||
// player's break does.
|
||||
let name = self.facts.get(state).name.clone();
|
||||
if let Some(mut xp) = removal::break_experience(&mut self.world.random, &name)
|
||||
// `xpRange.sample` draws before silk touch zeroes it.
|
||||
let xp = removal::break_experience(&mut self.world.random, &name)
|
||||
.filter(|_| !silk || &*name == "minecraft:spawner");
|
||||
if let Some(mut xp) = xp
|
||||
&& self.rules.block_drops
|
||||
{
|
||||
if self.native_authoritative {
|
||||
|
||||
@@ -1720,7 +1720,7 @@ impl Game<'_> {
|
||||
if inside(local, bx, by, bw, 17.0)
|
||||
&& let Some((id, l)) = &offers.clues[i]
|
||||
{
|
||||
let e = &self.stations().enchantments;
|
||||
let e = self.enchantments();
|
||||
let (name, _) = e.full_name(id, *l);
|
||||
let clue = self
|
||||
.translate("container.enchant.clue", "%s . . . ?")
|
||||
@@ -2121,7 +2121,7 @@ impl Game<'_> {
|
||||
nested: bool,
|
||||
creative: bool,
|
||||
) -> Vec<(horizon_client::text::Text, u32)> {
|
||||
let e: &Enchantments = &self.stations().enchantments;
|
||||
let e: &Enchantments = self.enchantments();
|
||||
let color = self.rarity_color(stack);
|
||||
let advanced = !nested && self.options_advanced_tooltips();
|
||||
let display = horizon_client::item_tooltip::TooltipDisplay::for_stack(stack);
|
||||
|
||||
@@ -135,8 +135,9 @@ impl Stats {
|
||||
}
|
||||
stats
|
||||
}
|
||||
/// `ServerStatsCounter.save`.
|
||||
pub(super) fn write(&self, path: &Path) {
|
||||
/// `ServerStatsCounter.toJson`: what `write` puts in the file, and what a
|
||||
/// hand-over's record carries (`player-export`).
|
||||
pub(super) fn to_json(&self) -> serde_json::Value {
|
||||
let stats: serde_json::Map<String, serde_json::Value> = self
|
||||
.values
|
||||
.iter()
|
||||
@@ -147,10 +148,14 @@ impl Stats {
|
||||
(ty.clone(), serde_json::Value::Object(entries))
|
||||
})
|
||||
.collect();
|
||||
let json = serde_json::json!({
|
||||
serde_json::json!({
|
||||
"stats": stats,
|
||||
"DataVersion": horizon_world::DATA_VERSION,
|
||||
});
|
||||
})
|
||||
}
|
||||
/// `ServerStatsCounter.save`.
|
||||
pub(super) fn write(&self, path: &Path) {
|
||||
let json = self.to_json();
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
|
||||
@@ -253,6 +253,8 @@ impl Game<'_> {
|
||||
.unwrap_or_else(|| self.items().get(&stack.item).map_or(250, |i| i.max_damage))
|
||||
}
|
||||
/// `TridentItem.use`: start the throw unless one more use would break it.
|
||||
/// Returns false for `InteractionResult.FAIL` (about to break, or Riptide
|
||||
/// while dry) so callers neither dip the hand nor consume the click.
|
||||
pub(super) fn use_trident(&mut self, stack: &ItemStack) -> bool {
|
||||
if &*stack.item != "minecraft:trident" {
|
||||
return false;
|
||||
@@ -260,7 +262,7 @@ impl Game<'_> {
|
||||
let max = self.trident_max_damage(stack);
|
||||
let riptide = stack.enchantment_level("minecraft:riptide") > 0;
|
||||
if stack.damage >= max.saturating_sub(1) || (riptide && !self.in_water_or_rain()) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
self.start_using_item();
|
||||
true
|
||||
|
||||
@@ -519,3 +519,18 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod entity_event_particle_tests {
|
||||
/// The raid sweat (42) and the dolphin's treasure hearts (38) are in the
|
||||
/// table that now decides which entity events draw particles.
|
||||
#[test]
|
||||
fn the_table_draws_the_raid_sweat_and_the_dolphin_event() {
|
||||
let kind = |entity, event| {
|
||||
horizon_client::particles::entity_event_burst(entity, event).map(|(k, _)| k)
|
||||
};
|
||||
assert_eq!(kind("villager", 42), Some("splash"));
|
||||
assert_eq!(kind("dolphin", 38), Some("happy_villager"));
|
||||
assert_eq!(kind("villager", 2), None);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,11 @@ impl Game<'_> {
|
||||
if owner_breeze && m.species() == Species::Breeze {
|
||||
return;
|
||||
}
|
||||
// Refused before the hurt, so the kill is not credited
|
||||
// either; the burst of `wind_charge_hit` still goes off.
|
||||
if horizon_client::mobs::wither::armor_refuses(m, &p.kind) {
|
||||
return;
|
||||
}
|
||||
m.damage(1.0, source);
|
||||
}
|
||||
if p.owner == Some(Who::Player) {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -771,17 +771,40 @@ fn serve_login(
|
||||
lost(shared, &user, &text);
|
||||
return Ok(());
|
||||
}
|
||||
// A player a proxy said would arrive (`player-expect`) uses the expectation up
|
||||
// here, as the login starts: one that ran out while the player was still in
|
||||
// configuration (a resource pack prompt) would otherwise make it an ordinary
|
||||
// login from this server's own file.
|
||||
let expected = shared.config.arrivals.as_ref().and_then(|table| {
|
||||
table
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take_until(uuid, std::time::Instant::now())
|
||||
});
|
||||
// A login that does not reach play (it failed in configuration) gives the
|
||||
// expectation back, so the proxy's next attempt is still expected.
|
||||
let put_back = || {
|
||||
if let (Some(deadline), Some(table)) = (expected, shared.config.arrivals.as_ref()) {
|
||||
table.lock().unwrap_or_else(|e| e.into_inner()).put_back(
|
||||
uuid,
|
||||
deadline,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let result = login_and_configure(&mut c, shared, uuid, &name, properties);
|
||||
match result {
|
||||
Ok(Some(parts)) => {
|
||||
play(c, shared, parts, address, counted);
|
||||
play(c, shared, parts, address, counted, expected.is_some());
|
||||
Ok(())
|
||||
}
|
||||
Ok(None) => {
|
||||
put_back();
|
||||
shared.release(uuid);
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
put_back();
|
||||
shared.release(uuid);
|
||||
// ServerConfigurationPacketListenerImpl.onDisconnect.
|
||||
let profile = format!("{name} ({})", uuid_string(uuid));
|
||||
@@ -1227,6 +1250,7 @@ fn play(
|
||||
parts: Parts,
|
||||
address: std::net::SocketAddr,
|
||||
counted: Option<super::Counted>,
|
||||
expected: bool,
|
||||
) {
|
||||
shared.entered_play();
|
||||
let conn = shared.next_id();
|
||||
@@ -1277,6 +1301,7 @@ fn play(
|
||||
sender,
|
||||
latency,
|
||||
address,
|
||||
expected,
|
||||
};
|
||||
let mut session = Box::new(session);
|
||||
if shared
|
||||
|
||||
@@ -338,6 +338,14 @@ pub struct Guest {
|
||||
pub death_time: u32,
|
||||
/// The join packets were sent (PlayerList.placeNewPlayer ran).
|
||||
pub placed: bool,
|
||||
/// The hand-over a proxy started by exporting it with `freeze`
|
||||
/// (`arrivals::Handover`): while it lasts, what it sends other than its
|
||||
/// acknowledgements is not acted on, and every export answers with the
|
||||
/// same record. It ends after `[transfer]`'s `max_freeze_seconds`.
|
||||
pub handover: Option<super::arrivals::Handover>,
|
||||
/// Its login started while a proxy said it would arrive from another
|
||||
/// server, and used that expectation up (`player-expect`).
|
||||
pub expected: bool,
|
||||
/// Root command names the mods took out of this player's command tree
|
||||
/// (`native_player_commands_sent`); their tree is sent without them.
|
||||
pub hidden_commands: Vec<String>,
|
||||
@@ -512,6 +520,8 @@ impl Guest {
|
||||
dead: false,
|
||||
death_time: 0,
|
||||
placed: false,
|
||||
handover: None,
|
||||
expected: j.expected,
|
||||
hidden_commands: Vec::new(),
|
||||
flying_speed: 0.05,
|
||||
dimension: "minecraft:overworld".into(),
|
||||
@@ -1534,6 +1544,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx, Arc::new(std::sync::atomic::AtomicUsize::new(0))),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:1".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
1,
|
||||
position,
|
||||
|
||||
@@ -269,6 +269,9 @@ pub struct Hub {
|
||||
pub native_menu_events: Vec<super::menu::NativeMenuEvent>,
|
||||
/// Kicks waiting for the game thread to notify native mods.
|
||||
pending_kicks: Vec<HostEvent>,
|
||||
/// `[transfer]` on (a dedicated server's `--transfers`): the players a proxy
|
||||
/// said would arrive (`player-expect`). None while it is off.
|
||||
pub transfers: Option<super::arrivals::Transfers>,
|
||||
/// The entities players started and stopped tracking, collected by
|
||||
/// `collect_tracking`, for the game to tell the mods.
|
||||
pub native_tracking: Vec<TrackingBatch>,
|
||||
@@ -670,6 +673,7 @@ impl Hub {
|
||||
mod_commands: (serde_json::Value::Null, None),
|
||||
native_menu_events: Vec::new(),
|
||||
pending_kicks: Vec::new(),
|
||||
transfers: None,
|
||||
native_tracking: Vec::new(),
|
||||
attachment_snapshots: Vec::new(),
|
||||
sync_attachments: false,
|
||||
@@ -999,6 +1003,35 @@ impl Hub {
|
||||
);
|
||||
}
|
||||
|
||||
/// Lets go of what a proxy that stopped answering left behind: the players
|
||||
/// it said would come and did not, and the freezes no hand-over ended.
|
||||
/// Neither may last for ever.
|
||||
fn expire_arrivals(&mut self, events: &mut Vec<HostEvent>) {
|
||||
// Off: nothing to do (and no player can be frozen).
|
||||
let Some(transfers) = &self.transfers else {
|
||||
return;
|
||||
};
|
||||
for uuid in transfers.table().expire(std::time::Instant::now()) {
|
||||
events.push(HostEvent::Warn(format!(
|
||||
"player {} was expected from another server and never came",
|
||||
horizon_protocol::crypto::uuid_string(uuid)
|
||||
)));
|
||||
}
|
||||
let max = transfers.settings.max_freeze_ticks;
|
||||
for g in &mut self.guests {
|
||||
if g.handover
|
||||
.as_ref()
|
||||
.is_some_and(|h| super::arrivals::frozen_too_long(h.since, self.ticks, max))
|
||||
{
|
||||
g.handover = None;
|
||||
events.push(HostEvent::Warn(format!(
|
||||
"{} was frozen for a hand-over for {max} ticks and nothing finished it: let go",
|
||||
g.name,
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a player of `permission` sees the player `uuid`: not one a
|
||||
/// native mod hid from players below a higher level.
|
||||
pub fn sees(&self, permission: u8, uuid: u128) -> bool {
|
||||
@@ -1713,6 +1746,7 @@ impl Hub {
|
||||
self.ticks += 1;
|
||||
self.anticheat_tick(ctx.tick_rate);
|
||||
let mut events = std::mem::take(&mut self.pending_kicks);
|
||||
self.expire_arrivals(&mut events);
|
||||
for event in self.lan.poll() {
|
||||
match event {
|
||||
NetEvent::Joined(j) => {
|
||||
@@ -1787,6 +1821,41 @@ impl Hub {
|
||||
events
|
||||
}
|
||||
|
||||
/// Keeps, in a frozen player's inbox, the packets that answer the server
|
||||
/// (teleport, chunk batch, loaded, keep-alive, ping, tick end, client
|
||||
/// information) and drops the rest. A chat message or signed command is
|
||||
/// dropped too, but the messages it says the client has seen are taken
|
||||
/// first (`unpackAndApplyLastSeen`), and a chat acknowledgement is handled
|
||||
/// here, in the order they came: the client counts them as acknowledged,
|
||||
/// and its next message after the freeze would not validate otherwise.
|
||||
pub(crate) fn keep_acknowledgements(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
let inbox = std::mem::take(&mut self.guests[i].inbox);
|
||||
let mut bytes = 0;
|
||||
for (packet, size) in inbox {
|
||||
match &packet {
|
||||
S::Chat(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatCommandSigned(c) => {
|
||||
self.apply_last_seen(i, &c.last_seen, events);
|
||||
}
|
||||
S::ChatAck(c) => self.chat_ack(i, c.offset, events),
|
||||
S::AcceptTeleportation(_)
|
||||
| S::PlayerLoaded(_)
|
||||
| S::ChunkBatchReceived(_)
|
||||
| S::ClientInformation(_)
|
||||
| S::ClientTickEnd(_)
|
||||
| S::KeepAlive(_)
|
||||
| S::Pong(_) => {
|
||||
bytes += size;
|
||||
self.guests[i].inbox.push_back((packet, size));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.guests[i].inbox_bytes = bytes;
|
||||
}
|
||||
|
||||
/// Handle the players in this level's packets (`ServerGamePacketListenerImpl`
|
||||
/// for each), before the level's scheduled block ticks so a place landed in
|
||||
/// the same tick as a falling block's schedule still sees the support
|
||||
@@ -1797,10 +1866,19 @@ impl Hub {
|
||||
let timed = crate::spans::recording_here();
|
||||
for i in 0..self.guests.len() {
|
||||
if self.here(&self.guests[i]) {
|
||||
let frozen = self.guests[i].handover.is_some();
|
||||
if frozen {
|
||||
// Frozen for a hand-over: it answers the server and nothing more.
|
||||
self.keep_acknowledgements(i, &mut events);
|
||||
}
|
||||
let started = timed.then(std::time::Instant::now);
|
||||
let waiting = self.guests[i].inbox.len();
|
||||
self.process_inbox(i, ctx, &mut events);
|
||||
self.tick_floating(i, &mut events);
|
||||
// Its moves are dropped, so whether the last one left it floating is
|
||||
// stale: a player frozen in a jump is not kicked for flying.
|
||||
if !frozen {
|
||||
self.tick_floating(i, &mut events);
|
||||
}
|
||||
// A slow tick's line says whose packets took it (`spans::note`).
|
||||
if let Some(started) = started {
|
||||
let g = &self.guests[i];
|
||||
@@ -4537,6 +4615,7 @@ mod tests {
|
||||
sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()),
|
||||
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
1,
|
||||
[0.5, 64.0, 0.5],
|
||||
|
||||
@@ -904,6 +904,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! network pays nothing.
|
||||
mod advancements;
|
||||
pub mod anticheat;
|
||||
pub mod arrivals;
|
||||
pub mod attributes;
|
||||
pub mod border;
|
||||
pub mod chunks;
|
||||
@@ -80,6 +81,9 @@ pub struct Joined {
|
||||
/// Smoothed keep-alive round trip in milliseconds.
|
||||
pub latency: Arc<AtomicI32>,
|
||||
pub address: SocketAddr,
|
||||
/// The login used up an expectation of a proxy's (`player-expect`) when it
|
||||
/// started (`arrivals`).
|
||||
pub expected: bool,
|
||||
}
|
||||
/// What a [`PlayerSender`] made for a test's channel gives it.
|
||||
pub enum Out {
|
||||
@@ -182,6 +186,10 @@ pub struct NetConfig {
|
||||
/// Behind a Velocity proxy: the secret its forwarded player information is signed with. Only an offline
|
||||
/// server (the proxy authenticates) uses it; without it a login is the plain offline one.
|
||||
pub forwarding_secret: Option<Vec<u8>>,
|
||||
/// `[transfer]` on: the players a proxy said would arrive
|
||||
/// (`arrivals::Transfers`), shared with the hub. A login uses its
|
||||
/// expectation up as it starts, once the bans and the whitelist let it in.
|
||||
pub arrivals: Option<Arc<std::sync::Mutex<arrivals::Expectations>>>,
|
||||
pub log: Option<NetLog>,
|
||||
/// server.properties `resource-pack`: offered to every player during
|
||||
/// configuration (a dedicated server's; MinecraftServer.getServerResourcePack).
|
||||
@@ -234,6 +242,7 @@ impl NetConfig {
|
||||
gate: None,
|
||||
online: None,
|
||||
forwarding_secret: None,
|
||||
arrivals: None,
|
||||
log: None,
|
||||
resource_pack: None,
|
||||
code_of_conducts: Vec::new(),
|
||||
|
||||
@@ -212,8 +212,9 @@ pub fn apply(guest: &mut Guest, level: &Level) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared player compound for logout and autosave snapshots.
|
||||
fn player_tag(guest: &Guest) -> Tag {
|
||||
/// The shared player compound for logout and autosave snapshots, and for
|
||||
/// `player-export`'s record (`arrivals::export_record`).
|
||||
pub fn player_tag(guest: &Guest) -> Tag {
|
||||
let mut level = Level {
|
||||
player_uuid: Some(uuid_ints(guest.uuid)),
|
||||
..Level::default()
|
||||
@@ -538,6 +539,7 @@ mod tests {
|
||||
sender: PlayerSender::new(tx, Arc::default()),
|
||||
latency: Arc::new(AtomicI32::new(0)),
|
||||
address: "127.0.0.1:25565".parse().unwrap(),
|
||||
expected: false,
|
||||
},
|
||||
2,
|
||||
[0.0, 64.0, 0.0],
|
||||
|
||||
@@ -104,6 +104,16 @@ impl SceneSound {
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
/// `HoneyBlock.fallOn`: `entity.playSound` of the slide, and of the block's
|
||||
/// own fall sound when the landing still hurts. A player's `playSound`
|
||||
/// skips that player; their client plays the same event itself.
|
||||
pub fn honey_landing_sound(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Block(LandingSound::HoneySlide) => Some("minecraft:block.honey_block.slide"),
|
||||
Self::Block(LandingSound::HoneyFall) => Some("minecraft:block.honey_block.fall"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::SmallFall => "small",
|
||||
@@ -117,6 +127,16 @@ impl SceneSound {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `HoneyBlock.showParticles`: a slide (entity event 53) sheds 5 honey-block
|
||||
/// crumbs, a landing (entity event 54) sheds 10.
|
||||
pub fn honey_particle_count(event: i8) -> Option<u32> {
|
||||
match event {
|
||||
53 => Some(5),
|
||||
54 => Some(10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum FallEvent {
|
||||
Particles {
|
||||
@@ -783,4 +803,23 @@ mod tests {
|
||||
);
|
||||
assert_eq!(SceneSound::BigFall.block_sound(), None);
|
||||
}
|
||||
|
||||
/// `HoneyBlock.fallOn` plays the slide, then the block's fall sound when
|
||||
/// the landing hurts. `showSlideParticles` is entity event 53 (5 crumbs);
|
||||
/// `showJumpParticles` is entity event 54 (10).
|
||||
#[test]
|
||||
fn a_honey_landing_names_its_sounds_and_particle_counts() {
|
||||
assert_eq!(
|
||||
SceneSound::Block(LandingSound::HoneySlide).honey_landing_sound(),
|
||||
Some("minecraft:block.honey_block.slide")
|
||||
);
|
||||
assert_eq!(
|
||||
SceneSound::Block(LandingSound::HoneyFall).honey_landing_sound(),
|
||||
Some("minecraft:block.honey_block.fall")
|
||||
);
|
||||
assert_eq!(SceneSound::SmallFall.honey_landing_sound(), None);
|
||||
assert_eq!(honey_particle_count(53), Some(5));
|
||||
assert_eq!(honey_particle_count(54), Some(10));
|
||||
assert_eq!(honey_particle_count(46), None);
|
||||
}
|
||||
}
|
||||
@@ -2714,6 +2714,10 @@ pub struct PlayerView {
|
||||
pub main_hand: Option<Arc<str>>,
|
||||
pub off_hand: Option<Arc<str>>,
|
||||
pub head: Option<Arc<str>>,
|
||||
/// `getItemBySlot` for chest, legs and feet.
|
||||
pub chest: Option<Arc<str>>,
|
||||
pub legs: Option<Arc<str>>,
|
||||
pub feet: Option<Arc<str>>,
|
||||
pub armor_cover: f32,
|
||||
/// The Invisibility effect (`isInvisible`).
|
||||
pub invisible: bool,
|
||||
@@ -2762,8 +2766,28 @@ pub struct Vehicle {
|
||||
pub velocity: Vec3,
|
||||
}
|
||||
impl PlayerView {
|
||||
/// `LivingEntity.getVisibilityPercent` without the worn-skull scaling.
|
||||
pub fn visibility_percent(&self) -> f64 {
|
||||
/// Worn stacks in `EquipmentSlot.VALUES` order. A player has no body or
|
||||
/// saddle slot.
|
||||
fn worn_slots(&self) -> [(&'static str, Option<&str>); 6] {
|
||||
[
|
||||
("mainhand", self.main_hand.as_deref()),
|
||||
("offhand", self.off_hand.as_deref()),
|
||||
("feet", self.feet.as_deref()),
|
||||
("legs", self.legs.as_deref()),
|
||||
("chest", self.chest.as_deref()),
|
||||
("head", self.head.as_deref()),
|
||||
]
|
||||
}
|
||||
/// `LivingEntity.getVisibilityPercent`: 1, ×0.8 while discrete
|
||||
/// (`isShiftKeyDown`), ×0.7×armor cover (at least 0.1) while invisible,
|
||||
/// then each worn stack's `minecraft:mob_visibility` when its equippable
|
||||
/// slot matches and `targeting` is in `targetingEntityTypes`. Clamped to
|
||||
/// 0..=10 (`Mth.clamp`). `targeting` is null when nobody is looking.
|
||||
pub fn visibility_percent(
|
||||
&self,
|
||||
targeting: Option<&str>,
|
||||
items: Option<&crate::items::Items>,
|
||||
) -> f64 {
|
||||
let mut visibility = 1.0;
|
||||
if self.sneaking {
|
||||
visibility *= 0.8;
|
||||
@@ -2771,7 +2795,20 @@ impl PlayerView {
|
||||
if self.invisible {
|
||||
visibility *= 0.7 * f64::from(self.armor_cover).max(0.1);
|
||||
}
|
||||
visibility
|
||||
if let (Some(targeting), Some(items)) = (targeting, items) {
|
||||
for (slot, item) in self.worn_slots() {
|
||||
let Some(item) = item else { continue };
|
||||
if let Some(factor) = items.worn_visibility(item, slot, targeting) {
|
||||
visibility *= f64::from(factor);
|
||||
}
|
||||
}
|
||||
}
|
||||
// `Mth.clamp`: value < min ? min : Math.min(value, max).
|
||||
if visibility < 0.0 {
|
||||
0.0
|
||||
} else {
|
||||
visibility.min(10.0)
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.hasEffect`, by id without namespace.
|
||||
pub fn has_effect(&self, effect: &str) -> bool {
|
||||
@@ -2871,14 +2908,20 @@ impl<'a> Teams<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `Entity.random` of a client's copy of entity `id` first seen at `position`: vanilla's is its
|
||||
/// own (`RandomSource.create()`), drawn on by nothing else; this one is seeded from the two.
|
||||
pub fn entity_random(id: u32, position: Vec3) -> LegacyRandom {
|
||||
let mut random = LegacyRandom::new(
|
||||
i64::from(id).wrapping_mul(0x5DEE_CE66D)
|
||||
^ (position.x.to_bits() as i64)
|
||||
^ (position.z.to_bits() as i64).rotate_left(17),
|
||||
);
|
||||
let _ = random.next_int();
|
||||
random
|
||||
}
|
||||
impl Mob {
|
||||
pub fn new(id: u32, kind: Arc<MobType>, position: Vec3, yaw: f32) -> Self {
|
||||
let mut random = LegacyRandom::new(
|
||||
i64::from(id).wrapping_mul(0x5DEE_CE66D)
|
||||
^ (position.x.to_bits() as i64)
|
||||
^ (position.z.to_bits() as i64).rotate_left(17),
|
||||
);
|
||||
let _ = random.next_int();
|
||||
let random = entity_random(id, position);
|
||||
let mut mob = Self {
|
||||
join: Default::default(),
|
||||
id,
|
||||
@@ -5424,3 +5467,100 @@ mod silent_voice_tests {
|
||||
assert_eq!(quiet_next, loud_next, "the pitch is drawn either way");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod visibility_percent_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// The four heads `Items` registers, with the component JSON vanilla writes.
|
||||
fn heads() -> crate::items::Items {
|
||||
let mut items = crate::items::Items::for_tests(HashMap::new());
|
||||
items.insert_mob_visibility_component(
|
||||
"minecraft:skeleton_skull",
|
||||
&json!({
|
||||
"minecraft:equippable": {"slot": "head", "swappable": false},
|
||||
"minecraft:mob_visibility": {
|
||||
"targeting_entity_types": "minecraft:skeleton",
|
||||
"visibility": 0.5
|
||||
}
|
||||
}),
|
||||
);
|
||||
items.insert_mob_visibility_component(
|
||||
"minecraft:zombie_head",
|
||||
&json!({
|
||||
"minecraft:equippable": {"slot": "head"},
|
||||
"minecraft:mob_visibility": {
|
||||
"targeting_entity_types": "minecraft:zombie",
|
||||
"visibility": 0.5
|
||||
}
|
||||
}),
|
||||
);
|
||||
items.insert_mob_visibility_component(
|
||||
"minecraft:creeper_head",
|
||||
&json!({
|
||||
"minecraft:equippable": {"slot": "head"},
|
||||
"minecraft:mob_visibility": {
|
||||
"targeting_entity_types": "minecraft:creeper",
|
||||
"visibility": 0.5
|
||||
}
|
||||
}),
|
||||
);
|
||||
items.insert_mob_visibility_component(
|
||||
"minecraft:piglin_head",
|
||||
&json!({
|
||||
"minecraft:equippable": {"slot": "head"},
|
||||
"minecraft:mob_visibility": {
|
||||
"targeting_entity_types": ["minecraft:piglin", "minecraft:piglin_brute"],
|
||||
"visibility": 0.5
|
||||
}
|
||||
}),
|
||||
);
|
||||
items
|
||||
}
|
||||
|
||||
/// `LivingEntity.getVisibilityPercent` halves the range for the mob named
|
||||
/// on the worn head, and leaves every other mob at full range. A skull
|
||||
/// held in the hand does not count: its equippable slot is the head.
|
||||
#[test]
|
||||
fn worn_mob_heads_halve_that_mobs_detection_range() {
|
||||
let items = heads();
|
||||
let see = |head: &str, mob: &str| {
|
||||
let mut player = world::test_player(Vec3::ZERO);
|
||||
player.head = Some(Arc::from(head));
|
||||
player.visibility_percent(Some(mob), Some(&items))
|
||||
};
|
||||
assert_eq!(see("minecraft:skeleton_skull", "minecraft:skeleton"), 0.5);
|
||||
assert_eq!(see("minecraft:zombie_head", "minecraft:zombie"), 0.5);
|
||||
assert_eq!(see("minecraft:creeper_head", "minecraft:creeper"), 0.5);
|
||||
assert_eq!(see("minecraft:piglin_head", "minecraft:piglin"), 0.5);
|
||||
assert_eq!(see("minecraft:piglin_head", "minecraft:piglin_brute"), 0.5);
|
||||
assert_eq!(see("minecraft:skeleton_skull", "minecraft:zombie"), 1.0);
|
||||
assert_eq!(
|
||||
see("minecraft:piglin_head", "minecraft:zombified_piglin"),
|
||||
1.0
|
||||
);
|
||||
assert_eq!(
|
||||
see(
|
||||
"minecraft:wither_skeleton_skull",
|
||||
"minecraft:wither_skeleton"
|
||||
),
|
||||
1.0
|
||||
);
|
||||
|
||||
let mut held = world::test_player(Vec3::ZERO);
|
||||
held.main_hand = Some(Arc::from("minecraft:skeleton_skull"));
|
||||
assert_eq!(
|
||||
held.visibility_percent(Some("minecraft:skeleton"), Some(&items)),
|
||||
1.0
|
||||
);
|
||||
|
||||
let mut sneaking = world::test_player(Vec3::ZERO);
|
||||
sneaking.head = Some(Arc::from("minecraft:skeleton_skull"));
|
||||
sneaking.sneaking = true;
|
||||
assert_eq!(
|
||||
sneaking.visibility_percent(Some("minecraft:skeleton"), Some(&items)),
|
||||
0.4
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2749,6 +2749,9 @@ pub fn custom_travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<Mo
|
||||
if m.species() == Species::Turtle {
|
||||
return super::turtle::travel(m, w, input, events);
|
||||
}
|
||||
if m.species() == Species::Frog {
|
||||
return super::frog::travel(m, w, input, events);
|
||||
}
|
||||
match family(m.species()) {
|
||||
Family::Aquatic => aquatic::travel(m, w, input, events),
|
||||
Family::Guardian => super::guardian::travel(m, w, input, events),
|
||||
@@ -4826,3 +4829,59 @@ mod ravager_noai_tests {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod air_tests {
|
||||
use super::*;
|
||||
|
||||
/// A mob of `kind` in a pool, its eyes under, after `ticks` base ticks.
|
||||
fn air_after(kind: &str, air: i32, ticks: u32) -> Option<i32> {
|
||||
let build = |world: &mut crate::world::World,
|
||||
registry: &horizon_world::registry::BlockRegistry| {
|
||||
world.chunks.insert(
|
||||
(0, 0),
|
||||
std::sync::Arc::new(crate::chunk::Chunk::empty(0, 0)),
|
||||
);
|
||||
let water = registry.default_state("minecraft:water").unwrap();
|
||||
for x in 0..=4 {
|
||||
for z in 0..=4 {
|
||||
for y in 0..=8 {
|
||||
world.set(x, y, z, water).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut out = None;
|
||||
crate::mobs::world::with_built_test_world(&[], build, |w, types| {
|
||||
let kind = std::sync::Arc::new(types.types[kind].clone());
|
||||
let mut m = Mob::new(1, kind, Vec3::new(2.5, 3.0, 2.5), 0.0);
|
||||
m.air = air;
|
||||
let mut events = Vec::new();
|
||||
for _ in 0..ticks {
|
||||
base_tick(&mut m, w, &mut events);
|
||||
}
|
||||
out = Some(m.air);
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// `Dolphin.handleAirSupply` is empty and the dolphin isn't in
|
||||
/// `#can_breathe_under_water`: under water it loses a breath a tick from
|
||||
/// its 4800, as `LivingEntity.baseTick` takes it, so it must surface.
|
||||
#[test]
|
||||
fn a_dolphin_holds_its_breath_under_water() {
|
||||
let Some(air) = air_after("minecraft:dolphin", 4800, 10) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(air, 4790);
|
||||
}
|
||||
|
||||
/// A copper golem is in `#can_breathe_under_water`: its air stays full.
|
||||
#[test]
|
||||
fn a_copper_golem_breathes_under_water() {
|
||||
let Some(air) = air_after("minecraft:copper_golem", 300, 10) else {
|
||||
return;
|
||||
};
|
||||
assert_eq!(air, 300);
|
||||
}
|
||||
}
|
||||
@@ -645,7 +645,7 @@ fn targetable(m: &mut Mob, w: &MobWorld, who: Who) -> bool {
|
||||
// TargetingConditions.forNonCombat(): in range (scaled by the
|
||||
// player's visibility, at least 2) and in sight.
|
||||
let Some(p) = &w.player else { return false };
|
||||
let r = (range * p.visibility_percent()).max(2.0);
|
||||
let r = (range * p.visibility_percent(Some(m.kind.id.as_str()), w.items)).max(2.0);
|
||||
p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who)
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
|
||||
@@ -532,6 +532,9 @@ mod golem_touch_tests {
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -162,6 +162,18 @@ pub fn tick(m: &mut Mob) {
|
||||
let still = m.fluids.in_water && m.limb_speed <= 1.0e-5;
|
||||
m.anim.states[SWIM_IDLE].animate_when(still, age);
|
||||
}
|
||||
/// `Frog.travelInWater`: `moveRelative(getSpeed(), input)`, the move, then
|
||||
/// the motion scaled by 0.9. `getSpeed` is the speed the move control stored.
|
||||
/// Out of water the usual travel runs.
|
||||
pub fn travel(m: &mut Mob, w: &MobWorld, input: Vec3, events: &mut Vec<MobEvent>) -> bool {
|
||||
if !m.fluids.in_water {
|
||||
return false;
|
||||
}
|
||||
m.move_relative(m.ai.speed, input);
|
||||
m.do_move(w, events);
|
||||
m.velocity = m.velocity.scale(0.9);
|
||||
true
|
||||
}
|
||||
|
||||
/// `Frog.canEat`: a slime or magma cube of size 1.
|
||||
/// `Frog.updateWalkAnimation`: the frog's short steps walk the legs 25 times
|
||||
@@ -980,6 +992,40 @@ mod tests {
|
||||
Mob::new(1, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `Frog.travelInWater`: the move uses `getSpeed` (the speed the move
|
||||
/// control stored, here 0.25, not the movement attribute 1.0 and not the
|
||||
/// turtle's 0.1), then the whole motion is scaled by 0.9. Out of water
|
||||
/// this path does not run.
|
||||
#[test]
|
||||
fn water_travel_uses_get_speed_and_scales_by_nine_tenths() {
|
||||
let ran = crate::mobs::world::with_test_world(&[], |w, _| {
|
||||
let mut m = frog();
|
||||
m.attrs.speed = 1.0;
|
||||
m.ai.speed = 0.25;
|
||||
m.yaw = 0.0;
|
||||
m.velocity = Vec3::new(1.0, -0.5, 0.125);
|
||||
let kept = m.velocity;
|
||||
assert!(!travel(
|
||||
&mut m,
|
||||
w,
|
||||
Vec3::new(0.0, 0.0, 1.0),
|
||||
&mut Vec::new()
|
||||
));
|
||||
assert_eq!(m.velocity, kept);
|
||||
|
||||
m.fluids.in_water = true;
|
||||
m.position = Vec3::new(0.5, 80.0, 0.5);
|
||||
m.travel(w, Vec3::new(0.0, 0.0, 1.0), &mut Vec::new());
|
||||
// (1, -0.5, 0.125) + (0, 0, getSpeed 0.25) = (1, -0.5, 0.375),
|
||||
// then * 0.9 = (0.9, -0.45, 0.3375).
|
||||
assert!((m.velocity.x - 0.9).abs() < 1.0e-9, "{}", m.velocity.x);
|
||||
assert!((m.velocity.y - -0.45).abs() < 1.0e-9, "{}", m.velocity.y);
|
||||
assert!((m.velocity.z - 0.3375).abs() < 1.0e-9, "{}", m.velocity.z);
|
||||
});
|
||||
// Without a test world the closure does not run; there is nothing else to check.
|
||||
let _ = ran;
|
||||
}
|
||||
|
||||
/// `Frog.updateWalkAnimation`: target `min(distance * 25, 1)`, 0 while
|
||||
/// the jump animation is started, eased by 0.4 (`WalkAnimationState.update`).
|
||||
#[test]
|
||||
|
||||
@@ -757,22 +757,12 @@ impl Mob {
|
||||
if !p.alive || p.spectator {
|
||||
return false;
|
||||
}
|
||||
// LivingEntity.getVisibilityPercent
|
||||
let mut visibility = if scale { p.visibility_percent() } else { 1.0 };
|
||||
if let Some(head) = p.head.as_ref().filter(|_| scale) {
|
||||
let skull = match self.species() {
|
||||
Species::Zombie | Species::Husk | Species::Drowned | Species::ZombieVillager => {
|
||||
"minecraft:zombie_head"
|
||||
}
|
||||
Species::Skeleton | Species::Stray | Species::Bogged => "minecraft:skeleton_head",
|
||||
Species::Creeper => "minecraft:creeper_head",
|
||||
Species::Piglin | Species::PiglinBrute => "minecraft:piglin_head",
|
||||
_ => "",
|
||||
};
|
||||
if &**head == skull {
|
||||
visibility *= 0.5;
|
||||
}
|
||||
}
|
||||
// LivingEntity.getVisibilityPercent, including worn mob_visibility.
|
||||
let visibility = if scale {
|
||||
p.visibility_percent(Some(self.kind.id.as_str()), w.items)
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let dist = (range * visibility).max(2.0);
|
||||
if self.distance_sqr(p.position) > dist * dist {
|
||||
return false;
|
||||
|
||||
@@ -380,7 +380,8 @@ impl Goal for GhastTempt {
|
||||
let Some(p) = &cx.w.player else { return false };
|
||||
// getNearestPlayer(TargetingConditions.forNonCombat()
|
||||
// .ignoreLineOfSight().range(TEMPT_RANGE 16)): creative players too.
|
||||
let range = (16.0_f64 * p.visibility_percent()).max(2.0);
|
||||
let range =
|
||||
(16.0_f64 * p.visibility_percent(Some(m.kind.id.as_str()), cx.w.items)).max(2.0);
|
||||
p.alive
|
||||
&& !p.spectator
|
||||
&& (Self::wants(m, &p.main_hand) || Self::wants(m, &p.off_hand))
|
||||
|
||||
@@ -140,7 +140,7 @@ fn visible(m: &mut Mob, w: &MobWorld, who: Who) -> bool {
|
||||
match who {
|
||||
Who::Player => {
|
||||
let Some(p) = &w.player else { return false };
|
||||
let r = (range * p.visibility_percent()).max(2.0);
|
||||
let r = (range * p.visibility_percent(Some(m.kind.id.as_str()), w.items)).max(2.0);
|
||||
p.alive && !p.spectator && m.distance_sqr(p.position) <= r * r && m.sees(w, who)
|
||||
}
|
||||
Who::Mob(id) => {
|
||||
|
||||
@@ -1485,7 +1485,10 @@ impl Def {
|
||||
| S::ZombieNautilus
|
||||
),
|
||||
undead,
|
||||
breathes_water: aquatic
|
||||
// `#can_breathe_under_water`, and the iron golem, whose
|
||||
// `decreaseAirSupply` keeps its air. Not the dolphin: it holds its
|
||||
// breath (4800 ticks) and surfaces for air.
|
||||
breathes_water: (aquatic && s != S::Dolphin)
|
||||
|| undead
|
||||
|| matches!(
|
||||
s,
|
||||
@@ -1495,13 +1498,13 @@ impl Def {
|
||||
| S::Guardian
|
||||
| S::ElderGuardian
|
||||
| S::IronGolem
|
||||
| S::SnowGolem
|
||||
| S::Ghast
|
||||
| S::Blaze
|
||||
| S::CopperGolem
|
||||
| S::Nautilus
|
||||
),
|
||||
// Nautilus.handleAirSupply: it dries out on land.
|
||||
needs_water: aquatic || s == S::Nautilus,
|
||||
// WaterAnimal.handleAirSupply: it dries out on land; Nautilus's
|
||||
// does the same. Dolphin.handleAirSupply is empty, so a dolphin
|
||||
// takes LivingEntity.baseTick's air like a land mob.
|
||||
needs_water: (aquatic && s != S::Dolphin) || s == S::Nautilus,
|
||||
water_sensitive: matches!(s, S::Enderman | S::Blaze | S::SnowGolem | S::Strider),
|
||||
animal: !matches!(s, S::Bat | S::SulfurCube | S::Mannequin)
|
||||
&& !self.hostile_class()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! The wither (`WitherBoss`): eleven seconds of invulnerable charging that
|
||||
//! end in a blast, then flying after its target while three heads fire
|
||||
//! skulls, breaking blocks around it when hurt, immune to arrows below half
|
||||
//! health.
|
||||
//! skulls, breaking blocks around it when hurt, immune to arrows and a
|
||||
//! player's wind charges below half health.
|
||||
use super::{
|
||||
DamageKind, Mob, MobEvent, MobWorld, Species, Who,
|
||||
anim::{Poses, State},
|
||||
@@ -449,6 +449,14 @@ pub fn accepts(m: &mut Mob, kind: DamageKind, attacker: Option<Who>, others_unde
|
||||
m.extra.ints[5] += 3;
|
||||
true
|
||||
}
|
||||
/// `WitherBoss.hurtServer`: a powered wither's armor refuses a direct
|
||||
/// `WindCharge`. A breeze's `BreezeWindCharge` is not one (both only extend
|
||||
/// `AbstractWindCharge`), so it still hurts. Checked on the projectile, not on
|
||||
/// `DamageKind`: the `wind_charge` damage type alone has no direct charge
|
||||
/// (`/damage @s minecraft:wind_charge`) and goes through.
|
||||
pub fn armor_refuses(m: &Mob, direct: &ProjectileKind) -> bool {
|
||||
m.species() == Species::Wither && *direct == ProjectileKind::WindCharge && powered(m)
|
||||
}
|
||||
/// `WitherBossModel.setupAnim`.
|
||||
pub fn anim(m: &Mob, s: &State, p: &mut Poses) {
|
||||
let body =
|
||||
@@ -518,6 +526,40 @@ mod tests {
|
||||
assert!(!shielded_from(Species::Wither, Species::Creeper));
|
||||
assert!(!shielded_from(Species::Zombie, Species::Wither));
|
||||
}
|
||||
/// `WitherBoss.hurtServer` tests `instanceof WindCharge`: a powered wither
|
||||
/// took a point of damage from every charge (#8779), and a breeze's charge,
|
||||
/// a sibling class, has to keep hurting it.
|
||||
#[test]
|
||||
fn only_a_powered_withers_armor_refuses_a_wind_charge() {
|
||||
use crate::mobs::Damage;
|
||||
use crate::mobs::sensor_scan_tests::kind;
|
||||
use std::sync::Arc;
|
||||
let mob = |species, health: f32| {
|
||||
let mut kind = kind();
|
||||
Arc::make_mut(&mut kind).species = species;
|
||||
let mut m = Mob::new(1, kind, Vec3::ZERO, 0.0);
|
||||
m.attrs.max_health = 300.0;
|
||||
m.health = health;
|
||||
m
|
||||
};
|
||||
let (charge, breeze) = (ProjectileKind::WindCharge, ProjectileKind::BreezeWindCharge);
|
||||
// `isPowered` is `health <= max / 2`: the armor starts at exactly half.
|
||||
let half = mob(Species::Wither, 150.0);
|
||||
assert!(armor_refuses(&half, &charge));
|
||||
assert!(armor_refuses(&mob(Species::Wither, 1.0), &charge));
|
||||
assert!(!armor_refuses(&half, &breeze));
|
||||
assert!(!armor_refuses(&mob(Species::Wither, 150.5), &charge));
|
||||
assert!(!armor_refuses(&mob(Species::Zombie, 150.0), &charge));
|
||||
|
||||
// What `wind_charge_damage` lets through still lands on the armor...
|
||||
let mut m = mob(Species::Wither, 150.0);
|
||||
assert!(m.damage(1.0, Damage::of(DamageKind::Thrown)));
|
||||
assert!(m.health < 150.0);
|
||||
// ...and arrows stay refused by the damage filter.
|
||||
let mut m = mob(Species::Wither, 150.0);
|
||||
assert!(!m.damage(1.0, Damage::of(DamageKind::Arrow)));
|
||||
assert_eq!(m.health, 150.0);
|
||||
}
|
||||
/// A spawning wither rolled the three particles of `aiStep`'s
|
||||
/// `getInvulnerableTicks() > 0` block and dropped them on the floor: the
|
||||
/// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486).
|
||||
|
||||
@@ -542,6 +542,9 @@ pub(crate) fn test_player(position: Vec3) -> super::PlayerView {
|
||||
main_hand: None,
|
||||
off_hand: None,
|
||||
head: None,
|
||||
chest: None,
|
||||
legs: None,
|
||||
feet: None,
|
||||
armor_cover: 0.0,
|
||||
invisible: false,
|
||||
spectator: false,
|
||||
|
||||
@@ -26,14 +26,14 @@ pub use crate::game::{
|
||||
NativeCriterion, NativeDataMapType, NativeExperienceOrb, NativeFalling, NativeHeightmap,
|
||||
NativeHeldItem, NativeHit, NativeItemEntity, NativeMapDecoration, NativeMapInfo,
|
||||
NativeMobRender, NativeModEvent, NativePlayer, NativePlayerRender, NativeReloadListener,
|
||||
NativeShake,
|
||||
NativeShake, NativeWorldDraw,
|
||||
};
|
||||
pub use crate::gfx::native_render::{
|
||||
BlockSpriteArrays, RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent,
|
||||
wgpu,
|
||||
};
|
||||
pub use crate::hud::HudBatch;
|
||||
pub use crate::hud_layout::{GuiDraw, HealthInputs};
|
||||
pub use crate::hud_layout::{GuiDraw, HealthHud, HealthInputs};
|
||||
pub use crate::items::ItemStack;
|
||||
pub use crate::merchant::MerchantOffer;
|
||||
pub use crate::mesher::TintSource;
|
||||
|
||||
@@ -342,11 +342,37 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum
|
||||
}
|
||||
}
|
||||
if dimension != "minecraft:the_nether" {
|
||||
shade_sky(&mut column, states);
|
||||
shade_sky(&mut column, states, Dampening::PerLevel);
|
||||
}
|
||||
column
|
||||
}))
|
||||
}
|
||||
/// How much of its own dampening a run records, for [`shade_sky`]. Either
|
||||
/// way the light it passes on to the spans under it is the same, so seabeds
|
||||
/// and the ground under canopies are lit identically.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Dampening {
|
||||
/// A span a level, as a real chunk's light has them. A column that
|
||||
/// `Column::reduced` merges with its neighbours needs this: a reduction
|
||||
/// takes the brightest light of each slice it merges, so one bright span
|
||||
/// for a whole sea lit its bed as brightly as its surface.
|
||||
PerLevel,
|
||||
/// One span for the run, lit as its top is. A previewed column already
|
||||
/// stands for a whole far cell and is never reduced against neighbouring
|
||||
/// columns, so nothing merges a run with the bed under it, and the levels
|
||||
/// between its top and its bottom reach no face that is drawn: the spans
|
||||
/// inside a run have neither a top nor a bottom face (`hides` matches on
|
||||
/// state, not light), and the side of a run is exposed at its top, where
|
||||
/// the light is the run's brightest -- below that the neighbour hides it,
|
||||
/// and above it `add_side` raises the light to the neighbour's own sky.
|
||||
///
|
||||
/// Over real seed-1234 terrain, nine tiles each at levels 0, 2, 4 and 6,
|
||||
/// `PerLevel` leaves 18.7, 12.3, 12.0 and 12.6 spans a column where the
|
||||
/// runs themselves are 4.1 to 4.5: 64% to 78% of every stored far span
|
||||
/// was a gradient step, and a sea 15 blocks deep or more spent 16 spans
|
||||
/// on one.
|
||||
PerRun,
|
||||
}
|
||||
/// Skylight as it reaches the top of each span straight down from the sky:
|
||||
/// fluids and leaves take a level per block, as vanilla's dampening does.
|
||||
/// Full skylight everywhere lit seabeds and the ground under canopies as if
|
||||
@@ -355,10 +381,21 @@ fn columns(chunk: &ProtoChunk, biome_map: &[u16], dimension: &str) -> Arc<[Colum
|
||||
// stop it: the faces under them that far terrain shows are cliffs and
|
||||
// overhangs, which vanilla lights sideways from the open air, and they went
|
||||
// black. The lighting engine for exact light.
|
||||
fn shade_sky(column: &mut Column, states: &GenBlockStates) {
|
||||
// Dimming blocks get a span a level, as a real chunk's light has them,
|
||||
// down to the dark: one lit span for a whole sea lit its bed, where
|
||||
// `Column::reduced` merges water and sand, as brightly as its surface.
|
||||
fn shade_sky(column: &mut Column, states: &GenBlockStates, dampening: Dampening) {
|
||||
shade_runs(
|
||||
column,
|
||||
|id| {
|
||||
let state = states.get(id);
|
||||
// A solid block stops no light here, as the note above says; a
|
||||
// fluid or leaves take a level a block.
|
||||
!(state.motion && state.no_leaves)
|
||||
},
|
||||
dampening,
|
||||
);
|
||||
}
|
||||
/// [`shade_sky`] over a predicate rather than the generator's block states,
|
||||
/// which have no constructor outside `horizon-world`.
|
||||
fn shade_runs(column: &mut Column, dampens: impl Fn(BlockStateId) -> bool, dampening: Dampening) {
|
||||
let mut sky = 15u32;
|
||||
let mut shaded = Vec::with_capacity(column.spans.len());
|
||||
for span in column.spans.iter().rev() {
|
||||
@@ -368,11 +405,18 @@ fn shade_sky(column: &mut Column, states: &GenBlockStates) {
|
||||
light: (span.light & !0xf0) | (sky << 4) as u16,
|
||||
..*span
|
||||
};
|
||||
let state = states.get(span.state);
|
||||
if state.motion && state.no_leaves {
|
||||
if !dampens(span.state) {
|
||||
shaded.push(lit(span.min, span.max, sky));
|
||||
continue;
|
||||
}
|
||||
if dampening == Dampening::PerRun {
|
||||
shaded.push(lit(span.min, span.max, sky));
|
||||
// What the per-level walk below leaves, so the spans under the
|
||||
// run are lit the same either way.
|
||||
let height = i32::from(span.max) - i32::from(span.min);
|
||||
sky = sky.saturating_sub(height.max(0) as u32);
|
||||
continue;
|
||||
}
|
||||
let mut top = span.max;
|
||||
while top > span.min {
|
||||
let bottom = if sky == 0 { span.min } else { top - 1 };
|
||||
@@ -642,7 +686,11 @@ impl VanillaLodSampler {
|
||||
self.vegetation.apply(&mut column, x, z, width, light);
|
||||
self.islands.apply(&mut column, x, z, width, light);
|
||||
if !nether {
|
||||
shade_sky(&mut column, self.generator.generator.data().block_states());
|
||||
shade_sky(
|
||||
&mut column,
|
||||
self.generator.generator.data().block_states(),
|
||||
Dampening::PerRun,
|
||||
);
|
||||
}
|
||||
column
|
||||
}
|
||||
@@ -650,6 +698,89 @@ impl VanillaLodSampler {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// Either dampening lights everything under a run the same, so seabeds
|
||||
/// and the ground under canopies keep the light they had; `PerRun` spends
|
||||
/// one span on the run itself where `PerLevel` spends one a level.
|
||||
#[test]
|
||||
fn a_run_passes_on_the_same_light_however_much_of_it_it_records() {
|
||||
use super::*;
|
||||
let span = |min: i16, max: i16, state: u32| Span {
|
||||
min,
|
||||
max,
|
||||
state: BlockStateId(state),
|
||||
biome: 0,
|
||||
light: 240,
|
||||
};
|
||||
// State 1 dampens (water), state 2 does not (sand).
|
||||
let dampens = |id: BlockStateId| id == BlockStateId(1);
|
||||
let sky = |span: &Span| (span.light & 0xf0) >> 4;
|
||||
for depth in [1i16, 2, 5, 14, 15, 16, 40] {
|
||||
let bed = span(0, 4, 2);
|
||||
let water = span(4, 4 + depth, 1);
|
||||
let mut level = Column {
|
||||
surface: None,
|
||||
spans: vec![bed, water].into(),
|
||||
};
|
||||
let mut run = level.clone();
|
||||
shade_runs(&mut level, dampens, Dampening::PerLevel);
|
||||
shade_runs(&mut run, dampens, Dampening::PerRun);
|
||||
|
||||
// The bed is one span either way, lit identically: this is what
|
||||
// the per-level walk was added for.
|
||||
assert_eq!(level.spans[0], run.spans[0], "depth {depth}: the bed");
|
||||
assert_eq!(
|
||||
sky(&level.spans[0]),
|
||||
15u16.saturating_sub(depth.max(0) as u16),
|
||||
"depth {depth}: the bed takes the light the run let through"
|
||||
);
|
||||
// The run's top face is the topmost span's, and takes the light
|
||||
// arriving at the top of the run: full sky, nothing above it.
|
||||
assert_eq!(sky(level.spans.last().unwrap()), 15, "depth {depth}");
|
||||
assert_eq!(sky(run.spans.last().unwrap()), 15, "depth {depth}");
|
||||
assert_eq!(level.spans.last().unwrap().max, 4 + depth);
|
||||
assert_eq!(run.spans.last().unwrap().max, 4 + depth);
|
||||
// One span for the run, against one a level down to the dark.
|
||||
assert_eq!(run.spans.len(), 2, "depth {depth}");
|
||||
// A span a block while the sky lasts (15 levels), then one for
|
||||
// whatever of the run is left below the dark.
|
||||
let walked = if depth <= 15 { depth as usize } else { 16 };
|
||||
assert_eq!(
|
||||
level.spans.len(),
|
||||
1 + walked,
|
||||
"depth {depth}: a span a level, then the rest in one"
|
||||
);
|
||||
// Neither invents or loses ground: the spans still cover the
|
||||
// column exactly, bottom to top, without a gap or an overlap.
|
||||
for shaded in [&level, &run] {
|
||||
assert_eq!(shaded.spans.first().unwrap().min, 0);
|
||||
assert_eq!(shaded.spans.last().unwrap().max, 4 + depth);
|
||||
for pair in shaded.spans.windows(2) {
|
||||
assert_eq!(pair[0].max, pair[1].min, "depth {depth}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A solid run takes no light from what passes down it, under either
|
||||
/// dampening: only fluids and leaves dim the column.
|
||||
#[test]
|
||||
fn solid_runs_are_one_span_and_dim_nothing() {
|
||||
use super::*;
|
||||
let stone = Span {
|
||||
min: 0,
|
||||
max: 60,
|
||||
state: BlockStateId(2),
|
||||
biome: 0,
|
||||
light: 240,
|
||||
};
|
||||
for dampening in [Dampening::PerLevel, Dampening::PerRun] {
|
||||
let mut column = Column {
|
||||
surface: None,
|
||||
spans: vec![stone].into(),
|
||||
};
|
||||
shade_runs(&mut column, |_| false, dampening);
|
||||
assert_eq!(column.spans, [stone], "{dampening:?}");
|
||||
}
|
||||
}
|
||||
/// The all-cached path must return exactly the per-cell reductions the
|
||||
/// old override produced, including negative chunk coordinates.
|
||||
#[test]
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
//! everywhere.
|
||||
//! - On x86-64, `sin`, `cos`, `tan`, `exp`, `log`, `log10` and `pow` are
|
||||
//! Intel's libm stubs, which round like glibc for nearly every argument;
|
||||
//! there they use the platform libm, as before.
|
||||
//! there they use the platform libm, as before. Windows' libm (Microsoft's)
|
||||
//! rounds `sin` and `cos` differently for about 3% of arguments, so there
|
||||
//! they are correctly rounded instead ([`exact`]), as Intel's stubs are for
|
||||
//! all but about 0.1%.
|
||||
//! - On AArch64 only `sin` and `cos` are intrinsics: fdlibm's kernels
|
||||
//! evaluated with fused multiply-adds (`macroAssembler_aarch64_trig.cpp`).
|
||||
//! Everything else is `StrictMath`.
|
||||
@@ -38,6 +41,8 @@ pub fn round_f32(a: f32) -> i32 {
|
||||
pub fn sin(x: f64) -> f64 {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
aarch64::sin(x)
|
||||
} else if cfg!(windows) {
|
||||
exact::sin(x)
|
||||
} else {
|
||||
x.sin()
|
||||
}
|
||||
@@ -47,6 +52,8 @@ pub fn sin(x: f64) -> f64 {
|
||||
pub fn cos(x: f64) -> f64 {
|
||||
if cfg!(target_arch = "aarch64") {
|
||||
aarch64::cos(x)
|
||||
} else if cfg!(windows) {
|
||||
exact::cos(x)
|
||||
} else {
|
||||
x.cos()
|
||||
}
|
||||
@@ -548,9 +555,246 @@ pub mod aarch64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// `sin` and `cos` correctly rounded, for Windows, whose libm rounds them
|
||||
/// unlike Java's x86-64 intrinsics. Double-double arithmetic (two `f64`s per
|
||||
/// value): `x` reduced by a four-part pi/2, a short series checked against an
|
||||
/// error bound, and where the bound leaves the rounding open, the full series
|
||||
/// with its terms summed exactly. Checked against 400-bit results on 1.3 million
|
||||
/// arguments, small ones and ones next to multiples of pi/2 among them; not a
|
||||
/// proof.
|
||||
pub mod exact {
|
||||
/// A value `hi + lo` with |lo| <= ulp(hi) / 2.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Dd(f64, f64);
|
||||
|
||||
/// `a + b` exactly, as a sum and its rounding error (Knuth).
|
||||
fn two_sum(a: f64, b: f64) -> Dd {
|
||||
let s = a + b;
|
||||
let v = s - a;
|
||||
Dd(s, (a - (s - v)) + (b - v))
|
||||
}
|
||||
|
||||
/// `a + b` exactly when |a| >= |b| (Dekker).
|
||||
fn fast_two_sum(a: f64, b: f64) -> Dd {
|
||||
let s = a + b;
|
||||
Dd(s, b - (s - a))
|
||||
}
|
||||
|
||||
/// `a * b` exactly, as a product and its rounding error (Dekker's split, no fused multiply-add).
|
||||
fn two_prod(a: f64, b: f64) -> Dd {
|
||||
fn split(a: f64) -> (f64, f64) {
|
||||
let t = 134_217_729.0 * a; // 2^27 + 1
|
||||
let hi = t - (t - a);
|
||||
(hi, a - hi)
|
||||
}
|
||||
let p = a * b;
|
||||
let (ah, al) = split(a);
|
||||
let (bh, bl) = split(b);
|
||||
Dd(p, ((ah * bh - p) + ah * bl + al * bh) + al * bl)
|
||||
}
|
||||
|
||||
impl Dd {
|
||||
fn add(self, b: Dd) -> Dd {
|
||||
let s = two_sum(self.0, b.0);
|
||||
let t = two_sum(self.1, b.1);
|
||||
let s = fast_two_sum(s.0, s.1 + t.0);
|
||||
fast_two_sum(s.0, s.1 + t.1)
|
||||
}
|
||||
fn mul(self, b: Dd) -> Dd {
|
||||
let p = two_prod(self.0, b.0);
|
||||
fast_two_sum(p.0, p.1 + (self.0 * b.1 + self.1 * b.0))
|
||||
}
|
||||
/// Divided by a small whole number (exact in an `f64`).
|
||||
fn div(self, d: f64) -> Dd {
|
||||
let q = self.0 / d;
|
||||
let p = two_prod(q, d);
|
||||
let r = ((self.0 - p.0) - p.1 + self.1) / d;
|
||||
fast_two_sum(q, r)
|
||||
}
|
||||
fn neg(self) -> Dd {
|
||||
Dd(-self.0, -self.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// pi/2 = P1 + P2 + P3 + P4 to 2^-160: the first three have 33 significant bits, so `k * Pn` is
|
||||
/// exact for |k| < 2^20.
|
||||
const P1: f64 = f64::from_bits(0x3FF9_21FB_5440_0000);
|
||||
const P2: f64 = f64::from_bits(0x3DD0_B461_1A60_0000);
|
||||
const P3: f64 = f64::from_bits(0x3BA3_198A_2E00_0000);
|
||||
const P4: f64 = f64::from_bits(0x397B_839A_2520_49C1);
|
||||
|
||||
/// `x - k * pi/2` and the quadrant `k mod 4`, for |x| <= 2^20.
|
||||
fn reduce(x: f64) -> (Dd, u32) {
|
||||
let k = (x * std::f64::consts::FRAC_2_PI).round();
|
||||
let r = Dd(x - k * P1, 0.0)
|
||||
.add(Dd(-(k * P2), 0.0))
|
||||
.add(Dd(-(k * P3), 0.0))
|
||||
.add(two_prod(k, P4).neg());
|
||||
(r, (k as i64 & 3) as u32)
|
||||
}
|
||||
|
||||
/// `sum(xs)` rounded once, exactly (Shewchuk's partials, with the half-way fix-up of a final
|
||||
/// rounding that a running sum can't see).
|
||||
fn exact_sum(xs: &[f64]) -> f64 {
|
||||
let mut partials = [0.0f64; 64];
|
||||
let mut len = 0;
|
||||
for &x0 in xs {
|
||||
let mut x = x0;
|
||||
let mut i = 0;
|
||||
for j in 0..len {
|
||||
let mut y = partials[j];
|
||||
if x.abs() < y.abs() {
|
||||
std::mem::swap(&mut x, &mut y);
|
||||
}
|
||||
let hi = x + y;
|
||||
let lo = y - (hi - x);
|
||||
if lo != 0.0 {
|
||||
partials[i] = lo;
|
||||
i += 1;
|
||||
}
|
||||
x = hi;
|
||||
}
|
||||
partials[i] = x;
|
||||
len = i + 1;
|
||||
}
|
||||
if len == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut n = len - 1;
|
||||
let mut hi = partials[n];
|
||||
let mut lo = 0.0;
|
||||
while n > 0 {
|
||||
let x = hi;
|
||||
n -= 1;
|
||||
let y = partials[n];
|
||||
hi = x + y;
|
||||
lo = y - (hi - x);
|
||||
if lo != 0.0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n > 0 && ((lo < 0.0 && partials[n - 1] < 0.0) || (lo > 0.0 && partials[n - 1] > 0.0)) {
|
||||
let y = lo * 2.0;
|
||||
let x = hi + y;
|
||||
if y == x - hi {
|
||||
hi = x;
|
||||
}
|
||||
}
|
||||
hi
|
||||
}
|
||||
|
||||
/// sin(r) or cos(r) for |r| <= pi/4 + a little: the Taylor series to below 2^-110, each term
|
||||
/// kept as a double-double and the terms summed exactly. The leading terms are exact for a small
|
||||
/// `x` (r = x), where cos x and sin x come closest to half-way between two doubles.
|
||||
fn series(r: Dd, cos: bool) -> f64 {
|
||||
let r2 = r.mul(r);
|
||||
let mut parts = [0.0f64; 32];
|
||||
let mut len = 0;
|
||||
let (mut term, first) = if cos { (Dd(1.0, 0.0), 1.0) } else { (r, 2.0) };
|
||||
parts[0] = term.0;
|
||||
parts[1] = term.1;
|
||||
len += 2;
|
||||
for n in 1..=14 {
|
||||
let k = 2.0 * f64::from(n) + first - 1.0;
|
||||
term = term.mul(r2).div(k * (k - 1.0)).neg();
|
||||
parts[len] = term.0;
|
||||
parts[len + 1] = term.1;
|
||||
len += 2;
|
||||
}
|
||||
exact_sum(&parts[..len])
|
||||
}
|
||||
|
||||
/// Taylor coefficients 1/n! with alternating sign, for the f64 tails: sin from r^7, cos from r^8.
|
||||
const SIN_TAIL: [f64; 9] = [
|
||||
-1.0 / 5040.0,
|
||||
1.0 / 362880.0,
|
||||
-1.0 / 39916800.0,
|
||||
1.0 / 6227020800.0,
|
||||
-1.0 / 1307674368000.0,
|
||||
1.0 / 355687428096000.0,
|
||||
-1.0 / 121645100408832000.0,
|
||||
1.0 / 51090942171709440000.0,
|
||||
-1.0 / 25852016738884976640000.0,
|
||||
];
|
||||
const COS_TAIL: [f64; 8] = [
|
||||
1.0 / 40320.0,
|
||||
-1.0 / 3628800.0,
|
||||
1.0 / 479001600.0,
|
||||
-1.0 / 87178291200.0,
|
||||
1.0 / 20922789888000.0,
|
||||
-1.0 / 6402373705728000.0,
|
||||
1.0 / 2432902008176640000.0,
|
||||
-1.0 / 1124000727777607680000.0,
|
||||
];
|
||||
|
||||
fn horner(c: &[f64], x: f64) -> f64 {
|
||||
c.iter().rev().fold(0.0, |acc, &k| acc * x + k)
|
||||
}
|
||||
|
||||
/// sin(r) or cos(r) to about 2^-64: the first terms in double-double, the small rest in f64.
|
||||
fn fast(r: Dd, cos: bool) -> Dd {
|
||||
let r2 = r.mul(r);
|
||||
let x2 = r2.0;
|
||||
if cos {
|
||||
let t2 = r2.div(2.0).neg();
|
||||
let t4 = r2.mul(r2).div(24.0);
|
||||
let t6 = t4.mul(r2).div(30.0).neg();
|
||||
let tail = x2 * x2 * x2 * x2 * horner(&COS_TAIL, x2);
|
||||
Dd(1.0, 0.0).add(t2).add(t4).add(t6).add(Dd(tail, 0.0))
|
||||
} else {
|
||||
let t3 = r2.mul(r).div(6.0).neg();
|
||||
let t5 = t3.mul(r2).div(20.0).neg();
|
||||
let tail = r.0 * x2 * x2 * x2 * horner(&SIN_TAIL, x2);
|
||||
r.add(t3).add(t5).add(Dd(tail, 0.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounds `v` when its error bound can't change the rounding, else `None`.
|
||||
fn rounded(v: Dd) -> Option<f64> {
|
||||
let e = v.0.abs() * f64::from_bits(0x3C30_0000_0000_0000); // 2^-60
|
||||
let (a, b) = (v.0 + (v.1 - e), v.0 + (v.1 + e));
|
||||
(a == b).then_some(a)
|
||||
}
|
||||
|
||||
/// sin (`cos == false`) or cos of a reduced angle in quadrant `q`.
|
||||
fn eval(r: Dd, q: u32, cos: bool) -> f64 {
|
||||
// sin(x) = sin r, cos r, -sin r, -cos r in quadrants 0..3; cos(x) is a quadrant later.
|
||||
let q = (q + u32::from(cos)) & 3;
|
||||
let want_cos = q & 1 == 1;
|
||||
let v = rounded(fast(r, want_cos)).unwrap_or_else(|| series(r, want_cos));
|
||||
if q >= 2 { -v } else { v }
|
||||
}
|
||||
|
||||
/// `sin(x)` rounded to nearest; beyond |x| = 2^20 the platform's.
|
||||
pub fn sin(x: f64) -> f64 {
|
||||
if x.abs() < f64::from_bits(0x3E40_0000_0000_0000) {
|
||||
// |x| < 2^-27: sin x rounds to x (0 included).
|
||||
return x;
|
||||
}
|
||||
if x.is_nan() || x.abs() > 1_048_576.0 {
|
||||
return x.sin();
|
||||
}
|
||||
let (r, q) = reduce(x);
|
||||
eval(r, q, false)
|
||||
}
|
||||
|
||||
/// `cos(x)` rounded to nearest; beyond |x| = 2^20 the platform's.
|
||||
pub fn cos(x: f64) -> f64 {
|
||||
if x.abs() < f64::from_bits(0x3E40_0000_0000_0000) {
|
||||
// |x| < 2^-27: cos x rounds to 1.
|
||||
return 1.0;
|
||||
}
|
||||
if x.is_nan() || x.abs() > 1_048_576.0 {
|
||||
return x.cos();
|
||||
}
|
||||
let (r, q) = reduce(x);
|
||||
eval(r, q, true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{aarch64, fdlibm, round_f32};
|
||||
use super::{aarch64, exact, fdlibm, round_f32};
|
||||
|
||||
#[test]
|
||||
fn round_f32_is_exact_like_java() {
|
||||
@@ -587,6 +831,47 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Java 24's `Math.sin` and `Math.cos` on x86-64 HotSpot, for arguments
|
||||
/// where Windows' libm rounds the other way.
|
||||
#[test]
|
||||
fn exact_sin_and_cos_round_like_javas_x86_intrinsics() {
|
||||
for case in [
|
||||
(0x400cfed4c52a225f, 0xbfddb6fb8dfc34d9),
|
||||
(0x401562e96dc95e79, 0xbfe9c702a472eb20),
|
||||
(0x3fe83defdda344b2, 0x3fe5fd24ae6866f7),
|
||||
(0x4015f2973d92bdce, 0xbfe6df4f6eb17f8b),
|
||||
(0x400ccab8c6bca404, 0xbfdc43578964fcd9),
|
||||
(0x4081275db9d375bb, 0x3fe835467b8bb2a8),
|
||||
(0x408545bc77918c1a, 0x3feb130e731423b6),
|
||||
(0xc08a86d7f86386d0, 0xbfe2bc064eff6f2b),
|
||||
] {
|
||||
check1(exact::sin, case);
|
||||
}
|
||||
for case in [
|
||||
(0x3fff44a3b3c18058, 0xbfd7f1ca30696aa9),
|
||||
(0x401824074317a30f, 0x3fef055d8c6cc46e),
|
||||
(0x4001b86d21e7b163, 0xbfe33824a109fcc3),
|
||||
(0xc08b37219efa0d36, 0xbfe913c9a4e569fe),
|
||||
(0xc084ce175c9dc180, 0x3feef489ec429ba7),
|
||||
(0x400ed26ab1d4ada5, 0xbfe83e5b263a94b1),
|
||||
(0x40156db4b780dcab, 0x3fe33b5a52983c7c),
|
||||
(0x408e8b5accbb6a88, 0xbfedab57ae231703),
|
||||
// About 3e-28 below a half-way point: a double-double running sum loses that.
|
||||
(0x3e937b1311fb4936, 0x3feffffffffffe85),
|
||||
] {
|
||||
check1(exact::cos, case);
|
||||
}
|
||||
// Small arguments, the quadrant edges and beyond the reduced range.
|
||||
assert_eq!(exact::sin(1e-300), 1e-300);
|
||||
assert_eq!(exact::cos(-1e-10), 1.0);
|
||||
assert_eq!(exact::sin(0.0).to_bits(), 0);
|
||||
assert_eq!(exact::sin(-0.0).to_bits(), (-0.0f64).to_bits());
|
||||
assert!(exact::sin(f64::NAN).is_nan() && exact::cos(f64::INFINITY).is_nan());
|
||||
assert_eq!(exact::sin(std::f64::consts::FRAC_PI_2), 1.0);
|
||||
assert_eq!(exact::cos(std::f64::consts::PI), -1.0);
|
||||
assert_eq!(exact::sin(3.0e6), 3.0e6f64.sin());
|
||||
}
|
||||
|
||||
fn check1(f: fn(f64) -> f64, (x, expected): (u64, u64)) {
|
||||
assert_eq!(f(f64::from_bits(x)).to_bits(), expected, "{x:x}");
|
||||
}
|
||||
|
||||
@@ -140,6 +140,46 @@ pub fn level_name(level: u32) -> String {
|
||||
.map_or_else(|| level.to_string(), |s| (*s).to_owned())
|
||||
}
|
||||
|
||||
/// One registry entry from its definition (`Enchantment.DIRECT_CODEC`).
|
||||
fn parse_entry(
|
||||
id: &str,
|
||||
v: &Value,
|
||||
tags: &HashMap<String, Vec<Arc<str>>>,
|
||||
lang: &HashMap<String, String>,
|
||||
) -> Enchantment {
|
||||
let exclusive = match &v["exclusive_set"] {
|
||||
Value::String(s) if s.starts_with('#') => tags.get(&s[1..]).cloned().unwrap_or_default(),
|
||||
other => string_list(other)
|
||||
.into_iter()
|
||||
.map(|s| Arc::from(s.as_str()))
|
||||
.collect(),
|
||||
};
|
||||
let key = v["description"]["translate"].as_str().unwrap_or("");
|
||||
Enchantment {
|
||||
id: Arc::from(id),
|
||||
description: lang.get(key).cloned().unwrap_or_else(|| {
|
||||
// A data pack's entry may name itself with a literal component
|
||||
// rather than a translation key.
|
||||
if key.is_empty() && !v["description"].is_null() {
|
||||
horizon_client_core::scoreboard::comp_text(&v["description"]).plain()
|
||||
} else {
|
||||
id.trim_start_matches("minecraft:").replace('_', " ")
|
||||
}
|
||||
}),
|
||||
description_key: key.to_owned(),
|
||||
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
|
||||
weight: v["weight"].as_u64().unwrap_or(1) as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
|
||||
min_cost: Cost::parse(&v["min_cost"]),
|
||||
max_cost: Cost::parse(&v["max_cost"]),
|
||||
supported: string_list(&v["supported_items"]),
|
||||
primary: v.get("primary_items").map(string_list),
|
||||
exclusive: exclusive.into_iter().collect(),
|
||||
slots: string_list(&v["slots"]),
|
||||
effects: v["effects"].clone(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Enchantments {
|
||||
/// Use the same frozen registry and resolved tags as player damage.
|
||||
pub fn bind_damage_catalog(&mut self, catalog: &horizon_world::damage_source::Catalog) {
|
||||
@@ -253,37 +293,10 @@ impl Enchantments {
|
||||
|| k.starts_with("recipe.toast.")
|
||||
})
|
||||
.collect();
|
||||
let mut list = Vec::new();
|
||||
for (id, v) in &files {
|
||||
let exclusive = match &v["exclusive_set"] {
|
||||
Value::String(s) if s.starts_with('#') => {
|
||||
tags.get(&s[1..]).cloned().unwrap_or_default()
|
||||
}
|
||||
other => string_list(other)
|
||||
.into_iter()
|
||||
.map(|s| Arc::from(s.as_str()))
|
||||
.collect(),
|
||||
};
|
||||
let key = v["description"]["translate"].as_str().unwrap_or("");
|
||||
list.push(Enchantment {
|
||||
id: Arc::from(id.as_str()),
|
||||
description: lang
|
||||
.get(key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| id.trim_start_matches("minecraft:").replace('_', " ")),
|
||||
description_key: key.to_owned(),
|
||||
max_level: v["max_level"].as_u64().unwrap_or(1) as u32,
|
||||
weight: v["weight"].as_u64().unwrap_or(1) as u32,
|
||||
anvil_cost: v["anvil_cost"].as_u64().unwrap_or(0) as u32,
|
||||
min_cost: Cost::parse(&v["min_cost"]),
|
||||
max_cost: Cost::parse(&v["max_cost"]),
|
||||
supported: string_list(&v["supported_items"]),
|
||||
primary: v.get("primary_items").map(string_list),
|
||||
exclusive: exclusive.into_iter().collect(),
|
||||
slots: string_list(&v["slots"]),
|
||||
effects: v["effects"].clone(),
|
||||
});
|
||||
}
|
||||
let list = files
|
||||
.iter()
|
||||
.map(|(id, v)| parse_entry(id, v, &tags, &lang))
|
||||
.collect::<Vec<_>>();
|
||||
let index = list
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -326,6 +339,26 @@ impl Enchantments {
|
||||
pub fn get(&self, id: &str) -> Option<&Enchantment> {
|
||||
self.index.get(id).map(|&i| &self.list[i])
|
||||
}
|
||||
/// The table a client plays a server with: this one and the server's
|
||||
/// entries it lacks (a data pack's), from the definitions the registry
|
||||
/// sync sent; `None` when there are none. The names then come from the
|
||||
/// entries' own `description`, as `Enchantment.getFullname` reads it.
|
||||
pub fn with_entries<'a>(
|
||||
&self,
|
||||
entries: impl IntoIterator<Item = (&'a str, &'a Value)>,
|
||||
) -> Option<Self> {
|
||||
let mut table = None::<Self>;
|
||||
for (id, v) in entries {
|
||||
if self.index.contains_key(id) {
|
||||
continue;
|
||||
}
|
||||
let t = table.get_or_insert_with(|| self.clone());
|
||||
let e = parse_entry(id, v, &t.tags, &t.lang);
|
||||
t.index.insert(e.id.clone(), t.list.len());
|
||||
t.list.push(e);
|
||||
}
|
||||
table
|
||||
}
|
||||
/// The longest `minecraft:ignite` the attacker's `minecraft:post_attack`
|
||||
/// effects (Fire Aspect) set on the victim, in seconds: the effects whose
|
||||
/// requirements hold for `ctx` (0 when none).
|
||||
@@ -1249,6 +1282,33 @@ pub mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// A server's data-pack enchantment the game lacks is named by the
|
||||
/// `description` it sent (`Enchantment.getFullname`), with the level
|
||||
/// numeral unless both the level and the maximum are 1; the game's own
|
||||
/// entries keep their tables.
|
||||
#[test]
|
||||
fn synced_entries_name_themselves() {
|
||||
let e = sample();
|
||||
assert_eq!(e.full_name("enchex:drill", 2).0, "enchex:drill II");
|
||||
let drill = json!({"description":{"text":"Drill"},"max_level":3,"supported_items":"#minecraft:enchantable/mining"});
|
||||
let hex = json!({"description":"Hex","max_level":1});
|
||||
let other = json!({"description":{"text":"Other"},"max_level":5});
|
||||
let synced = e
|
||||
.with_entries([
|
||||
("enchex:drill", &drill),
|
||||
("enchex:hex", &hex),
|
||||
("minecraft:sharpness", &other),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(synced.full_name("enchex:drill", 2).0, "Drill II");
|
||||
assert_eq!(synced.full_name("enchex:drill", 1).0, "Drill I");
|
||||
assert_eq!(synced.full_name("enchex:hex", 1).0, "Hex");
|
||||
assert_eq!(synced.full_name("minecraft:sharpness", 1).0, "sharpness I");
|
||||
assert_eq!(synced.list.len(), e.list.len() + 2);
|
||||
// Nothing new: the game's table stays in use.
|
||||
assert!(e.with_entries([("minecraft:sharpness", &other)]).is_none());
|
||||
}
|
||||
|
||||
/// The enchantment's name and its level come from the selected language,
|
||||
/// with the English name loaded above as the fallback.
|
||||
#[test]
|
||||
|
||||
@@ -365,6 +365,9 @@ thread_local! {
|
||||
}
|
||||
pub struct Items {
|
||||
pub items: HashMap<Arc<str>, ItemInfo>,
|
||||
/// Default `minecraft:mob_visibility` by item id, with the `equippable`
|
||||
/// slot it applies in (`LivingEntity.getVisibilityPercent`).
|
||||
mob_visibility: HashMap<Arc<str>, (String, MobVisibility)>,
|
||||
pub order: Vec<Arc<str>>,
|
||||
/// The same items in item registry (network id) order, as vanilla lists
|
||||
/// them; sorted by name when the registry export is missing.
|
||||
@@ -591,6 +594,56 @@ fn entity_spec(tags: &HashMap<String, HashSet<String>>, spec: &str) -> HashSet<S
|
||||
None => HashSet::from([spec.to_owned()]),
|
||||
}
|
||||
}
|
||||
/// `MobVisibility`: entity types a worn item hides its wearer from, and the
|
||||
/// factor `LivingEntity.getVisibilityPercent` multiplies by.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MobVisibility {
|
||||
pub targeting: HashSet<String>,
|
||||
pub visibility: f32,
|
||||
}
|
||||
/// `minecraft:mob_visibility` (`targeting_entity_types` is one id, a `#tag`,
|
||||
/// or a list) when the object is present and names at least one type.
|
||||
fn parse_mob_visibility(
|
||||
v: &Value,
|
||||
tags: &HashMap<String, HashSet<String>>,
|
||||
) -> Option<MobVisibility> {
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
let visibility = v.get("visibility")?.as_f64()? as f32;
|
||||
let targeting = match v.get("targeting_entity_types")? {
|
||||
Value::String(spec) => entity_spec(tags, spec),
|
||||
Value::Array(list) => list
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.flat_map(|spec| entity_spec(tags, spec))
|
||||
.collect(),
|
||||
_ => return None,
|
||||
};
|
||||
if targeting.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(MobVisibility {
|
||||
targeting,
|
||||
visibility,
|
||||
})
|
||||
}
|
||||
/// The item's `equippable.slot` and `mob_visibility`, when both are set.
|
||||
/// `getVisibilityPercent` multiplies only when the stack is in that slot.
|
||||
fn worn_mob_visibility(
|
||||
components: &Value,
|
||||
tags: &HashMap<String, HashSet<String>>,
|
||||
) -> Option<(String, MobVisibility)> {
|
||||
let slot = components
|
||||
.get("minecraft:equippable")?
|
||||
.get("slot")?
|
||||
.as_str()?
|
||||
.to_owned();
|
||||
Some((
|
||||
slot,
|
||||
parse_mob_visibility(components.get("minecraft:mob_visibility")?, tags)?,
|
||||
))
|
||||
}
|
||||
/// Every item of the last item registry loaded with its `villager_food`
|
||||
/// nutrition (0 without the component): what the mobs' villagers read, since
|
||||
/// a mob does not hold the registry (`None` until one is loaded).
|
||||
@@ -706,6 +759,7 @@ impl Items {
|
||||
};
|
||||
let mut items = HashMap::new();
|
||||
let mut order = Vec::new();
|
||||
let mut mob_visibility = HashMap::new();
|
||||
for (name, c) in raw.iter() {
|
||||
let id: Arc<str> = Arc::from(name.as_str());
|
||||
let tool = c["minecraft:tool"]["rules"]
|
||||
@@ -911,6 +965,9 @@ impl Items {
|
||||
attack_speed: modifier("minecraft:attack_speed"),
|
||||
},
|
||||
);
|
||||
if let Some(parsed) = worn_mob_visibility(c, &entity_tags) {
|
||||
mob_visibility.insert(id.clone(), parsed);
|
||||
}
|
||||
order.push(id);
|
||||
}
|
||||
// Every loot table, keyed by its id (blocks/stone, entities/sheep/white,
|
||||
@@ -922,6 +979,7 @@ impl Items {
|
||||
set_villager_food(&items);
|
||||
Ok(Self {
|
||||
items,
|
||||
mob_visibility,
|
||||
order,
|
||||
registry_order,
|
||||
block_tags: load_tags(root, "block", overlay)?,
|
||||
@@ -936,6 +994,25 @@ impl Items {
|
||||
pub fn get(&self, id: &str) -> Option<&ItemInfo> {
|
||||
self.items.get(id)
|
||||
}
|
||||
/// `ItemStack.get(DataComponents.MOB_VISIBILITY).visibility()` when the
|
||||
/// prototype's equippable slot is `slot` and `targeting` is in
|
||||
/// `targetingEntityTypes`. None otherwise (`getVisibilityPercent` skips it).
|
||||
pub fn worn_visibility(&self, item: &str, slot: &str, targeting: &str) -> Option<f32> {
|
||||
let (equip, vis) = self.mob_visibility.get(item)?;
|
||||
if equip == slot && vis.targeting.contains(targeting) {
|
||||
Some(vis.visibility)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
/// Record one prototype's `equippable` and `mob_visibility` the way `load`
|
||||
/// reads them from the default-component report.
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub fn insert_mob_visibility_component(&mut self, id: &str, components: &Value) {
|
||||
if let Some(parsed) = worn_mob_visibility(components, &HashMap::new()) {
|
||||
self.mob_visibility.insert(Arc::from(id), parsed);
|
||||
}
|
||||
}
|
||||
/// The instrument a goat horn plays: its `instrument` component's
|
||||
/// registry entry or inline instrument.
|
||||
pub fn instrument(&self, stack: &ItemStack) -> Option<Instrument> {
|
||||
@@ -952,6 +1029,7 @@ impl Items {
|
||||
pub fn tags_only(root: &Path, overlay: &PackOverlay) -> Result<Self> {
|
||||
Ok(Self {
|
||||
items: HashMap::new(),
|
||||
mob_visibility: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
registry_order: Vec::new(),
|
||||
block_tags: load_tags(root, "block", overlay)?,
|
||||
@@ -968,6 +1046,7 @@ impl Items {
|
||||
pub fn for_tests(item_tags: HashMap<String, HashSet<String>>) -> Self {
|
||||
Self {
|
||||
items: HashMap::new(),
|
||||
mob_visibility: HashMap::new(),
|
||||
order: Vec::new(),
|
||||
registry_order: Vec::new(),
|
||||
block_tags: HashMap::new(),
|
||||
|
||||
@@ -8,6 +8,7 @@ mod tests {
|
||||
count: i32,
|
||||
}
|
||||
struct BlockEntityDraw(Vec<u32>);
|
||||
struct NativeWorldDraw(Vec<u32>);
|
||||
#[allow(dead_code)]
|
||||
mod stubs {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -148,6 +149,9 @@ mod tests {
|
||||
calls: Arc<Mutex<Vec<u32>>>,
|
||||
}
|
||||
impl Hooks for Mod {
|
||||
fn world_submit(&self, _: &dyn Host, event: &mut WorldSubmitEvent<'_>) {
|
||||
event.draw.0.push(self.id);
|
||||
}
|
||||
fn block_entity_submit(&self, _: &dyn Host, event: &mut BlockEntitySubmitEvent<'_>) {
|
||||
assert_eq!(event.pos, [1, 2, 3]);
|
||||
assert_eq!(event.block, "example:machine");
|
||||
@@ -229,6 +233,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn world_submit_dispatches_in_order_with_shared_draw() {
|
||||
let mods: Vec<Box<dyn Hooks>> = vec![1, 2]
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
Box::new(Mod {
|
||||
id,
|
||||
calls: Arc::new(Mutex::new(Vec::new())),
|
||||
}) as Box<dyn Hooks>
|
||||
})
|
||||
.collect();
|
||||
let mut draw = NativeWorldDraw(Vec::new());
|
||||
dispatch_world_submit(&TestHost, &mods, &mut draw);
|
||||
assert_eq!(draw.0, [1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_entity_submit_dispatches_in_order_with_shared_draw() {
|
||||
let calls = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
@@ -855,6 +855,13 @@ pub fn carry(
|
||||
}
|
||||
let file = fs::File::open(jar).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?;
|
||||
// The previous harness verdict describes the content we are about to replace.
|
||||
let verdict = out.join("harness/result.json");
|
||||
if let Err(e) = fs::remove_file(&verdict)
|
||||
&& e.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
return Err(format!("{}: {e}", verdict.display()));
|
||||
}
|
||||
let (inside, outside) = (out.join("carry"), out.join("outside"));
|
||||
let _ = fs::remove_dir_all(&inside);
|
||||
let _ = fs::remove_dir_all(&outside);
|
||||
@@ -1438,6 +1445,112 @@ mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
#[test]
|
||||
fn recarry_invalidates_the_verdict_before_replacing_content() {
|
||||
use crate::{
|
||||
inventory::Record,
|
||||
report::{self, Status},
|
||||
};
|
||||
|
||||
let nonce = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-recarry-{}-{nonce}", std::process::id()));
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
let jar = dir.join("example.jar");
|
||||
let payload = br#"{"variants":{"":{"model":"example:block/stone"}}}"#;
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(&jar).unwrap());
|
||||
zip.start_file(
|
||||
"assets/example/blockstates/stone.json",
|
||||
zip::write::SimpleFileOptions::default()
|
||||
.compression_method(zip::CompressionMethod::Stored),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(payload).unwrap();
|
||||
zip.finish().unwrap();
|
||||
let work = dir.join("work");
|
||||
let out = work.join("mods/example");
|
||||
let verdict = out.join("harness/result.json");
|
||||
let info = crate::jar::JarInfo {
|
||||
mod_id: "example".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let run = |source: &Path| {
|
||||
carry(
|
||||
source,
|
||||
&info,
|
||||
&out,
|
||||
None,
|
||||
&|_| true,
|
||||
&Pack {
|
||||
loaded: &|_| true,
|
||||
mods: &|_| false,
|
||||
},
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
};
|
||||
let records = [Record {
|
||||
key: "example".into(),
|
||||
class: "content".into(),
|
||||
tier: "data".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
let row = || report::build(&records, &work).pop().unwrap();
|
||||
run(&jar).unwrap();
|
||||
fs::create_dir_all(verdict.parent().unwrap()).unwrap();
|
||||
let green = br#"{"checks":{"blocks":{"passed":1,"failed":0}}}"#;
|
||||
let untouched = work.join("mods/untouched/harness/result.json");
|
||||
fs::create_dir_all(untouched.parent().unwrap()).unwrap();
|
||||
fs::write(&untouched, green).unwrap();
|
||||
for aggregate in [false, true] {
|
||||
let result = if aggregate {
|
||||
br#"{"passed":true,"checks":{"blocks":{"passed":1,"failed":0}}}"#.as_slice()
|
||||
} else {
|
||||
green.as_slice()
|
||||
};
|
||||
fs::write(&verdict, result).unwrap();
|
||||
assert_eq!(row().status, Status::Ported);
|
||||
run(&jar).unwrap();
|
||||
assert_eq!(row().status, Status::Partial);
|
||||
assert!(row().reason.contains("harness not run"));
|
||||
assert!(!verdict.exists());
|
||||
assert_eq!(fs::read(&untouched).unwrap(), green);
|
||||
}
|
||||
fs::write(&verdict, green).unwrap();
|
||||
// An invalid archive has not changed the old content, so its evidence stays valid.
|
||||
let bad = dir.join("invalid.jar");
|
||||
fs::write(&bad, b"not a zip").unwrap();
|
||||
assert!(run(&bad).is_err());
|
||||
assert_eq!(row().status, Status::Ported);
|
||||
assert_eq!(fs::read(&verdict).unwrap(), green);
|
||||
|
||||
// Opening this ZIP succeeds, but reading its payload fails the CRC after mutation starts.
|
||||
let mut corrupt = fs::read(&jar).unwrap();
|
||||
let offset = corrupt
|
||||
.windows(payload.len())
|
||||
.position(|w| w == payload)
|
||||
.unwrap();
|
||||
corrupt[offset] ^= 1;
|
||||
fs::write(&bad, corrupt).unwrap();
|
||||
assert!(run(&bad).is_err());
|
||||
assert_eq!(row().status, Status::Partial);
|
||||
assert!(!verdict.exists());
|
||||
run(&jar).unwrap();
|
||||
fs::write(&verdict, green).unwrap();
|
||||
assert_eq!(row().status, Status::Ported);
|
||||
|
||||
// An invalidation error must leave the carried content alone.
|
||||
fs::remove_file(&verdict).unwrap();
|
||||
fs::create_dir(&verdict).unwrap();
|
||||
let sentinel = out.join("carry/keep.txt");
|
||||
fs::write(&sentinel, b"keep").unwrap();
|
||||
assert!(run(&jar).is_err());
|
||||
assert_eq!(fs::read(&sentinel).unwrap(), b"keep");
|
||||
fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
/// YUNG's pools carry comments, which Minecraft's lenient reader skips;
|
||||
/// the carry holds them as plain JSON. A comment marker inside a string
|
||||
/// stays, and text that is not JSON without its comments is refused.
|
||||
|
||||
@@ -546,6 +546,118 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn moderation_reply(response: Response) -> Value {
|
||||
assert!(response.stream.is_none());
|
||||
json!({
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"headers": response.headers,
|
||||
"body": serde_json::from_str::<Value>(&response.body).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_replies_match_api_v1_goldens() {
|
||||
let golden: Value =
|
||||
serde_json::from_str(include_str!("../testdata/api-v1-moderation.json")).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-api-moderation-goldens-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(crate::config::FILE), crate::config::TEMPLATE).unwrap();
|
||||
crate::config::update(&dir, "server", "online_mode", toml::Value::Boolean(false)).unwrap();
|
||||
crate::config::update(&dir, "server", "whitelist", toml::Value::Boolean(false)).unwrap();
|
||||
let shared = Shared::with_install(&dir, None);
|
||||
shared.set_phase(Phase::Stopped);
|
||||
let call = |path: &str, body: &[u8]| {
|
||||
moderation_reply(crate::api::route(
|
||||
&shared,
|
||||
&crate::http::Request {
|
||||
method: "POST".into(),
|
||||
path: path.into(),
|
||||
query: String::new(),
|
||||
headers: Vec::new(),
|
||||
body: body.to_vec(),
|
||||
},
|
||||
))
|
||||
};
|
||||
let check = |case: &str, path: &str, body: &[u8]| {
|
||||
assert_eq!(call(path, body), golden[case], "{case}");
|
||||
};
|
||||
|
||||
check("op", "/api/v1/players/op", br#"{"name":"Alex"}"#);
|
||||
check("op_again", "/api/v1/players/op", br#"{"name":"Alex"}"#);
|
||||
check("deop", "/api/v1/players/deop", br#"{"name":"Alex"}"#);
|
||||
check("deop_again", "/api/v1/players/deop", br#"{"name":"Alex"}"#);
|
||||
check(
|
||||
"whitelist_add",
|
||||
"/api/v1/whitelist/add",
|
||||
br#"{"name":"Alex"}"#,
|
||||
);
|
||||
check(
|
||||
"whitelist_add_again",
|
||||
"/api/v1/whitelist/add",
|
||||
br#"{"name":"Alex"}"#,
|
||||
);
|
||||
check("whitelist_on", "/api/v1/whitelist", br#"{"enabled":true}"#);
|
||||
check(
|
||||
"whitelist_on_again",
|
||||
"/api/v1/whitelist",
|
||||
br#"{"enabled":true}"#,
|
||||
);
|
||||
check(
|
||||
"whitelist_remove",
|
||||
"/api/v1/whitelist/remove",
|
||||
br#"{"name":"Alex"}"#,
|
||||
);
|
||||
check(
|
||||
"whitelist_remove_again",
|
||||
"/api/v1/whitelist/remove",
|
||||
br#"{"name":"Alex"}"#,
|
||||
);
|
||||
for (case, path, body) in [
|
||||
(
|
||||
"kick_stopped",
|
||||
"/api/v1/players/kick",
|
||||
br#"{"name":"Alex"}"#.as_slice(),
|
||||
),
|
||||
(
|
||||
"ban_stopped",
|
||||
"/api/v1/players/ban",
|
||||
br#"{"name":"Alex"}"#.as_slice(),
|
||||
),
|
||||
(
|
||||
"pardon_stopped",
|
||||
"/api/v1/players/pardon",
|
||||
br#"{"name":"Alex"}"#.as_slice(),
|
||||
),
|
||||
(
|
||||
"ban_ip_stopped",
|
||||
"/api/v1/players/ban-ip",
|
||||
br#"{"ip":"203.0.113.7"}"#.as_slice(),
|
||||
),
|
||||
(
|
||||
"pardon_ip_stopped",
|
||||
"/api/v1/players/pardon-ip",
|
||||
br#"{"ip":"203.0.113.7"}"#.as_slice(),
|
||||
),
|
||||
] {
|
||||
check(case, path, body);
|
||||
}
|
||||
check("malformed", "/api/v1/players/op", b"not json");
|
||||
check("bad_name", "/api/v1/players/op", br#"{"name":"a b"}"#);
|
||||
check(
|
||||
"bad_reason",
|
||||
"/api/v1/players/kick",
|
||||
br#"{"name":"Alex","reason":"first\nsecond"}"#,
|
||||
);
|
||||
check("bad_ip", "/api/v1/players/pardon-ip", br#"{"ip":"Alex"}"#);
|
||||
check("bad_whitelist", "/api/v1/whitelist", br#"{}"#);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mods_come_from_the_build_record() {
|
||||
let none = built_with(None);
|
||||
|
||||
@@ -1006,6 +1006,128 @@ impl Client {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn observability_reply(mut response: Response, dynamic: &[&str]) -> Value {
|
||||
assert!(response.stream.is_none());
|
||||
let mut body = serde_json::from_str::<Value>(&response.body)
|
||||
.unwrap_or_else(|_| Value::String(response.body.clone()));
|
||||
for field in dynamic {
|
||||
if let Some(value) = body.get_mut(*field) {
|
||||
*value = json!("<dynamic>");
|
||||
}
|
||||
}
|
||||
if let Some(text) = body.as_str() {
|
||||
let text = text
|
||||
.lines()
|
||||
.map(|line| {
|
||||
if line.starts_with("horizon_supervisor_uptime_seconds ") {
|
||||
"horizon_supervisor_uptime_seconds <dynamic>"
|
||||
} else {
|
||||
line
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
body = Value::String(if response.body.ends_with('\n') {
|
||||
format!("{text}\n")
|
||||
} else {
|
||||
text
|
||||
});
|
||||
}
|
||||
json!({
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"headers": std::mem::take(&mut response.headers),
|
||||
"body": body,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observability_replies_match_api_v1_goldens() {
|
||||
let golden: Value =
|
||||
serde_json::from_str(include_str!("../testdata/api-v1-observability.json")).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-api-observability-goldens-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let shared = Shared::with_install(&dir, None);
|
||||
shared.set_phase(Phase::Stopped);
|
||||
{
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
state.crashes = 2;
|
||||
state.last_crash = Some("exit status: 1".into());
|
||||
state.sample = json!({
|
||||
"t": 200,
|
||||
"rss_bytes": 536_870_912u64,
|
||||
"cpu_percent": 12.5,
|
||||
"game": {
|
||||
"tps": 19.9,
|
||||
"target_tps": 20.0,
|
||||
"mspt": { "average": 8.25, "p50": 7.0, "p95": 12.5, "p99": 18.0 },
|
||||
"players": [{ "name": "Alex" }, { "name": "Steve" }],
|
||||
"max_players": 20,
|
||||
"chunks": 321,
|
||||
},
|
||||
});
|
||||
}
|
||||
shared.record(json!({ "t": 100, "game": { "tps": 20.0 } }));
|
||||
shared.record(shared.state.lock().unwrap().sample.clone());
|
||||
shared.log.lock().unwrap().push("first line".into());
|
||||
shared.log.lock().unwrap().push("second line".into());
|
||||
|
||||
let call = |path: &str, query: &str, dynamic: &[&str]| {
|
||||
observability_reply(
|
||||
route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: "GET".into(),
|
||||
path: path.into(),
|
||||
query: query.into(),
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
},
|
||||
),
|
||||
dynamic,
|
||||
)
|
||||
};
|
||||
let cases = [
|
||||
(
|
||||
"status_stopped",
|
||||
"/api/v1/status",
|
||||
"",
|
||||
&["pid", "uptime_seconds"][..],
|
||||
),
|
||||
("logs_all", "/api/v1/logs", "", &[][..]),
|
||||
("logs_since", "/api/v1/logs", "since=1", &[][..]),
|
||||
("logs_invalid_since", "/api/v1/logs", "since=nope", &[][..]),
|
||||
("metrics_all", "/api/v1/metrics", "", &[][..]),
|
||||
("metrics_since", "/api/v1/metrics", "since=100", &[][..]),
|
||||
(
|
||||
"metrics_invalid_since",
|
||||
"/api/v1/metrics",
|
||||
"since=nope",
|
||||
&[][..],
|
||||
),
|
||||
(
|
||||
"prometheus_stopped",
|
||||
"/api/v1/metrics/prometheus",
|
||||
"",
|
||||
&[][..],
|
||||
),
|
||||
];
|
||||
for (case, path, query, dynamic) in cases {
|
||||
assert_eq!(call(path, query, dynamic), golden[case], "{case}");
|
||||
}
|
||||
shared.set_phase(Phase::Running);
|
||||
assert_eq!(
|
||||
call("/api/v1/metrics/prometheus", "", &[]),
|
||||
golden["prometheus_running"],
|
||||
"prometheus_running"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
const LIFECYCLE_GOLDENS: &str = include_str!("../testdata/api-v1-lifecycle.json");
|
||||
|
||||
fn golden_phase(name: &str) -> Phase {
|
||||
@@ -1516,4 +1638,96 @@ mod tests {
|
||||
assert!(shared.restart.load(Ordering::SeqCst));
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn config_reply(response: Response) -> Value {
|
||||
assert!(response.stream.is_none());
|
||||
json!({
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"headers": response.headers,
|
||||
"body": serde_json::from_str::<Value>(&response.body).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_replies_match_api_v1_goldens() {
|
||||
let golden: Value =
|
||||
serde_json::from_str(include_str!("../testdata/api-v1-config.json")).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-api-config-goldens-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let toml = crate::config::TEMPLATE
|
||||
.replace("token = \"\"", "token = \"a-token-for-the-config-test\"")
|
||||
.replace(
|
||||
"webhook = \"\"",
|
||||
"webhook = \"https://discord.com/api/webhooks/123/config-test\"",
|
||||
);
|
||||
std::fs::write(dir.join(crate::config::FILE), toml).unwrap();
|
||||
let shared = Shared::with_install(&dir, None);
|
||||
shared.set_phase(Phase::Stopped);
|
||||
let call = |method: &str, body: &[u8]| {
|
||||
config_reply(route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: method.into(),
|
||||
path: "/api/v1/config".into(),
|
||||
query: String::new(),
|
||||
headers: Vec::new(),
|
||||
body: body.to_vec(),
|
||||
},
|
||||
))
|
||||
};
|
||||
let check = |case: &str, method: &str, body: &[u8]| {
|
||||
assert_eq!(call(method, body), golden[case], "{case}");
|
||||
};
|
||||
|
||||
check("get", "GET", b"");
|
||||
check("malformed", "PATCH", b"not json");
|
||||
check(
|
||||
"protected",
|
||||
"PATCH",
|
||||
br#"{"changes":{"api.token":"replacement"}}"#,
|
||||
);
|
||||
check(
|
||||
"invalid_range",
|
||||
"PATCH",
|
||||
br#"{"changes":{"server.max_players":0}}"#,
|
||||
);
|
||||
check(
|
||||
"invalid_shape",
|
||||
"PATCH",
|
||||
br#"{"changes":{"world.seed":[1,2]}}"#,
|
||||
);
|
||||
check(
|
||||
"unknown",
|
||||
"PATCH",
|
||||
br#"{"changes":{"server.unknown":true}}"#,
|
||||
);
|
||||
check(
|
||||
"patch_stopped",
|
||||
"PATCH",
|
||||
br#"{"changes":{"world.difficulty":"hard","server.max_players":50}}"#,
|
||||
);
|
||||
|
||||
shared.set_phase(Phase::Running);
|
||||
check(
|
||||
"patch_running",
|
||||
"PATCH",
|
||||
br#"{"changes":{"server.port":25566,"backups.keep":7}}"#,
|
||||
);
|
||||
let saved = crate::config::Config::load(&dir).unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
saved.world.difficulty.as_str(),
|
||||
saved.server.max_players,
|
||||
saved.server.port,
|
||||
saved.backups.keep,
|
||||
),
|
||||
("hard", 50, 25566, 7)
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,14 @@ bind = "" # the control API: "" a free port on 127.0.0.1; or
|
||||
token = "" # its token (16 characters or more); "" a new random one at every start
|
||||
allow_public = false # true lets bind be any address; the token is then all that guards the server
|
||||
|
||||
# A proxy handing players over between servers (docs/server.md). Not vanilla's accepts-transfers (the transfer packet).
|
||||
[transfer]
|
||||
enabled = false # true: take a proxy's player-* requests; needs Velocity forwarding (forwarding.secret)
|
||||
expect_seconds = 10 # how long a player the proxy announced is waited for, when it names no time
|
||||
max_expect_seconds = 60 # the longest the proxy may ask for
|
||||
max_expected = 64 # players waited for at once
|
||||
max_freeze_seconds = 30 # the longest a player stays frozen while it is handed over
|
||||
|
||||
# Post the chat, joins, leaves and deaths to a Discord channel, drawn like the in-game chat (docs/hosting.md)
|
||||
[discord]
|
||||
webhook = "" # the channel's webhook URL (a secret: anyone with it can post there); "" off
|
||||
@@ -137,6 +145,9 @@ pub struct Config {
|
||||
pub panel: Panel,
|
||||
#[serde(default)]
|
||||
pub api: Api,
|
||||
/// A proxy's hand-overs (docs/server.md), passed to the game as `--transfers`.
|
||||
#[serde(default)]
|
||||
pub transfer: Transfer,
|
||||
/// Chat and players posted to a Discord channel (discord.rs).
|
||||
#[serde(default)]
|
||||
pub discord: crate::discord::Settings,
|
||||
@@ -287,6 +298,64 @@ pub fn private(ip: std::net::IpAddr) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `[transfer]`: a proxy moving players between this server and others (docs/server.md, the game's
|
||||
/// `lan/arrivals.rs`). Off by default. The supervisor passes it to the game as `--transfers`, not as a
|
||||
/// server.properties key, which stays vanilla's; and it is not vanilla's `accepts-transfers`, which is about the
|
||||
/// transfer packet.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Transfer {
|
||||
pub enabled: bool,
|
||||
pub expect_seconds: u32,
|
||||
pub max_expect_seconds: u32,
|
||||
pub max_expected: u32,
|
||||
pub max_freeze_seconds: u32,
|
||||
}
|
||||
|
||||
impl Default for Transfer {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
expect_seconds: 10,
|
||||
max_expect_seconds: 60,
|
||||
max_expected: 64,
|
||||
max_freeze_seconds: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Transfer {
|
||||
fn check(&self) -> Result<(), String> {
|
||||
for (name, n) in [
|
||||
("expect_seconds", self.expect_seconds),
|
||||
("max_expect_seconds", self.max_expect_seconds),
|
||||
("max_expected", self.max_expected),
|
||||
("max_freeze_seconds", self.max_freeze_seconds),
|
||||
] {
|
||||
if n == 0 {
|
||||
return Err(format!("transfer.{name} must be above zero"));
|
||||
}
|
||||
}
|
||||
if self.expect_seconds > self.max_expect_seconds {
|
||||
return Err("transfer.expect_seconds is above transfer.max_expect_seconds".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The game's `--transfers` value, when the section turns transfers on.
|
||||
pub fn engine_arg(&self) -> Option<String> {
|
||||
self.enabled.then(|| {
|
||||
format!(
|
||||
"expect_seconds={},max_expect_seconds={},max_expected={},max_freeze_seconds={}",
|
||||
self.expect_seconds,
|
||||
self.max_expect_seconds,
|
||||
self.max_expected,
|
||||
self.max_freeze_seconds
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Backups {
|
||||
@@ -408,6 +477,7 @@ impl Config {
|
||||
crate::backup::interval(&self.backups.every)?;
|
||||
self.panel.address()?;
|
||||
self.api.check()?;
|
||||
self.transfer.check()?;
|
||||
self.discord.check()?;
|
||||
self.anticheat.check()?;
|
||||
self.chat_filter.check()?;
|
||||
@@ -519,6 +589,11 @@ pub fn render(config: &Config) -> String {
|
||||
("api", "bind"),
|
||||
("api", "token"),
|
||||
("api", "allow_public"),
|
||||
("transfer", "enabled"),
|
||||
("transfer", "expect_seconds"),
|
||||
("transfer", "max_expect_seconds"),
|
||||
("transfer", "max_expected"),
|
||||
("transfer", "max_freeze_seconds"),
|
||||
] {
|
||||
set(&mut doc, section, field, &table[section][field]);
|
||||
}
|
||||
@@ -668,6 +743,31 @@ mod tests {
|
||||
assert_eq!(render(&Config::default()), TEMPLATE);
|
||||
}
|
||||
|
||||
/// `[transfer]` is off by default, passes nothing to the game then, and is checked when it is read.
|
||||
#[test]
|
||||
fn transfers_are_off_by_default_and_checked() {
|
||||
assert_eq!(Config::default().transfer.engine_arg(), None);
|
||||
let on = TEMPLATE.replace(
|
||||
"enabled = false # true: take",
|
||||
"enabled = true # true: take",
|
||||
);
|
||||
let config = Config::parse(&on).unwrap();
|
||||
assert_eq!(
|
||||
config.transfer.engine_arg().as_deref(),
|
||||
Some("expect_seconds=10,max_expect_seconds=60,max_expected=64,max_freeze_seconds=30")
|
||||
);
|
||||
assert_eq!(Config::parse(&render(&config)).unwrap(), config);
|
||||
for (from, to) in [
|
||||
("expect_seconds = 10 ", "expect_seconds = 0 "),
|
||||
("max_expect_seconds = 60", "max_expect_seconds = 5 "),
|
||||
("max_freeze_seconds = 30", "max_freeze_seconds = 0 "),
|
||||
] {
|
||||
let bad = on.replace(from, to);
|
||||
assert_ne!(bad, on, "{from} is in the template");
|
||||
assert!(Config::parse(&bad).is_err(), "{to} was taken");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_chat_filter_is_on_unless_its_section_turns_it_off() {
|
||||
assert!(Config::default().chat_filter.enabled);
|
||||
|
||||
@@ -238,8 +238,9 @@ pub fn spawn(
|
||||
resources: &Path,
|
||||
dir: &Path,
|
||||
low_memory: bool,
|
||||
transfers: Option<&str>,
|
||||
) -> std::io::Result<Child> {
|
||||
command(program, resources, dir, low_memory)?.spawn()
|
||||
command(program, resources, dir, low_memory, transfers)?.spawn()
|
||||
}
|
||||
|
||||
/// The command `spawn` runs.
|
||||
@@ -248,6 +249,7 @@ fn command(
|
||||
resources: &Path,
|
||||
dir: &Path,
|
||||
low_memory: bool,
|
||||
transfers: Option<&str>,
|
||||
) -> std::io::Result<Command> {
|
||||
// The game runs in the server folder, so a folder given relative to ours (`--dir srv`) would put its
|
||||
// control endpoint in `srv/srv/.horizon`, which doesn't exist.
|
||||
@@ -258,7 +260,12 @@ fn command(
|
||||
.arg("--resources")
|
||||
.arg(resources)
|
||||
.arg("--control-socket")
|
||||
.arg(control_path(&dir))
|
||||
.arg(control_path(&dir));
|
||||
// `[transfer]` on: a proxy's hand-over requests on that endpoint (docs/server.md).
|
||||
if let Some(transfers) = transfers {
|
||||
command.arg("--transfers").arg(transfers);
|
||||
}
|
||||
command
|
||||
.args(["--dedicated", "--nogui"])
|
||||
.current_dir(&dir)
|
||||
.env("HORIZON_NO_AUDIO", "1")
|
||||
@@ -339,7 +346,8 @@ mod tests {
|
||||
fn low_memory_turns_huge_pages_off_for_the_game() {
|
||||
let thp = |low_memory| {
|
||||
let dir = std::env::temp_dir();
|
||||
let command = command(Path::new("horizon-client"), &dir, &dir, low_memory).unwrap();
|
||||
let command =
|
||||
command(Path::new("horizon-client"), &dir, &dir, low_memory, None).unwrap();
|
||||
command
|
||||
.get_envs()
|
||||
.find(|(k, _)| *k == "MIMALLOC_ALLOW_THP")
|
||||
@@ -349,6 +357,25 @@ mod tests {
|
||||
assert_eq!(thp(false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_reach_the_game_only_when_they_are_on() {
|
||||
let args = |transfers: Option<&str>| {
|
||||
let dir = std::env::temp_dir();
|
||||
let command =
|
||||
command(Path::new("horizon-client"), &dir, &dir, false, transfers).unwrap();
|
||||
command
|
||||
.get_args()
|
||||
.map(|a| a.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert!(!args(None).iter().any(|a| a == "--transfers"));
|
||||
let on = args(Some("expect_seconds=10"));
|
||||
let at = on.iter().position(|a| a == "--transfers").unwrap();
|
||||
assert_eq!(on[at + 1], "expect_seconds=10");
|
||||
// Before `--dedicated`: what follows it is vanilla's server arguments.
|
||||
assert!(at < on.iter().position(|a| a == "--dedicated").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resources_are_searched_in_order() {
|
||||
let mut config = Config::default();
|
||||
@@ -515,6 +542,7 @@ mod tests {
|
||||
&root.join("res"),
|
||||
&server,
|
||||
false,
|
||||
None,
|
||||
) {
|
||||
Err(e) if e.raw_os_error() == Some(26) && tries < 50 => {
|
||||
tries += 1;
|
||||
|
||||
@@ -193,6 +193,16 @@ pub fn route(shared: &Arc<Shared>, request: &Request) -> Response {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event_reply(response: Response) -> Value {
|
||||
assert!(response.stream.is_none());
|
||||
json!({
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"headers": response.headers,
|
||||
"body": serde_json::from_str::<Value>(&response.body).unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_after_a_number_by_type() {
|
||||
let mut events = Events::default();
|
||||
@@ -283,4 +293,67 @@ mod tests {
|
||||
assert_eq!(polled["events"][0]["line"], "not a join");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_replies_match_api_v1_goldens() {
|
||||
use std::io::Read;
|
||||
|
||||
let golden: Value =
|
||||
serde_json::from_str(include_str!("../testdata/api-v1-events.json")).unwrap();
|
||||
let shared = Shared::with_install(std::path::Path::new("unused"), None);
|
||||
shared.log_line("ready".into());
|
||||
shared.emit("join", json!({ "name": "Alex" }));
|
||||
shared.emit("chat", json!({ "name": "Alex", "message": "hi" }));
|
||||
let poll = |query: &str, headers: Vec<(String, String)>| {
|
||||
event_reply(route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: "GET".into(),
|
||||
path: "/api/v1/events".into(),
|
||||
query: query.into(),
|
||||
headers,
|
||||
body: Vec::new(),
|
||||
},
|
||||
))
|
||||
};
|
||||
assert_eq!(poll("", Vec::new()), golden["all"]);
|
||||
assert_eq!(poll("since=2", Vec::new()), golden["since"]);
|
||||
assert_eq!(poll("types=join,leave", Vec::new()), golden["types"]);
|
||||
assert_eq!(
|
||||
poll("", vec![("Last-Event-ID".into(), "1".into())]),
|
||||
golden["last_event_id"]
|
||||
);
|
||||
assert_eq!(poll("since=not-a-number", Vec::new()), golden["bad_since"]);
|
||||
assert_eq!(poll("types=unknown", Vec::new()), golden["unknown_type"]);
|
||||
|
||||
let mut response = route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: "GET".into(),
|
||||
path: "/api/v1/events".into(),
|
||||
query: "types=join,chat".into(),
|
||||
headers: vec![
|
||||
("Accept".into(), "text/event-stream".into()),
|
||||
("Last-Event-ID".into(), "1".into()),
|
||||
],
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
assert_eq!(response.status, golden["stream"]["status"]);
|
||||
assert_eq!(response.content_type, golden["stream"]["content_type"]);
|
||||
assert_eq!(response.headers, Vec::<(&str, String)>::new());
|
||||
let expected = golden["stream"]["body"].as_str().unwrap().as_bytes();
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
let control = server.try_clone().unwrap();
|
||||
let write = response.stream.take().unwrap();
|
||||
let writer = std::thread::spawn(move || write(&server));
|
||||
let mut body = vec![0; expected.len()];
|
||||
client.read_exact(&mut body).unwrap();
|
||||
assert_eq!(body, expected);
|
||||
control.shutdown(std::net::Shutdown::Both).unwrap();
|
||||
shared.emit("join", json!({ "name": "Wake" }));
|
||||
writer.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -170,8 +170,15 @@ pub fn run(dir: &Path) -> Result<(), String> {
|
||||
),
|
||||
);
|
||||
shared.set_phase(Phase::Starting);
|
||||
let mut child = engine::spawn(&program, &resources, dir, config.engine.low_memory)
|
||||
.map_err(|e| format!("{}: {e}", program.display()))?;
|
||||
let transfers = config.transfer.engine_arg();
|
||||
let mut child = engine::spawn(
|
||||
&program,
|
||||
&resources,
|
||||
dir,
|
||||
config.engine.low_memory,
|
||||
transfers.as_deref(),
|
||||
)
|
||||
.map_err(|e| format!("{}: {e}", program.display()))?;
|
||||
{
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
state.engine_pid = Some(child.id());
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
{
|
||||
"get": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"config": {
|
||||
"api": {"allow_public": false, "bind": "", "token": "(set)"},
|
||||
"backups": {"every": "6h", "keep": 12},
|
||||
"discord": {
|
||||
"enabled": true,
|
||||
"events": ["chat", "join", "leave", "death"],
|
||||
"webhook": "(set)"
|
||||
},
|
||||
"engine": {
|
||||
"low_memory": false,
|
||||
"path": "",
|
||||
"resources": "",
|
||||
"restart_on_crash": true
|
||||
},
|
||||
"panel": {"allow_public": false, "bind": "127.0.0.1:8130"},
|
||||
"properties": {},
|
||||
"server": {
|
||||
"admin_feedback_to_ops": true,
|
||||
"bind": "",
|
||||
"enforce_whitelist": false,
|
||||
"max_players": 20,
|
||||
"max_view_distance": 0,
|
||||
"motd": "A Minecraft Server",
|
||||
"online_mode": true,
|
||||
"port": 25565,
|
||||
"simulation_distance": 10,
|
||||
"view_distance": 10,
|
||||
"whitelist": true
|
||||
},
|
||||
"transfer": {
|
||||
"enabled": false,
|
||||
"expect_seconds": 10,
|
||||
"max_expect_seconds": 60,
|
||||
"max_expected": 64,
|
||||
"max_freeze_seconds": 30
|
||||
},
|
||||
"updates": {"keep": 1},
|
||||
"world": {
|
||||
"difficulty": "easy",
|
||||
"game_mode": "survival",
|
||||
"hardcore": false,
|
||||
"name": "world",
|
||||
"seed": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"malformed": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the body must be {\"changes\": {\"section.name\": value, ...}}"}
|
||||
},
|
||||
"protected": {
|
||||
"status": 403,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "api.token can only be changed in server.toml"}
|
||||
},
|
||||
"invalid_range": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "server.max_players is 0: use 1 to 1000"}
|
||||
},
|
||||
"invalid_shape": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "world.seed: a value is one line of text, a number or true/false"}
|
||||
},
|
||||
"unknown": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "server.unknown is not a setting (`horizon-server config` lists them)"}
|
||||
},
|
||||
"patch_stopped": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"saved": ["server.max_players", "world.difficulty"],
|
||||
"applied": [],
|
||||
"restart": [],
|
||||
"running": false
|
||||
}
|
||||
},
|
||||
"patch_running": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"saved": ["backups.keep", "server.port"],
|
||||
"applied": ["backups.keep"],
|
||||
"restart": ["server.port"],
|
||||
"running": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
{
|
||||
"all": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"next": 3,
|
||||
"events": [
|
||||
{"id": 1, "line": "ready", "type": "log"},
|
||||
{"id": 2, "name": "Alex", "type": "join"},
|
||||
{"id": 3, "message": "hi", "name": "Alex", "type": "chat"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"since": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"next": 3,
|
||||
"events": [{"id": 3, "message": "hi", "name": "Alex", "type": "chat"}]
|
||||
}
|
||||
},
|
||||
"types": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"next": 3,
|
||||
"events": [{"id": 2, "name": "Alex", "type": "join"}]
|
||||
}
|
||||
},
|
||||
"last_event_id": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"next": 3,
|
||||
"events": [
|
||||
{"id": 2, "name": "Alex", "type": "join"},
|
||||
{"id": 3, "message": "hi", "name": "Alex", "type": "chat"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"bad_since": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {
|
||||
"next": 3,
|
||||
"events": [
|
||||
{"id": 1, "line": "ready", "type": "log"},
|
||||
{"id": 2, "name": "Alex", "type": "join"},
|
||||
{"id": 3, "message": "hi", "name": "Alex", "type": "chat"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"unknown_type": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"next": 3, "events": []}
|
||||
},
|
||||
"stream": {
|
||||
"status": 200,
|
||||
"content_type": "text/event-stream",
|
||||
"headers": [],
|
||||
"body": "id: 2\nevent: join\ndata: {\"id\":2,\"name\":\"Alex\",\"type\":\"join\"}\n\nid: 3\nevent: chat\ndata: {\"id\":3,\"message\":\"hi\",\"name\":\"Alex\",\"type\":\"chat\"}\n\n"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"op": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": true, "output": ["Made Alex a server operator"]}
|
||||
},
|
||||
"op_again": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": false, "output": ["Nothing changed. The player already is an operator"]}
|
||||
},
|
||||
"deop": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": true, "output": ["Made Alex no longer a server operator"]}
|
||||
},
|
||||
"deop_again": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": false, "output": ["Nothing changed. The player is not an operator"]}
|
||||
},
|
||||
"whitelist_add": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": true, "output": ["Added Alex to the whitelist"]}
|
||||
},
|
||||
"whitelist_add_again": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": false, "output": ["Player is already whitelisted"]}
|
||||
},
|
||||
"whitelist_on": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": true, "output": ["Whitelist is now turned on"]}
|
||||
},
|
||||
"whitelist_on_again": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": false, "output": ["Whitelist is already turned on"]}
|
||||
},
|
||||
"whitelist_remove": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": true, "output": ["Removed Alex from the whitelist"]}
|
||||
},
|
||||
"whitelist_remove_again": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"changed": false, "output": ["Player is not whitelisted"]}
|
||||
},
|
||||
"kick_stopped": {
|
||||
"status": 409,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the game isn't running right now"}
|
||||
},
|
||||
"ban_stopped": {
|
||||
"status": 409,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the game isn't running right now"}
|
||||
},
|
||||
"pardon_stopped": {
|
||||
"status": 409,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the game isn't running right now"}
|
||||
},
|
||||
"ban_ip_stopped": {
|
||||
"status": 409,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the game isn't running right now"}
|
||||
},
|
||||
"pardon_ip_stopped": {
|
||||
"status": 409,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the game isn't running right now"}
|
||||
},
|
||||
"malformed": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the body needs \"name\": a player's name (1 to 16 characters, no spaces)"}
|
||||
},
|
||||
"bad_name": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the body needs \"name\": a player's name (1 to 16 characters, no spaces)"}
|
||||
},
|
||||
"bad_reason": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "\"reason\" is one line of at most 256 characters"}
|
||||
},
|
||||
"bad_ip": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the body needs \"ip\": an IPv4 or IPv6 address"}
|
||||
},
|
||||
"bad_whitelist": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "the body needs \"enabled\": true or false"}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user