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:
Florian ERNSTandCodex committed 2026-11-05 05:19:07 +01:00
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();
+52 -4
View File
@@ -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, &macros);
let post = horizon_shaders::load_post(
&pack,
"",
&properties,
&macros,
&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
]
);
}
}
+144 -17
View File
@@ -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();
+3 -1
View File
@@ -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,
+223 -26
View File
@@ -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,
+75
View File
@@ -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 {
+12
View File
@@ -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)]
+44 -1
View File
@@ -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 [
+57 -10
View File
@@ -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)
}
+81 -8
View File
@@ -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]
+15 -5
View File
@@ -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()
+5
View File
@@ -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]
+7 -9
View File
@@ -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() {
+29 -26
View File
@@ -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 {
+3 -4
View File
@@ -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(),
+30 -3
View File
@@ -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 }),
}
}
}
+154 -5
View File
@@ -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);
}
+108 -1
View File
@@ -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);
+16 -12
View File
@@ -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
+14 -4
View File
@@ -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() {
+16 -6
View File
@@ -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],
+32
View File
@@ -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);
+2 -3
View File
@@ -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)
}
},
+1 -1
View File
@@ -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) {
+79 -13
View File
@@ -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;
+29 -5
View File
@@ -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)
);
}
}
+129 -44
View File
@@ -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, &macros, &inputs);
+21 -5
View File
@@ -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 {
+2 -2
View File
@@ -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);
+9 -4
View File
@@ -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);
}
+3 -1
View File
@@ -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);
}
}
+5
View File
@@ -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
+26 -1
View File
@@ -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
+11
View File
@@ -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,
+80 -1
View File
@@ -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],
+1
View File
@@ -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],
+9
View File
@@ -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(),
+4 -2
View File
@@ -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],
+39
View File
@@ -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);
}
}
+149 -9
View File
@@ -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
);
}
}
+59
View File
@@ -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) => {
+3
View File
@@ -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,
+46
View File
@@ -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]
+6 -16
View File
@@ -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))
+1 -1
View File
@@ -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) => {
+9 -6
View File
@@ -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()
+44 -2
View File
@@ -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).
+3
View File
@@ -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,
+2 -2
View File
@@ -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;
+139 -8
View File
@@ -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]
+287 -2
View File
@@ -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}");
}
+91 -31
View File
@@ -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]
+79
View File
@@ -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()));
+113
View File
@@ -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.
+112
View File
@@ -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);
+214
View File
@@ -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();
}
}
+100
View File
@@ -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);
+31 -3
View File
@@ -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;
+73
View File
@@ -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();
}
}
+9 -2
View File
@@ -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
View File
@@ -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
}
}
}
+70
View File
@@ -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"
}
}
+122
View File
@@ -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