Merge remote-tracking branch 'upstream/main' into feat/native-mob-submit

This commit is contained in:
Claude committed 2026-11-01 12:43:51 +00:00
commit 7a767d30db
128 files changed
+6941 -665

No files matched your search

Generated
+1
View File
@@ -1619,6 +1619,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
@@ -96,6 +96,11 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -259,6 +264,9 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
})
}
}
+185 -16
View File
@@ -88,6 +88,7 @@ mod frame_gen;
mod hdr;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
pub mod lightning;
mod lod;
mod native_material;
@@ -412,6 +413,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
/// Camera-relative sun and moon, or the animated End flash (additive,
/// behind everything and after the resident End sky box).
pub celestial: &'a [Vertex],
@@ -599,6 +603,8 @@ pub struct Renderer {
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
/// The layout of `entity_group`, for the native shadow pass.
entity_bindings: wgpu::BindGroupLayout,
/// The entity resources with the hand's projection (`hand_globals`).
hand_group: wgpu::BindGroup,
hand_globals: wgpu::Buffer,
@@ -607,6 +613,8 @@ pub struct Renderer {
entity_batch: EntityBatch,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
pbr_maps: Option<[wgpu::Texture; 2]>,
lightmap: wgpu::Texture,
/// The block sprites, `sprite_split` layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
@@ -699,6 +707,9 @@ pub struct Renderer {
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// The light sources of each section and their revision, for `block_light`.
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
light_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// The column `occlusion`, `sections` and `detail_columns` count in: the
@@ -924,6 +935,37 @@ pub struct PreparedTextures {
/// The sprite layers per array `sprites` is cut by.
split: u32,
forcefield: (Vec<u8>, u32),
/// The LabPBR maps' mip chains (normals, specular), their levels, layers and size.
pbr: Option<(Vec<u8>, Vec<u8>, u32, u32, u32)>,
}
/// Every level of `layers` square RGBA layers of `size`, each the mean of four texels of the
/// one above, layer major: the LabPBR maps' mips (their channels are data, not colour).
fn box_mip_chain(pixels: &[u8], size: u32, layers: u32) -> Vec<u8> {
let layer_bytes = (size * size * 4) as usize;
let mut out = Vec::new();
for layer in pixels.chunks_exact(layer_bytes).take(layers as usize) {
let mut level = layer.to_vec();
let mut side = size as usize;
out.extend_from_slice(&level);
while side > 1 {
let half = side / 2;
let mut next = Vec::with_capacity(half * half * 4);
for y in 0..half {
for x in 0..half {
for c in 0..4 {
let at = |dx: usize, dy: usize| {
u32::from(level[((2 * y + dy) * side + 2 * x + dx) * 4 + c])
};
next.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8);
}
}
}
out.extend_from_slice(&next);
level = next;
side = half;
}
}
out
}
impl PreparedTextures {
pub fn new(
@@ -956,9 +998,25 @@ impl PreparedTextures {
.collect(),
split,
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
pbr: None,
}
}
/// With the resource packs' LabPBR `normals` and `specular` maps (`layers` layers of
/// `size`, as `RenderAssets` holds them; nothing when empty).
pub fn with_pbr(mut self, normals: &[u8], specular: &[u8], size: u32, layers: u32) -> Self {
if !normals.is_empty() {
self.pbr = Some((
box_mip_chain(normals, size, layers),
box_mip_chain(specular, size, layers),
size.trailing_zeros() + 1,
layers,
size,
));
}
self
}
/// The same mip chains cut by `split` layers per array: chains made before
/// the adapter was known, for a GPU with fewer layers per array.
fn resplit(mut self, layers: u32, split: u32) -> Self {
@@ -1923,6 +1981,40 @@ impl Renderer {
};
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
// The resource packs' LabPBR maps, for native shader packs: one array each, cut to
// the layers the GPU has (`block_normal_map` gives the default past them).
let pbr_maps = prepared
.pbr
.map(|(normals, specular, levels, layers, size)| {
let kept = layers.min(device.limits().max_texture_array_layers);
let count = array_layers(kept, gl);
[("LabPBR normals", normals), ("LabPBR specular", specular)].map(
|(label, mut data)| {
// The extra layers are empty.
let layer_bytes = data.len() / layers as usize;
data.resize(layer_bytes * count as usize, 0);
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
},
)
});
let sprite_textures: Vec<wgpu::Texture> = prepared
.sprites
.into_iter()
@@ -3160,6 +3252,7 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
entity_bindings,
hand_group,
hand_globals,
entity_buffer,
@@ -3167,6 +3260,7 @@ impl Renderer {
entity_batch: EntityBatch::default(),
group,
globals,
pbr_maps,
lightmap,
sprite_textures,
sprite_unused,
@@ -3224,6 +3318,8 @@ impl Renderer {
block_entities: Default::default(),
section_uploaded: Default::default(),
block_entity_revision: next_block_entity_revision(),
light_blocks: Default::default(),
light_revision: 0,
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
@@ -3472,6 +3568,11 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
self.native.as_ref().is_some_and(|n| n.shadow.is_some())
}
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
/// entities other than the player, so those Horizon's culling hides from the camera are
/// kept for it; the local player, so its model is built in first person too). (false,
@@ -3504,6 +3605,15 @@ impl Renderer {
if changed {
self.block_entity_revision = next_block_entity_revision();
}
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
} else {
let old = self.light_blocks.insert(key, mesh.lights.clone());
old.as_deref() != Some(&mesh.lights[..])
};
if lights_changed {
self.light_revision += 1;
}
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
@@ -3883,6 +3993,9 @@ impl Renderer {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if self.light_blocks.remove(&key).is_some() {
self.light_revision += 1;
}
self.section_uploaded.remove(&key);
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
@@ -4182,9 +4295,11 @@ impl Renderer {
horizon_shader_api::Report,
Vec<(String, f64)>,
Vec<(u32, u32)>,
Vec<(u32, u32)>,
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let Some((report, constants, sprite_tags)) = pack else {
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
};
@@ -4194,9 +4309,19 @@ impl Renderer {
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
pbr: self.pbr_maps.as_ref(),
};
let (pack, labels) =
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
let (pack, labels) = native_pack::NativePack::new(
&self.device,
&self.queue,
&terrain,
report,
&constants,
lights,
)?;
self.native = pack.draws().then_some(pack);
if self.native.is_some() {
self.pack_post = None;
@@ -5465,6 +5590,7 @@ impl Renderer {
frame.see_through,
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -5479,7 +5605,8 @@ impl Renderer {
// 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 cloud_quads = if let Some(input) = &frame.clouds {
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) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
let vertices = self.cloud_mesh.vertices.len() as u64;
@@ -5521,6 +5648,7 @@ impl Renderer {
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
@@ -5566,15 +5694,16 @@ impl Renderer {
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
);
}
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
let max_quads =
(world_quads + additive_quads + hand_quads + see_through_quads + shadow_only_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -5859,6 +5988,12 @@ impl Renderer {
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
native.frame.write(&self.queue, frame, size);
native.frame.block_light.update(
&self.queue,
frame.camera,
&self.light_blocks,
self.light_revision,
);
if let Some(t) = &mut native.timing {
t.begin(&self.device, &self.queue);
native_sky_pass =
@@ -5927,6 +6062,22 @@ impl Renderer {
shadow_translucent,
);
}
drop(pass);
if entity_quads > 0 || shadow_only_quads > 0 {
let mut pass = native
.frame
.shadow_map
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
// At the end of the buffer (`EntityBatch::append_shadow_only`).
let first = entity_vertices as u32 - shadow_only_quads * 4;
pass.draw_indexed(0..shadow_only_quads * 6, first as i32, 0..1);
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
@@ -6160,12 +6311,24 @@ impl Renderer {
self.end_sky.draw(&mut pass);
}
if celestial_vertices > 0 && !pack_draws_celestial {
// The Overworld's bodies start with the sun's quad
// (`Game::celestial_geometry`): a pack sky drawing its own skips it.
let own_sun = !frame.end_sky
&& self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_sun);
let first = if own_sun { 6 } else { 0 };
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
pass.draw_indexed(first..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
let own_stars = self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
self.stars.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
@@ -6649,7 +6812,13 @@ impl Renderer {
// terrain and clouds, so the water behind the rain is drawn first.
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
let native_weather = self
.native
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.weather.as_ref()?, &n.frame.group)));
self.weather
.draw(&mut pass, &self.entity_group, native_weather);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer: the world border after translucent terrain and weather.
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
@@ -79,6 +79,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -79,26 +87,52 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the maps: their own texels, as the sampler would pick.
let texels = in.uv * horizon_block_pbr_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -115,6 +149,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -134,12 +178,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -150,18 +196,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -174,14 +226,16 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -346,14 +400,20 @@ mod tests {
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -14,6 +14,11 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
@@ -27,20 +32,23 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref());
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|_| NativeShadow::new(device, terrain, &frame))
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
@@ -55,6 +63,34 @@ impl NativePack {
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -67,10 +103,10 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -79,6 +115,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -87,6 +124,10 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
@@ -95,6 +136,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
@@ -16,9 +16,59 @@ const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
enum Writes {
/// One of the chain's two targets.
Chain(usize),
/// A pack target (`[[post]] output`).
Target(usize),
/// The screen: the last pass.
Screen,
}
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
/// none).
#[derive(Clone, Copy)]
struct PassPlan {
writes: Writes,
reads: Option<usize>,
}
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
/// output writes that and reads the chain's picture, which goes on past it.
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
let mut current = None;
outputs
.iter()
.enumerate()
.map(|(i, output)| {
let reads = current;
let writes = match output {
_ if i + 1 == outputs.len() => Writes::Screen,
Some(t) => Writes::Target(*t),
None => {
let slot = usize::from(current == Some(0));
current = Some(slot);
Writes::Chain(slot)
}
};
PassPlan { writes, reads }
})
.collect()
}
/// A pack target's size: `scale` of the world's `size`, at least a texel.
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
}
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
/// The pack's textures.
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
@@ -40,6 +90,12 @@ pub struct NativePost {
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// What each pass writes and reads (`[[post]] output`).
plan: Vec<PassPlan>,
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
/// size.
scales: Vec<f32>,
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -50,6 +106,10 @@ pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
/// The pack's `[[textures]]` and their samplers, bound for every stage.
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
pub block_light: super::block_light::BlockLight,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
@@ -57,6 +117,9 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
@@ -151,10 +214,14 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any.
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -162,19 +229,46 @@ impl NativeFrame {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries = vec![
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
pbr_entry(12),
pbr_entry(13),
];
let block_light_entries = [
wgpu::BindGroupLayoutEntry {
binding: 9,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 10,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
entries.extend(block_light_entries);
entries.extend(pack_texture_entries(textures.len()));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &entries,
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
@@ -192,16 +286,49 @@ impl NativeFrame {
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
})
};
let pbr = match pbr {
Some(maps) => maps.each_ref().map(array_view),
None => {
// Two layers: one would make a 2D texture on the OpenGL backend.
let blank = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack LabPBR (none)"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
[array_view(&blank), array_view(&blank)]
}
};
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&shadow_map,
&opaque_depth,
&opaque_color,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
@@ -241,54 +368,74 @@ impl NativeFrame {
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
shadow: &super::native_shadow::ShadowMap,
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
textures: &[(wgpu::TextureView, wgpu::Sampler)],
block_light: &super::block_light::BlockLight,
pbr: &[wgpu::TextureView; 2],
) -> wgpu::BindGroup {
let opaque_depth = opaque_depth.create_view(&Default::default());
let opaque_color = opaque_color.create_view(&Default::default());
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&opaque_depth),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(&opaque_color),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&pbr[0]),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::TextureView(&pbr[1]),
},
];
entries.extend([
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&block_light.view),
},
wgpu::BindGroupEntry {
binding: 10,
resource: block_light.grid.as_entire_binding(),
},
]);
entries.extend(pack_texture_group_entries(textures));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -338,8 +485,10 @@ impl NativeFrame {
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.shadow_map,
&self.opaque_depth,
&self.opaque_color,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -437,6 +586,20 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -461,26 +624,43 @@ impl NativePost {
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
manifest: &horizon_shader_api::Manifest,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
.post
.iter()
.map(|p| {
let name = p.output.as_deref()?;
targets.iter().position(|t| t.0 == name)
})
.collect();
let plan = plan(&outputs);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut entries = vec![
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -519,7 +699,11 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
format: if plan[i].writes == Writes::Screen {
format
} else {
INTERMEDIATE
},
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -543,6 +727,7 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
@@ -557,6 +742,9 @@ impl NativePost {
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
scales: targets.iter().map(|t| t.1).collect(),
named: None,
plan,
format,
})
}
@@ -573,7 +761,39 @@ impl NativePost {
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
.iter()
.map(|&scale| {
let size = scaled(size, scale);
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack target"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
})
.collect();
self.named = Some((size, views));
self.groups = None;
}
let chain = self
.plan
.iter()
.any(|p| matches!(p.writes, Writes::Chain(_)));
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -624,62 +844,79 @@ impl NativePost {
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
],
entries: &entries,
})
})
.collect();
@@ -706,8 +943,9 @@ impl NativePost {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -729,14 +967,17 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
.map(|p| p.writes)
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
targets[slot].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
@@ -871,8 +1112,125 @@ pub fn frame_inputs(
}
}
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
pub fn pack_textures(
device: &wgpu::Device,
queue: &wgpu::Queue,
report: &horizon_shader_api::Report,
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
report
.textures
.iter()
.map(|(t, image)| {
let texture = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: Some(&t.id),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
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: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&image.rgba,
);
let filter = if t.linear {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
};
let address = if t.repeat {
wgpu::AddressMode::Repeat
} else {
wgpu::AddressMode::ClampToEdge
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&t.id),
address_mode_u: address,
address_mode_v: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
});
(texture.create_view(&Default::default()), sampler)
})
.collect()
}
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_SAMPLER_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
]
})
.collect()
}
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
.flat_map(|(i, (view, sampler))| {
[
wgpu::BindGroupEntry {
binding: TEXTURE_BINDING + i as u32,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: TEXTURE_SAMPLER_BINDING + i as u32,
resource: wgpu::BindingResource::Sampler(sampler),
},
]
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
use super::{Writes, plan};
// Bloom into a target, a pass of the chain, a second target, the picture.
let p = plan(&[Some(0), None, Some(1), None, None]);
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
assert!(writes[0] == Writes::Target(0));
assert!(writes[1] == Writes::Chain(0));
assert!(writes[2] == Writes::Target(1));
assert!(writes[3] == Writes::Chain(1));
assert!(writes[4] == Writes::Screen);
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
}
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
@@ -934,7 +1292,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -943,6 +1301,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -1053,8 +1412,16 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
@@ -1,5 +1,5 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
@@ -56,7 +56,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@@ -67,6 +67,40 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
@@ -191,6 +225,25 @@ impl ShadowMap {
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
@@ -198,7 +251,7 @@ impl ShadowMap {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
@@ -261,14 +314,106 @@ pub struct NativeShadow {
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries.
fn shader_source() -> String {
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
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,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
@@ -283,8 +428,9 @@ impl NativeShadow {
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source();
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
@@ -420,6 +566,7 @@ impl NativeShadow {
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
@@ -428,6 +575,7 @@ impl NativeShadow {
flat,
translucent,
flat_translucent,
entity,
group,
})
}
@@ -439,7 +587,29 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
let text = shader_source();
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -454,6 +624,7 @@ mod tests {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
@@ -474,6 +645,7 @@ mod tests {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
+169 -2
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -84,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -138,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+17 -1
View File
@@ -364,10 +364,16 @@ pub struct Graphics {
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -424,10 +430,12 @@ impl Default for Graphics {
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -490,10 +498,14 @@ impl Graphics {
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -548,7 +560,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -556,10 +568,12 @@ impl Graphics {
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -709,10 +723,12 @@ mod tests {
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -33,6 +33,7 @@ pub enum H {
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -261,6 +262,11 @@ pub fn rows(level: Level) -> Vec<Row> {
Low,
"Animates water, lava, fire and other moving textures.",
),
h(
H::DitherCutoutMips,
Low,
"Keeps fine plants in high-res packs from fading far away.",
),
Row::Header("Display"),
fov,
fps,
@@ -525,6 +531,7 @@ impl H {
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
@@ -646,6 +653,7 @@ impl H {
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
H::Antialiasing => {
@@ -733,6 +741,7 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
+17
View File
@@ -544,6 +544,17 @@ impl HudAtlas {
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
@@ -567,6 +578,12 @@ impl HudAtlas {
format!("{namespace}:{rest}")
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
@@ -231,6 +231,9 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Blocks that give off light (world position, state id), for the colour of block light
/// Horizon's shader packs read (`gfx::block_light`).
pub lights: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
@@ -248,6 +251,7 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
@@ -1124,6 +1128,10 @@ impl Mesher<'_> {
out.block_entities
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.emission > 0 {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
+123
View File
@@ -38,6 +38,21 @@ pub struct Settings {
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub item: bool,
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
pub dither_cutouts: bool,
}
/// The `ditherCutoutMips` setting, for textures loaded from now on.
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
pub fn set_dither_cutouts(on: bool) -> bool {
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
}
/// The `ditherCutoutMips` setting.
pub fn dither_cutouts() -> bool {
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
}
/// A square or rectangular RGBA8 image, rows top to bottom.
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
}
out.push(data);
}
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
dither_cutout_levels(&mut out);
}
out
}
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
const DITHERED_CUTOUT_SIZE: u32 = 64;
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
/// its texels are in or out, about as many in as the base has.
fn dither_cutout_levels(levels: &mut [Image]) {
const BAYER: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
let mut alpha: Vec<f32> = levels[0]
.pixels
.iter()
.map(|p| f32::from(p[3]) / 255.0)
.collect();
for level in &mut levels[1..] {
let (nw, nh) = (level.width as usize, level.height as usize);
let mut next = vec![0.0; nw * nh];
for y in 0..nh {
for x in 0..nw {
let at = |dx: usize, dy: usize| {
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
};
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
next[y * nw + x] = mean;
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
}
}
alpha = next;
(w, h) = (nw, nh);
}
}
#[cfg(test)]
mod dither_tests {
use super::*;
#[test]
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
let cutout = |side: u32| Image {
width: side,
height: side,
pixels: (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect(),
};
let settings = |dither_cutouts| Settings {
has_transparent: true,
dither_cutouts,
..Settings::default()
};
assert_ne!(
generate(cutout(128), 4, settings(false)),
generate(cutout(128), 4, settings(true))
);
assert_eq!(
generate(cutout(32), 4, settings(false)),
generate(cutout(32), 4, settings(true))
);
}
#[test]
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
// Blades one texel wide, one column in four: too fine for the mips to hold.
let side = 128u32;
let pixels = (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect();
let base = Image {
width: side,
height: side,
pixels,
};
let settings = Settings {
has_transparent: true,
dither_cutouts: true,
..Settings::default()
};
let mips = generate(base, 4, settings);
for level in &mips[3..] {
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
let coverage = passing / level.pixels.len() as f32;
assert!((0.15..0.35).contains(&coverage), "{coverage}");
}
}
}
+116 -43
View File
@@ -1236,8 +1236,9 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack.
NativePackOptions(usize),
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
/// first the one the button opens.
NativePackOptions(usize, u8),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1638,25 +1639,58 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
/// The options of the Horizon pack `s.shader_packs[i]`, two a row.
fn native_pack_option_rows(s: &Settings, i: usize) -> Vec<Row> {
/// A list's slot with nothing in it: text takes no clicks.
fn blank_slot(w: f32) -> Widget {
Widget {
x: 0.0,
y: 0.0,
w,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
list: false,
row: 0,
}
}
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
/// else every option two a row.
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
use horizon_shader_api::PageItem;
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let mut widgets = pack
.options
.iter()
.zip(&pack.values)
.enumerate()
.map(|(o, (option, v))| {
let value = crate::shader_pack_list::show_value(&option.kind, *v);
let count = pack.options.len().min(pack.values.len());
let (columns, items) = match pack.pages.get(usize::from(at)) {
Some(page) => (usize::from(page.columns), page.items.clone()),
None => (2, (0..count).map(PageItem::Option).collect()),
};
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &PageItem| match *item {
PageItem::Option(o) if o < count => {
let option = &pack.options[o];
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, 150.0, label, Act::NativePackOption(i, o))
});
let mut rows = vec![Row::Note(pack.title.clone())];
while let Some(a) = widgets.next() {
rows.push(Row::Buttons(a, widgets.next()));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
let page = Page::NativePackOptions(i, p as u8);
button(0.0, 0.0, width, label, Act::Push(page))
}
_ => blank_slot(width),
};
let mut rows = Vec::new();
if at == 0 {
rows.push(Row::Note(pack.title.clone()));
}
rows.extend(
items
.chunks(columns)
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
);
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
@@ -1667,19 +1701,7 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
x: 0.0,
y: 0.0,
w: 150.0,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
list: false,
row: 0,
};
let blank = || blank_slot(150.0);
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
use horizon_shaders::ScreenItem;
@@ -1744,13 +1766,25 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
if let WKind::Button {
act: Act::NativePackOption(pack, option),
..
} = &wd.kind
{
let option = s.shader_packs.get(*pack)?.options.get(*option)?;
return (!option.description.is_empty()).then(|| Text::literal(&option.description));
let pack_text = match &wd.kind {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
} => Some(
&s.shader_packs
.get(*pack)?
.pages
.get(usize::from(*at))?
.description,
),
_ => None,
};
if let Some(text) = pack_text {
return (!text.is_empty()).then(|| Text::literal(text));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
@@ -2095,6 +2129,14 @@ impl OptionsUi {
_ => None,
}
}
/// The name of a Horizon pack's options page past its first, as the page's title.
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
let Page::NativePackOptions(pack, at @ 1..) = page else {
return None;
};
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
Some(Text::literal(&page.name))
}
fn title(page: Page) -> &'static str {
match page {
Page::Main => "options.title",
@@ -2113,7 +2155,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2572,7 +2614,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -3024,7 +3066,8 @@ impl OptionsUi {
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
text: Self::pack_page_title(s, page)
.unwrap_or_else(|| tr(Self::title(page))),
color: u32::MAX,
centered: true,
},
@@ -3109,7 +3152,9 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i) => Some(("controls.reset", Act::NativePackReset(i))),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3906,7 +3951,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -4829,7 +4874,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -5153,6 +5198,22 @@ mod tests {
},
}],
values: vec![1.0],
pages: vec![
horizon_shader_api::OptionPage {
id: "main".into(),
name: "P".into(),
description: String::new(),
columns: 2,
items: vec![horizon_shader_api::PageItem::Page(1)],
},
horizon_shader_api::OptionPage {
id: "more".into(),
name: "More".into(),
description: String::new(),
columns: 1,
items: vec![horizon_shader_api::PageItem::Option(0)],
},
],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
@@ -5166,7 +5227,11 @@ mod tests {
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0));
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
// The option is on the pack's second page, behind a button on its first.
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
@@ -5174,9 +5239,17 @@ mod tests {
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
ui.shift = false;
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.back(&mut s);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -332,6 +332,7 @@ impl Settings {
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, PackOption};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -26,6 +26,8 @@ pub struct Entry {
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
/// Its options page's `[[pages]]`; empty for one page of every option.
pub pages: Vec<OptionPage>,
}
impl Entry {
@@ -88,8 +90,10 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let (title, options) =
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
file,
@@ -97,6 +101,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -109,6 +114,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
+244 -41
View File
@@ -85,18 +85,93 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Still textures by their pixels and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
pub specular: Vec<u8>,
pub pbr_size: u32,
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
/// Largest sprite array (all layers of the top mip level): resource packs
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
/// Horizon: resource packs with bigger textures are sampled down to fit.
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
/// resources loaded from now on.
pub fn set_texture_budget(mib: u32) {
let bytes = match mib {
0 => DEFAULT_TEXTURE_BUDGET,
mib => u64::from(mib) << 20,
};
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
/// square RGBA layers take at most `bytes`.
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
size /= 2;
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
@@ -156,7 +231,7 @@ impl Sprites {
}
/// Lay every layer out at one size: the largest texture's (resource packs
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
@@ -170,13 +245,18 @@ impl Sprites {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
let size = side_within(largest, layers, budget);
if pixels {
log::info!(
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
budget >> 20
);
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.pbr = Vec::new();
self.size = size;
return;
}
@@ -192,6 +272,30 @@ impl Sprites {
animation.frames = resized[animation.layer as usize].clone();
}
self.size = size;
self.normals.clear();
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
}
}
}
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
@@ -222,6 +326,7 @@ impl Sprites {
.collect();
let layer = u32::try_from(self.native.len())?;
self.opaque.push(true);
self.pbr.push([None, None]);
self.translucent.push(false);
self.mips.push(crate::mipmap::Settings::default());
self.layers.insert(name.to_owned(), layer);
@@ -242,7 +347,7 @@ impl Sprites {
decoded
}
};
self.register(name, decoded)
self.register(root, name, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
@@ -263,40 +368,19 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
(u32::try_from(width)?, u32::try_from(height)?)
} else {
(info.width, info.height)
(png_width, png_height)
};
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
return Err(format!("sprite {name} has invalid frame dimensions").into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
let columns = info.width / width;
let count = columns * (info.height / height);
let columns = png_width / width;
let count = columns * (png_height / height);
// Layers are square; a frame of another shape is nearest-resampled to
// its longer side so UVs (normalized per layer) are unchanged.
let side = width.max(height);
@@ -309,7 +393,7 @@ impl Sprites {
let source_row = y + row * height / side;
for column in 0..side {
let offset =
((source_row * info.width + x + column * width / side) * 4) as usize;
((source_row * png_width + x + column * width / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
@@ -339,7 +423,12 @@ impl Sprites {
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
fn register(
&mut self,
root: &Path,
name: &str,
decoded: crate::sprite_cache::Decoded,
) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
@@ -356,6 +445,13 @@ impl Sprites {
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
};
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
@@ -364,10 +460,15 @@ impl Sprites {
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
side,
[
settings.strategy as u32,
@@ -401,12 +502,66 @@ impl Sprites {
self.mips.push(settings);
self.opaque.push(opaque);
self.translucent.push(translucent);
self.pbr.push(pbr);
self.layers.insert(name.to_owned(), layer);
self.native.push((side, frames, initial));
Ok(layer)
}
}
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -452,6 +607,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
mod tests {
use super::*;
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
// Never under vanilla's size, whatever the budget.
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
[200, 100, 0, 255],
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[None, None],
],
..Default::default()
};
sprites.finish();
assert_eq!(sprites.pbr_size, 16);
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
assert_eq!(sprites.specular[..4], NO_SPECULAR);
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
pbr: vec![[None, None]],
..Default::default()
};
sprites.finish();
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
@@ -133,7 +133,7 @@ fn main() -> Result<()> {
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
properties: properties.into(),
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
.collect();
BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
}
}
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let (facts, gameplay) =
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
+20 -13
View File
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
}
}
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
pub fn collected_components(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
collected
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
for (component, value) in collected_components(saved) {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
+225 -71
View File
@@ -198,6 +198,7 @@ mod native_lifecycle;
mod native_menus;
mod native_mob_draw;
mod native_mobs;
mod native_motion;
mod native_net;
mod native_panel;
mod native_panel_recipes;
@@ -1649,6 +1650,8 @@ struct FallingBlock {
age: i32,
/// `Entity.isNoGravity`.
no_gravity: bool,
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
portal: horizon_client::nether_portal::PortalState,
/// `FallingBlockEntity.dropItem`.
drop_item: bool,
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
@@ -2228,6 +2231,8 @@ struct Game<'a> {
luminance: Option<horizon_client::dynamic_light::Luminance>,
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
see_through_vertices: Vec<Vertex>,
/// The first-person player's model, which only a native pack's shadow pass draws.
shadow_only_vertices: std::cell::RefCell<Vec<Vertex>>,
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
@@ -2247,6 +2252,9 @@ struct Game<'a> {
/// The glint clock and options, read once at the start of each frame
/// (game/item_draw.rs).
glint_frame: horizon_client::item_render::GlintFrame,
/// Mod block entities' geometry their mods kept
/// (game/native_block_entity_draw.rs).
kept_block_entities: std::cell::RefCell<native_block_entity_draw::KeptGeometry>,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
@@ -2334,6 +2342,10 @@ struct Game<'a> {
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
/// it was by connection (None: the host).
last_hurt_by_player: HashMap<u32, Option<u32>>,
/// Leashed mobs a player killed, with the killer's UUID: the lead drops
/// on the corpse's next tick (`Leashable.tickLeash`), for the mods. With
/// the corpse's dimension: each level's tick prunes only its own.
lead_killers: HashMap<u32, (u128, Dim)>,
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
/// connection (None: the host). Breeding credits them, wherever they are.
love_cause: HashMap<u32, Option<u32>>,
@@ -2850,6 +2862,56 @@ impl Dim {
}
}
}
/// `Inventory.add` on a player's 36 slots (hotbar first) and off hand,
/// `max` the stack's own maximum: onto equal stacks first
/// (`getSlotWithRemainingSpace`: the selected slot, the off hand, then the
/// hotbar and the main inventory), then into empty slots of the inventory
/// (`getFreeSlot`, never the off hand). What does not fit stays in `stack`;
/// true when some of it is left.
fn inventory_add(
inventory: &mut [Option<ItemStack>],
offhand: &mut Option<ItemStack>,
selected: usize,
max: u32,
stack: &mut ItemStack,
) -> bool {
let order = std::iter::once(selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != selected));
for i in order {
let slot = if i == shield::OFFHAND_SLOT {
&mut *offhand
} else {
&mut inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
for slot in inventory.iter_mut().take(36) {
if slot.is_none() {
let moved = stack.count.min(max);
*slot = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
true
}
fn facing_offset(facing: &str) -> [i32; 3] {
match facing {
"north" => [0, 0, -1],
@@ -3241,57 +3303,23 @@ impl Game<'_> {
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
)
.then(|| stack.clone());
let mut added = false;
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
for i in 0..36 {
if self.inventory[i].is_none() {
let moved = stack.count.min(max);
self.inventory[i] = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
if added {
let before = stack.count;
let left = inventory_add(
&mut self.inventory,
&mut self.offhand,
self.selected,
max,
&mut stack,
);
if stack.count < before {
self.tutorial_get_item(tutorial_item.as_ref());
}
Some(stack)
left.then_some(stack)
}
/// The block's first up face (or any face) and its fixed tint, for block
/// models drawn as entities (falling blocks, carried blocks).
@@ -3544,6 +3572,10 @@ impl Game<'_> {
// Block.popResource: Mth.nextDouble offsets from the level random; the
// motion is `new ItemEntity(..)`'s own, from the item's random.
let p = spill::pop_position(&mut self.world.random, pos);
// Block.popResource draws its offsets before the private overload checks the gamerule.
if !self.ui.rules.bool("block_drops") {
return;
}
let v = dispenser::new_item_motion();
self.spawn_item(stack, p, v, 10);
}
@@ -4701,6 +4733,7 @@ impl Game<'_> {
velocity: Vec3::ZERO,
age: 0,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
@@ -5046,7 +5079,7 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
if !f.no_gravity {
@@ -5092,30 +5125,11 @@ impl Game<'_> {
f.position.z + movement.z,
);
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
// FallingBlockEntity.tick: off the ground, it gives up after 600
// ticks (100 outside the world) and drops itself as an item.
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if next.y < f.position.y {
f.fall_distance += f.position.y - next.y;
}
let from = f.position;
f.position = next;
if on_ground {
let pos = [
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
];
return Ok(FallStep::Landed(pos));
}
// Entity.move stops it on the axes it collided on.
if movement.x != wanted.x {
f.velocity.x = 0.0;
@@ -5126,6 +5140,10 @@ impl Game<'_> {
if movement.z != wanted.z {
f.velocity.z = 0.0;
}
if on_ground {
self.falling_block_impact(f);
f.fall_distance = 0.0;
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
// drags it. Entity.move records the move unless it was blocked short
@@ -5157,12 +5175,44 @@ impl Game<'_> {
if stuck {
f.stuck = horizon_client::falling_block::web_multiplier();
}
// FallingBlockEntity.tick handles its portal after the move's block
// effects, before placing/dropping on the floor under an End portal.
let teleported = self.falling_through_portal(f);
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
// either way through the End deliberately keeps the source's final
// placement/drop branch, even though the source entity was removed.
if let Some(target) = teleported
&& self.dim != Dim::End
&& target != Dim::End
{
return Ok(FallStep::Gone);
}
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if on_ground {
return Ok(FallStep::Landed([
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
]));
}
f.velocity = Vec3::new(
f.velocity.x * 0.98,
f.velocity.y * 0.98,
f.velocity.z * 0.98,
);
Ok(FallStep::Falling)
Ok(if teleported.is_some() {
FallStep::Gone
} else {
FallStep::Falling
})
}
/// `Entity.spawnAtLocation`: an item entity where the block was.
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
@@ -5314,6 +5364,9 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
}
if creative && storage::is_shulker_box(&facts.name) {
self.drop_creative_shulker(pos);
}
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
// with its bees and honey level.
if creative
@@ -7682,7 +7735,12 @@ impl Game<'_> {
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
// moved to (x, y): the damage bar, the cooldown shade and the count.
let info = self.items().get(&stack.item);
let max_damage = info.map_or(0, |info| info.max_damage as i32);
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
info.map_or(0, |info| info.max_damage as i32)
} else {
0
};
let cooldown =
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
let partial = self.clock.fraction() as f32;
@@ -10180,7 +10238,15 @@ impl Game<'_> {
self.mob_geometry(camera, alpha, &mut out);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
// the first-person model.
if self.pack_ids.is_none() && !self.own_body_in_view() {
self.shadow_only_vertices
.borrow_mut()
.extend(out.drain(player_start..));
}
self.mark_entity(out.len(), "minecraft:player");
self.guest_model_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
@@ -10195,9 +10261,11 @@ impl Game<'_> {
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
let shadow_sprite = self
.settings
.entity_shadows
// A native shader pack's sun shadows already fall under the entities in the Overworld:
// the round blob would be a second one. The Nether and the End have no sun to cast them.
let pack_shadows =
self.dim == Dim::Overworld && self.renderer.as_ref().is_some_and(|r| r.native_shadow());
let shadow_sprite = (self.settings.entity_shadows && !pack_shadows)
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
@@ -11769,6 +11837,7 @@ impl Game<'_> {
self.boss_overlay.reset();
// GameRenderer.resetData (Minecraft.clearClientLevel).
self.item_activation.reset();
self.kept_block_entities.get_mut().clear();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
@@ -14654,6 +14723,7 @@ impl Game<'_> {
entities.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
self.shadow_only_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
@@ -14734,6 +14804,7 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -14826,6 +14897,7 @@ impl Game<'_> {
},
additive: &additive,
see_through: &see_through,
shadow_only: &shadow_only,
celestial: if self.settings.horizon.sky || title {
&celestial
} else {
@@ -14893,6 +14965,7 @@ impl Game<'_> {
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
self.see_through_vertices = see_through;
result
}
@@ -15895,6 +15968,63 @@ impl Game<'_> {
}
return;
}
// ScreenEvent.KeyPressed.Pre: the open screen hears of the key before
// it acts on it, and a cancelled press goes no further — no focus
// move, no Escape-close, no inventory or hotbar key, no typing by the
// key itself, no `native_key`. It fires after the window's own
// shortcuts (fullscreen, screenshot, the debug keys, the narrator) so
// those keep working with a mod that cancels; the key's release still
// comes through.
//
// A key's characters are a separate callback of GLFW's, and it comes
// after the key callback (`KeyboardHandler.charTyped`): they are taken
// off the key here and dispatched once the screen has acted on it, on
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
// Options keeps its text on the key: its own key path routes a key's
// text between its boxes and its key-bind capture, and no key that
// carries text closes that screen or moves its focus.
let mut typed: Option<String> = None;
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &mut event
&& key_event.state == ElementState::Pressed
{
if self.screen_hooks_open() && self.screen != Screen::Options {
typed = key_event.text.take().map(|text| text.to_string());
}
let canceled = match key_event.physical_key {
PhysicalKey::Code(key) => self.screen_key_pressed(key, key_event.repeat),
PhysicalKey::Unidentified(_) => false,
};
if self.screen == Screen::Options && self.screen_hooks_open() {
// The hook after the key's own, then the text the key path
// types in place (its own routing keeps it).
if let Some(text) = key_event.text.as_deref() {
let left = self.screen_text_typed(text);
if left.as_deref() != Some(text) {
key_event.text = left.map(Into::into);
}
}
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
// characters already passed the hook in place.
if let Some(text) = typed.as_deref() {
self.screen_chars_typed(Some(text));
} else if self.screen == Screen::Options
&& let Some(text) = key_event.text.as_deref()
&& self.types_text()
{
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if !text.is_empty() {
// Err only says no box of the screen takes it.
let _ = self.type_chars_into_screen(&text);
}
}
return;
}
}
// Screen.keyPressed: Tab, the arrows and Enter move and press the
// menu's focus, unless the focused widget or the screen keeps them.
if let WindowEvent::KeyboardInput {
@@ -15904,6 +16034,9 @@ impl Game<'_> {
&& let PhysicalKey::Code(key) = key_event.physical_key
&& self.key_focus_key(event_loop, key)
{
// The keyboard's focus took the key; its characters are a separate
// callback and still come (`KeyboardHandler.charTyped`).
self.screen_chars_typed(typed.as_deref());
return;
}
let result: Result<()> = (|| {
@@ -16469,7 +16602,9 @@ impl Game<'_> {
if let PhysicalKey::Code(key) = event.physical_key
&& self.search_tab_hotbar_key(key)
{
// The hovered slot took the key (`ignoreTextInput`).
// The hovered slot took the key, and its characters
// go nowhere (`ignoreTextInput`).
typed = None;
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if let PhysicalKey::Code(key) = event.physical_key {
@@ -16911,6 +17046,10 @@ impl Game<'_> {
Ok(())
})();
// ScreenEvent.CharacterTyped.Pre: GLFW's char callback comes after its
// key callback, so a key's characters reach the screen the key left
// open, after the key's own effects (`KeyboardHandler.charTyped`).
self.screen_chars_typed(typed.as_deref());
if let Err(e) = result {
self.error = Some(e);
self.exit_on_error(event_loop);
@@ -17164,6 +17303,7 @@ impl<'a> Game<'a> {
dynamic_lights: Vec::new(),
luminance: None,
see_through_vertices: Vec::new(),
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
@@ -17175,6 +17315,7 @@ impl<'a> Game<'a> {
pad: Default::default(),
frames: 0,
glint_frame: Default::default(),
kept_block_entities: Default::default(),
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
@@ -17230,6 +17371,7 @@ impl<'a> Game<'a> {
attack_ticks: 100,
attack_item: None,
last_hurt_by_player: HashMap::new(),
lead_killers: HashMap::new(),
love_cause: HashMap::new(),
died: Default::default(),
be_anims: {
@@ -18139,7 +18281,7 @@ fn gameplay_tables(
))
.map_err(|e| e.to_string())?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json)
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
.map_err(|e| e.to_string())?;
horizon_client::block_contact::set_mod_blocks(
facts
@@ -18181,6 +18323,8 @@ fn load_resources(
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
let threads = settings.horizon.worker_threads();
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
// published at once so the title screen can appear while the world bundle
// loads; the synchronous launch instead carries it in `Loaded`.
@@ -18529,6 +18673,12 @@ fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::Prepa
&assets.sprite_mips,
(&pack.forcefield.2, pack.forcefield.0),
)
.with_pbr(
&assets.pbr_normals,
&assets.pbr_specular,
assets.pbr_size,
assets.texture_layers,
)
}
/// Upload pack textures and create the renderer on the window's device.
@@ -18551,6 +18701,8 @@ fn build_renderer(
// Chinese and Japanese menu labels go blank when the loading screen hands
// over to the game.
atlas.font.reset_cache();
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -18710,6 +18862,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
@@ -19988,6 +20141,7 @@ mod tests {
velocity: Vec3::ZERO,
age: 5,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
@@ -152,6 +152,31 @@ impl Game<'_> {
Some(stack)
}
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
/// one item at the block center, with collectComponents and the default pickup delay.
/// This direct spawn intentionally does not use the block_drops gamerule.
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
if items.iter().flatten().any(|s| s.count > 0));
if !filled {
return;
}
let Some(saved) = self.saved_block_entity(key) else {
return;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
let patch = block_entity_item::collected_components(&saved);
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
self.spawn_item(
stack,
pos.map(|v| f64::from(v) + 0.5),
super::dispenser::new_item_motion(),
10,
);
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
+4 -1
View File
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
properties.insert(k.to_owned(), v.to_owned());
}
Ok(BlockState { name, properties })
Ok(BlockState {
name,
properties: properties.into(),
})
}
impl Game<'_> {
+18 -5
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
if brushable && self.brush_block(hit.pos, hit.face, slot) {
self.damage_held(1);
}
}
@@ -113,7 +113,7 @@ impl Game<'_> {
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
@@ -141,7 +141,7 @@ impl Game<'_> {
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
self.brushing_completed(pos, brush.hit_face, slot);
return true;
}
self.schedule_brushable_tick(pos);
@@ -191,7 +191,7 @@ impl Game<'_> {
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
@@ -212,7 +212,20 @@ impl Game<'_> {
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
let mut drops = vec![item];
let tool = self.stack_in(slot).cloned();
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
"",
origin,
Some((pos, state)),
None,
tool.as_ref(),
&mut drops,
);
for item in drops {
self.spawn_item(item, at, [0.0; 3], 0);
}
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
@@ -6,7 +6,9 @@
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
//! waits until the terrain and entities around the player are loaded;
//! `portal` sends the player through the nether portal it stands in.
//! `break` destroys a block as the server player; `portal` sends the player
//! through the nether portal it stands in; `falling` counts physical falling
//! blocks by dimension, block and gravity.
use super::Game;
use horizon_client::{Result, text::Text};
use serde_json::{Value, json};
@@ -244,7 +246,36 @@ impl Game<'_> {
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"break" => {
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
self.harness_owner(|g| g.break_block(pos))?;
}
"portal" => self.harness_portal()?,
"falling" => {
// Physical falling blocks are not command-selector targets
// yet. Inspect the actual level rather than that unrelated gap.
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
let block = op["block"].as_str().unwrap_or("minecraft:sand");
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
let back = self.dim;
self.enter_level(None, super::Dim::from_id(dimension))?;
let facts = self.res.facts();
let matches = |f: &super::FallingBlock| {
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
};
let count = self.falling.iter().filter(|f| matches(f)).count();
if op["discard"].as_bool() == Some(true) {
self.falling.retain(|f| !matches(f));
}
self.enter_level(None, back)?;
let record = json!({
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
"messages": [],
"results": [[true, count]],
});
out.push_str(&record.to_string());
out.push('\n');
}
other => return Err(format!("command harness: unknown op {other}").into()),
}
}
+88 -42
View File
@@ -150,6 +150,69 @@ impl Game<'_> {
let _ = request.reply.send(reply.to_string());
}
/// Text typed at the open screen's text box by the control socket's
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
/// taken one, not the characters `\` and `b`. `Err` when no text box of
/// the screen takes it.
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
if text == "\\b" {
match self.screen {
Screen::WorldOptions => {
self.world_options_text(None);
return Ok(());
}
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(None, true);
return Ok(());
}
_ => {}
}
}
self.type_chars_into_screen(text)
}
/// Characters the open screen's focused text box takes: the control
/// socket's `type` and a key press's surviving characters (`charTyped`).
/// Nothing here is a key — the caller drops control characters. `Err` when
/// no text box of the screen takes them.
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
if self.book.is_some() {
self.book_type(text);
return Ok(());
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(Some(text), false)
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box, while it has an input to name.
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
Ok(())
}
fn control_command(
&mut self,
line: &str,
@@ -371,6 +434,17 @@ impl Game<'_> {
}
// Native mods see the key as the keyboard hook does.
let action = arg(2)?;
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
// press before it acts on it, as the window's key event hands
// it over. A cancelled press goes no further — not to the
// screens, not to `native_key` — and its release still comes.
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
if action == "tap" {
self.physical_keys.remove(&physical);
self.native_key_event(physical, false, false);
}
return ok();
}
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.native_key_event(physical, false, false);
@@ -675,48 +749,20 @@ impl Game<'_> {
self.require_world()?;
// Typed keys: the open screen's narration hears them.
self.narration.keyboard_action();
if self.book.is_some() {
self.book_type(text);
return ok();
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return ok();
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box.
Screen::Inventory
if self
.station
.as_ref()
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
{
self.station_text(Some(text), false)
}
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
// each character before a text box takes it, as the window's
// key event fires it. A cancelled character is dropped, so
// `type` types the survivors only.
// `\b` is the socket's backspace sentinel, not two characters.
let typed = if text == "\\b" {
text.to_owned()
} else {
let Some(typed) = self.screen_text_typed(text) else {
return ok();
};
typed
};
self.type_into_screen(&typed)?;
ok()
}
"options" => {
+49 -8
View File
@@ -1220,6 +1220,9 @@ impl Game<'_> {
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
self.lead_killers.insert(m.id, (player, self.dim));
}
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
@@ -1467,6 +1470,7 @@ impl Game<'_> {
player: player_uuid,
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
@@ -4891,6 +4895,9 @@ impl Game<'_> {
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
let mob = (m.id, super::native_hooks::mob_kind(m));
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
// motion; `MushroomCow.shear`'s do not.
let mut wool = false;
@@ -4980,8 +4987,9 @@ impl Game<'_> {
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops_table = format!("minecraft:shearing/sheep/{color}");
let stacks = world_items!(self).loot_table(
&format!("minecraft:shearing/sheep/{color}"),
&drops_table,
false,
None,
&mut self.world.random,
@@ -5482,8 +5490,22 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
.collect();
if !drops.is_empty() {
self.native_use_drops(
&drops_table,
feet,
None,
Some((mob.0, &mob.1)),
held.as_ref(),
&mut drops,
);
}
for stack in drops {
for _ in 0..stack.count {
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
// the feet that starts with the constructor's own motion, and
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
@@ -5501,10 +5523,8 @@ impl Game<'_> {
}
self.spawn_item(
ItemStack {
item: item.as_str().into(),
count: 1,
damage: 0,
extra: None,
..stack.clone()
},
[feet[0], feet[1] + 1.0, feet[2]],
v,
@@ -5536,10 +5556,21 @@ impl Game<'_> {
_ => return false,
};
let at = m.position;
let mob = (m.id, super::native_hooks::mob_kind(m));
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let origin = [at.x, at.y, at.z];
self.native_use_drops(
table,
origin,
None,
Some((mob.0, &mob.1)),
tool.as_ref(),
&mut stacks,
);
for stack in stacks {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
@@ -5562,7 +5593,17 @@ impl Game<'_> {
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
self.native_use_drops(
table,
feet,
None,
Some((id, &kind)),
tool.as_ref(),
&mut stacks,
);
for d in stacks {
self.spawn_at_location(at, 0.0, d);
}
self.audio
+5 -1
View File
@@ -1583,7 +1583,11 @@ impl Game<'_> {
_ => {
// The game's dispatcher, captured for the console.
self.chat_capture = Some(Vec::new());
self.as_console(|g| g.run_command(line));
// A command a native mod registered goes to the mods first,
// looked up by the same first word the match above read.
if !self.perform_native_console_command(line.trim()) {
self.as_console(|g| g.run_command(line));
}
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
@@ -17,6 +17,9 @@ impl Game<'_> {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
(Some(r), Some(_)) => r.pack_shadow_casters(),
// A native pack's shadow pass has no spans to hide entities with: it only needs
// the first-person model.
(Some(r), None) => (false, r.native_shadow()),
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
@@ -5,18 +5,15 @@ use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
impl Game<'_> {
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
/// `FallingBlockEntity.causeFallDamage`: impact damage and anvil wear,
/// before block effects and portal handling can transfer the entity.
pub(super) fn falling_block_impact(&mut self, block: &mut FallingBlock) {
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let stalactite = matches!(
&*name,
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
);
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
if let Some(impact) = block.damage.impact(block.fall_distance) {
if impact.amount > 0.0 {
let mob_kind = if stalactite {
@@ -79,6 +76,16 @@ impl Game<'_> {
}
}
}
}
pub(super) fn finish_falling_block(&mut self, block: FallingBlock, pos: [i32; 3]) {
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
if block.damage.cancel_drop {
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
+18 -4
View File
@@ -300,19 +300,33 @@ impl Game<'_> {
let s = f.step().map(f64::from);
let c = center(f);
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
let mut drops = Vec::new();
if with_frame {
let frame = ItemStack {
drops.push(ItemStack {
item: Arc::from(f.kind()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(frame, at, [0.0; 3], 10);
});
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
{
self.spawn_item(item, at, [0.0; 3], 10);
drops.push(item);
}
if by_player && !drops.is_empty() {
let tool = self.held().cloned();
self.native_use_drops(
"",
c,
None,
Some((f.id, f.kind())),
tool.as_ref(),
&mut drops,
);
}
for stack in drops {
self.spawn_item(stack, at, [0.0; 3], 10);
}
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
+7
View File
@@ -94,6 +94,13 @@ impl Game<'_> {
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
let text: String = text.chars().filter(|c| !c.is_control()).collect();
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
// reaches a text box by characters, as a key's own text does, so the
// open screen hears of each of them first and a cancelled one never
// reaches the box.
let Some(text) = self.screen_text_typed(&text) else {
return;
};
if text.is_empty() || !self.text_field_focused() {
return;
}
+189 -6
View File
@@ -414,6 +414,7 @@ impl Game<'_> {
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
motion.invulnerable = motion.may_fly;
@@ -2474,6 +2475,25 @@ impl Game<'_> {
}
}
/// Whether a block outline occludes the straight line from `eye` to `point`.
fn block_between(&self, eye: [f64; 3], point: [f64; 3]) -> bool {
let d = [point[0] - eye[0], point[1] - eye[1], point[2] - eye[2]];
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if dist < 1e-9 {
return false;
}
let dir = [d[0] / dist, d[1] / dist, d[2] / dist];
self.clip_ray(eye, dir, dist).is_some_and(|h| {
let hd = [
h.point[0] - eye[0],
h.point[1] - eye[1],
h.point[2] - eye[2],
];
let hit_dist = (hd[0] * hd[0] + hd[1] * hd[1] + hd[2] * hd[2]).sqrt();
hit_dist < dist - 1e-6
})
}
/// Aim the swapped-in player at an entity; returns where it aims and the
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
/// the point the client hit (the interact packet's location) when that
@@ -2514,12 +2534,13 @@ impl Game<'_> {
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
Some(if on { inside } else { middle })
};
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
let mob_box = self.mobs.iter().find(|m| m.id as i32 == id).map(|m| {
let b = m.bounds();
let (mn, mx) = (b.min_position(), b.max_position());
([mn.x, mn.y, mn.z], [mx.x, mx.y, mx.z])
});
let centre = match mob_box {
Some((lo, hi)) => visible_aim_point(eye, lo, hi, |p| self.block_between(eye, p)),
None if self.object_target(id).is_some() => object()?,
// A dragon part or an end crystal (game/dragon.rs).
None => {
@@ -2762,6 +2783,30 @@ impl Game<'_> {
command: &str,
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
) {
// The mods hear every command first, and may stop it.
let mut canceled = false;
let mut replies = Vec::new();
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
self.native_player_command(
uuid,
&name,
command.trim_start_matches('/'),
&mut canceled,
&mut replies,
);
}
for line in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
if canceled {
return;
}
if self.guest_message_command(host, conn, command, signed.as_ref()) {
return;
}
@@ -3098,6 +3143,7 @@ impl Game<'_> {
// restartClientLoadTimerAfterRespawn.
g.dead = false;
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
@@ -4747,3 +4793,140 @@ mod bundle_slot_tests {
assert_eq!(bundle_inventory_slot(45, None), Some(45));
}
}
/// Where the server aims a guest's attack or interact on a mob. The packet names only the
/// entity, so the server picked the mob's centre; a slab, trapdoor, stair or fence between the
/// eyes and that centre then hid a mob the guest's own crosshair hit through the gap (#5772).
/// This returns the centre when it is visible, else the visible point of the box nearest to it (the one nearer the eyes when two are equally near)
/// (`occluded` tells whether a block outline hides a point from the eyes); the centre again
/// when the whole box is hidden.
fn visible_aim_point(
eye: [f64; 3],
lo: [f64; 3],
hi: [f64; 3],
occluded: impl Fn([f64; 3]) -> bool,
) -> [f64; 3] {
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if !occluded(centre) {
return centre;
}
let margin = [0, 1, 2].map(|k| ((hi[k] - lo[k]) / 2.0).min(0.02));
let across = |k: usize, steps: usize| {
(0..=steps)
.map(|i| {
lo[k] + margin[k] + (hi[k] - lo[k] - 2.0 * margin[k]) * i as f64 / steps as f64
})
.collect::<Vec<f64>>()
};
let (xs, ys, zs) = (across(0, 2), across(1, 4), across(2, 2));
let mut candidates = Vec::with_capacity(xs.len() * ys.len() * zs.len());
for &x in &xs {
for &y in &ys {
for &z in &zs {
candidates.push([x, y, z]);
}
}
}
let nearness = |p: &[f64; 3]| (0..3).map(|k| (p[k] - centre[k]).powi(2)).sum::<f64>();
let from_eye = |p: &[f64; 3]| (0..3).map(|k| (p[k] - eye[k]).powi(2)).sum::<f64>();
candidates.sort_by(|a, b| {
nearness(a)
.total_cmp(&nearness(b))
.then(from_eye(a).total_cmp(&from_eye(b)))
.then(a[1].total_cmp(&b[1]))
.then(a[0].total_cmp(&b[0]))
.then(a[2].total_cmp(&b[2]))
});
candidates
.into_iter()
.find(|p| !occluded(*p))
.unwrap_or(centre)
}
#[cfg(test)]
mod aim_point_tests {
use super::visible_aim_point;
const EYE: [f64; 3] = [-760.7, 70.27, -450.4];
// A zombie one block behind a top slab: its centre is above the 0.5 gap under the slab.
const LO: [f64; 3] = [-763.0, 70.0, -450.9];
const HI: [f64; 3] = [-762.4, 71.95, -450.3];
/// A top slab filling x in [-762, -761] with its underside at y 70.5: a point is hidden when
/// the line from the eyes is above 70.5 anywhere inside that column.
fn behind_top_slab(p: [f64; 3]) -> bool {
let y_at = |x: f64| EYE[1] + (p[1] - EYE[1]) * (x - EYE[0]) / (p[0] - EYE[0]);
p[0] < -762.0 && (y_at(-761.0) > 70.5 || y_at(-762.0) > 70.5)
}
#[test]
fn a_visible_centre_is_kept() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert_eq!(visible_aim_point(EYE, LO, HI, |_| false), centre);
}
/// A bottom slab hides everything under y 70.9: the aim goes above it, through the gap over the slab.
#[test]
fn a_mob_behind_a_bottom_slab_is_aimed_at_above_the_slab() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
let p = visible_aim_point(EYE, LO, HI, |pt| pt[1] < 70.9);
assert!(p[1] >= 70.9, "{p:?} is still under the slab");
assert!(p[1] > centre[1] - 0.5, "{p:?} is far from the centre");
}
/// With only the centre hidden, two equally near candidates go to the one nearer the eyes.
#[test]
fn an_equally_near_point_on_the_side_of_the_eyes_wins() {
let eye = [5.0, 70.5, 0.0];
let (lo, hi) = ([-0.3, 70.0, -0.3], [0.3, 71.9, 0.3]);
let centre = [
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
];
let p = visible_aim_point(eye, lo, hi, |pt| pt == centre);
assert!(p[0] > centre[0], "{p:?} is on the far side from the eyes");
}
#[test]
fn a_mob_seen_only_through_a_gap_is_aimed_at_through_the_gap() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert!(behind_top_slab(centre), "the slab hides the centre");
let p = visible_aim_point(EYE, LO, HI, behind_top_slab);
assert!(!behind_top_slab(p), "{p:?} is still hidden");
for k in 0..3 {
assert!(LO[k] <= p[k] && p[k] <= HI[k], "{p:?} is outside the mob");
}
assert!(p[1] < centre[1], "{p:?} is not lower than the centre");
}
#[test]
fn a_fully_hidden_mob_keeps_the_centre() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert_eq!(visible_aim_point(EYE, LO, HI, |_| true), centre);
}
#[test]
fn the_choice_is_deterministic() {
assert_eq!(
visible_aim_point(EYE, LO, HI, behind_top_slab),
visible_aim_point(EYE, LO, HI, behind_top_slab)
);
}
}
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
packets::play::{self, Clientbound as C},
};
use horizon_world::registry::BlockStateId;
@@ -28,6 +28,7 @@ fn send_native_nearby(
host: &LanHost,
dimension: &str,
packet: Vec<u8>,
horizon_only: bool,
within_range: impl Fn([f64; 3]) -> bool,
) {
for guest in host
@@ -36,10 +37,22 @@ fn send_native_nearby(
.iter()
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
{
// A vanilla client cannot decode a native mod's particle id; see
// `mod_particle_only_for_horizon`.
if horizon_only && guest.brand != "horizon" {
continue;
}
guest.sender.send(packet.clone());
}
}
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
/// drops the connection, so such a particle may only go to Horizon clients.
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
matches!(particle.data, Some(ParticleData::Mod(_)))
}
/// One of a packet's `count` particles (`handleParticleEvent`): its position
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
@@ -152,6 +165,7 @@ impl Game<'_> {
else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -163,6 +177,9 @@ impl Game<'_> {
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
@@ -252,6 +269,13 @@ impl Game<'_> {
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
// A native mod's own type: no registry id, so the sentinel and the name.
if let Some(name) = &options.mod_particle {
return Some(Particle {
id: MOD_PARTICLE_ID,
data: Some(ParticleData::Mod(name.clone())),
});
}
let name = format!("minecraft:{}", options.ty.name());
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
@@ -292,6 +316,11 @@ impl Game<'_> {
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
if p.id == MOD_PARTICLE_ID
&& let Some(ParticleData::Mod(name)) = &p.data
{
return Some(ParticleOptions::named(name.clone()));
}
let name = self
.remote
.as_ref()?
@@ -434,6 +463,7 @@ impl Game<'_> {
let Some(host) = self.lan.as_ref() else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -447,7 +477,7 @@ impl Game<'_> {
.encode();
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
send_native_nearby(host, dimension, body, |position| {
send_native_nearby(host, dimension, body, horizon_only, |position| {
let d: f64 = (0..3)
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
@@ -480,7 +510,7 @@ impl Game<'_> {
)
.encode();
let range = f64::from(sound.volume.max(1.0)) * 16.0;
send_native_nearby(host, sound.dimension, packet, |position| {
send_native_nearby(host, sound.dimension, packet, false, |position| {
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
distance <= range * range
});
+12 -1
View File
@@ -112,13 +112,24 @@ impl Game<'_> {
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
let Some((permission, uuid, name)) = host
.hub
.guest(conn)
.map(|g| (g.permission, g.uuid, g.name.clone()))
else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
// The mods hear it as they hear a typed command; the batch is quiet,
// so their replies go nowhere.
let mut canceled = false;
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
if canceled {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
+15 -4
View File
@@ -1205,8 +1205,14 @@ impl Game<'_> {
None => {
let mut handled = false;
rules.edit(|b| handled = b.key(key, ctrl, false));
if !handled && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types: a `None` text is a
// backspace to `world_options_text`, which is the
// control socket's `\b`, not a key's.
if !handled
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
}
}
@@ -1312,8 +1318,13 @@ impl Game<'_> {
o.port.shortcut(shortcut, &mut self.clipboard);
return;
}
if !o.port.key(key, ctrl, false) && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types (a `None` is a backspace to
// `world_options_text`, the control socket's `\b`).
if !o.port.key(key, ctrl, false)
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
return;
}
+52 -6
View File
@@ -127,6 +127,14 @@ impl Game<'_> {
}
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
self.drop_leash(id, drop, sound, false);
}
/// `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);
}
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
@@ -136,16 +144,54 @@ impl Game<'_> {
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
let kind = super::native_hooks::mob_kind(m);
if sound {
self.lead_sound("break", at);
}
if drop {
if !drop {
return;
}
// The mods' say when a player is behind it: the user, else the
// killer of a mob that died leashed.
let player = if used {
Some(self.native_acting_id())
} else {
killer
};
let Some(player) = player else {
self.drop_lead_item(at);
return;
};
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
let context = super::native_hooks::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((id, &kind)),
player: Some(player),
tool: None,
explosion: None,
used,
};
self.native_loot_drops(&context, &mut drops);
for stack in drops {
if stack.count > 0 && &*stack.item != "minecraft:air" {
let v = super::dispenser::new_item_motion();
self.spawn_item(stack, at, v, 10);
}
}
}
/// Holders read from a save become real once the entity is found; a
/// holder that never shows up drops the lead after 100 ticks.
pub(super) fn tick_leads(&mut self) {
// A corpse gone without dropping its lead takes its killer along.
// Only this level's: the others' mobs aren't in place.
if !self.lead_killers.is_empty() {
let (mobs, dim) = (&self.mobs, self.dim);
self.lead_killers
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
}
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
for i in 0..self.mobs.len() {
let Some(pending) = self.mobs[i].extra.leash_pending else {
@@ -222,7 +268,7 @@ impl Game<'_> {
if item == "minecraft:shears" {
let mut dropped = false;
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
dropped = true;
}
let others: Vec<u32> = self
@@ -232,7 +278,7 @@ impl Game<'_> {
.map(|m| m.id)
.collect();
for o in others {
self.break_leash(o, true, false);
self.break_leash_used(o, true);
dropped = true;
}
let boats = self.boats_on(Holder::Mob(id));
@@ -255,7 +301,7 @@ impl Game<'_> {
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.break_leash_used(id, !creative);
self.lead_game_event("minecraft:entity_interact", at);
self.lead_sound("untied", at);
return true;
@@ -264,7 +310,7 @@ impl Game<'_> {
let center = self.player_center();
if self.can_attach_lead(index, center) {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
@@ -335,7 +381,7 @@ impl Game<'_> {
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
for i in existing {
let id = self.mobs[i].id;
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
self.drop_boat_leashes(&existing_boats);
self.audio
+4
View File
@@ -46,6 +46,8 @@ pub(super) struct Parked {
tickets: ChunkTickets,
random_tick_sections: RandomTickSections,
level_events: Vec<LevelEvent>,
/// Entity portal processes and cooldowns belong to this level's entities.
portal_states: HashMap<u32, horizon_client::nether_portal::PortalState>,
falling: Vec<FallingBlock>,
paintings: Vec<horizon_client::entities::Painting>,
item_frames: Vec<horizon_client::entities::ItemFrame>,
@@ -179,6 +181,7 @@ impl Game<'_> {
tickets: Default::default(),
random_tick_sections: Default::default(),
level_events: Vec::new(),
portal_states: HashMap::new(),
falling: Vec::new(),
paintings: Vec::new(),
item_frames: Vec::new(),
@@ -233,6 +236,7 @@ impl Game<'_> {
swap(&mut self.tickets, &mut p.tickets);
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
swap(&mut self.level_events, &mut p.level_events);
swap(&mut self.portals.entities, &mut p.portal_states);
swap(&mut self.falling, &mut p.falling);
swap(&mut self.paintings, &mut p.paintings);
swap(&mut self.item_frames, &mut p.item_frames);
@@ -183,7 +183,7 @@ impl Game<'_> {
}
let state = horizon_world::chunk::BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
};
match self.pack.assets.registry.resolve(&state) {
Ok(id) => self.level_set(pos, id).is_ok(),
@@ -12,7 +12,8 @@ use horizon_assets::{
};
use horizon_client::{
block_entity_render::{diffuse, diffuse_lights, vertex_light},
containers::BlockEntity,
containers::{BlockEntity, ModBlockEntity},
fast_hash::FxHashMap,
gfx::Cull,
hud::Rect,
items::ItemStack,
@@ -57,6 +58,7 @@ pub struct BlockEntityDraw {
poses: Vec<Mat4>,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
kept: bool,
}
#[allow(dead_code)]
@@ -118,6 +120,19 @@ impl BlockEntityDraw {
pub fn scale(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_scale(Vec3::from_array(by)));
}
/// Keeps what this call drew: the block is drawn the same again, with
/// neither `native_block_entity_submit` nor
/// `native_block_entity_render_bounding_box` called, until its state,
/// its block entity (what the server sent), its light or the resource
/// packs change. Call it only when what you drew reads none of
/// `partial`, `game_time`, the blocks around it or anything else of
/// yours, and moves nothing by itself: an idle machine, not a turning
/// shaft. Kept geometry stands still: an enchanted item's glint, and an
/// item drawn with `native_draw_item` whose model reads the time (a clock,
/// a compass), stop where they were. API @API:block_entity_keep@.
pub fn keep(&mut self) {
self.kept = true;
}
fn apply(&mut self, m: Mat4) {
if let Some(pose) = self.poses.last_mut() {
*pose *= m;
@@ -171,6 +186,98 @@ fn shaded(tint: u32, brightness: f32) -> u32 {
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
}
/// Game ticks kept geometry stays after it was last drawn, as signs' text.
const KEPT_FOR: i64 = 100;
/// What kept geometry was drawn with besides its block entity: drawn again
/// when any of it changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct KeptKey {
dim: Dim,
state: u32,
light: u32,
}
struct KeptEntry {
key: KeptKey,
entity: ModBlockEntity,
bounds: [f32; 6],
/// Game time it was last drawn.
used: i64,
/// Relative to the block's corner.
vertices: Vec<Vertex>,
}
/// Geometry mods kept (`BlockEntityDraw::keep`), by position. As
/// `SignTexts` keeps a sign's text, it is kept relative to the block and
/// moved to the camera each frame.
#[derive(Default)]
pub(super) struct KeptGeometry {
entries: FxHashMap<[i32; 3], KeptEntry>,
/// Game time of the last sweep of what is out of view.
swept: i64,
}
impl KeptGeometry {
/// Drops what was not drawn for `KEPT_FOR` ticks, once every `KEPT_FOR`.
fn sweep(&mut self, now: i64) {
if (now - self.swept).abs() >= KEPT_FOR {
self.swept = now;
self.entries
.retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used));
}
}
/// What is kept at `pos` when it was drawn with `key` and `entity`,
/// marked drawn at `now`.
fn get(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
now: i64,
) -> Option<&KeptEntry> {
let entry = self
.entries
.get_mut(&pos)
.filter(|e| e.key == key && e.entity == *entity)?;
entry.used = now;
Some(entry)
}
fn keep(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
bounds: [f32; 6],
vertices: &[Vertex],
now: i64,
) {
let mut kept = self
.entries
.remove(&pos)
.map_or_else(Vec::new, |e| e.vertices);
kept.clear();
kept.extend_from_slice(vertices);
self.entries.insert(
pos,
KeptEntry {
key,
entity: entity.clone(),
bounds,
used: now,
vertices: kept,
},
);
}
fn forget(&mut self, pos: [i32; 3]) {
self.entries.remove(&pos);
}
/// Forgets everything: the atlas's sprites moved.
pub(super) fn clear(&mut self) {
self.entries.clear();
}
}
impl Game<'_> {
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
@@ -192,7 +299,11 @@ impl Game<'_> {
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
kept: false,
};
let now = self.world.game_time;
let mut kept = self.kept_block_entities.borrow_mut();
kept.sweep(now);
for &(pos, state) in blocks {
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
// Vec3.closerThan: strictly closer.
@@ -204,9 +315,27 @@ impl Game<'_> {
else {
continue;
};
let origin = (corner - camera).as_vec3();
let light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
let key = KeptKey {
dim: self.dim,
state,
light,
};
if let Some(entry) = kept.get(pos, key, entity, now) {
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
let start = draw.vertices.len();
self.mark_block_entity(start, state);
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
self.cull_entity_vertices(&mut draw.vertices, start);
}
continue;
}
let id = BlockStateId(state);
let block = &*self.res.facts().get(id).name;
let origin = (corner - camera).as_vec3();
let mut bounds = DEFAULT_BOUNDS;
self.native_block_entity_render_bounding_box(pos, block, id, entity, &mut bounds);
if !in_frustum(self.view_frustum.as_ref(), origin, bounds) {
@@ -214,11 +343,22 @@ impl Game<'_> {
}
let start = draw.vertices.len();
self.mark_block_entity(start, state);
// Drawn relative to the block, then moved to the camera, so what
// is kept can be moved again.
draw.block = pos;
draw.poses.clear();
draw.poses.push(Mat4::from_translation(origin));
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
draw.poses.push(Mat4::IDENTITY);
draw.light = light;
draw.kept = false;
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
if draw.kept {
kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now);
} else {
kept.forget(pos);
}
for v in &mut draw.vertices[start..] {
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
}
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
@@ -388,6 +528,7 @@ mod tests {
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
lights: diffuse_lights(false),
vertices: Vec::new(),
kept: false,
}
}
@@ -472,4 +613,81 @@ mod tests {
// The vertex colour is RGBA8 read little-endian: red in the low byte.
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
}
fn machine(progress: i32) -> ModBlockEntity {
ModBlockEntity {
id: "mymod:machine".into(),
data: vec![progress],
..Default::default()
}
}
fn vertex(x: f32) -> Vertex {
Vertex {
pos: [x, 0.0, 0.0],
..bytemuck::Zeroable::zeroed()
}
}
const KEY: KeptKey = KeptKey {
dim: Dim::Overworld,
state: 7,
light: 0xF0F0,
};
#[test]
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
let mut kept = KeptGeometry::default();
let pos = [1, 2, 3];
kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0);
let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept");
assert_eq!(entry.vertices.len(), 1);
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
assert_eq!(entry.bounds, DEFAULT_BOUNDS);
// New data from the server, another state, light or dimension, or
// another position: none of them is the kept geometry.
assert!(kept.get(pos, KEY, &machine(1), 1).is_none());
for changed in [
KeptKey { state: 8, ..KEY },
KeptKey {
light: 0x00F0,
..KEY
},
KeptKey {
dim: Dim::Nether,
..KEY
},
] {
assert!(kept.get(pos, changed, &machine(0), 1).is_none());
}
assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none());
// Drawn again and kept with the new data, it replaces the old.
kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2);
assert!(kept.get(pos, KEY, &machine(0), 2).is_none());
let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again");
assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]);
// Drawn without keeping: forgotten.
kept.forget(pos);
assert!(kept.get(pos, KEY, &machine(1), 3).is_none());
}
#[test]
fn kept_geometry_out_of_view_for_its_ticks_is_swept() {
let mut kept = KeptGeometry::default();
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.get(gone, KEY, &machine(0), 50);
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
kept.sweep(100);
assert!(kept.entries.contains_key(&gone));
assert!(kept.entries.contains_key(&seen));
kept.get(seen, KEY, &machine(0), 150);
// At 199 no sweep is due yet; at 200 the one last drawn at 50 goes.
kept.sweep(199);
assert!(kept.entries.contains_key(&gone));
kept.sweep(200);
assert!(!kept.entries.contains_key(&gone));
assert!(kept.get(seen, KEY, &machine(0), 200).is_some());
}
}
@@ -105,6 +105,7 @@ impl Game<'_> {
stuck: Vec3::ZERO,
damage,
saved: None,
portal: Default::default(),
});
true
}
+370 -11
View File
@@ -679,7 +679,9 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) origin: [f64; 3],
/// The block broken: its position and state.
pub(crate) block: Option<([i32; 3], BlockStateId)>,
/// The mob that died: its entity ID and type (`minecraft:zombie`).
/// The mob that died, or the entity a player's use dropped them from (the
/// sheep sheared, the item frame broken): its entity ID and type
/// (`minecraft:zombie`).
pub(crate) entity: Option<(u32, &'a str)>,
/// The player who broke the block or killed the mob, when known. A mob
/// names one only while it remembers the player's hit (100 ticks), so one
@@ -689,6 +691,16 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) tool: Option<&'a ItemStack>,
/// The radius of the explosion that broke the block.
pub(crate) explosion: Option<f32>,
/// A player's use dropped them rather than a break or a death: shears on a
/// sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or
/// a suspicious block, shears carving a pumpkin, picking sweet berries or
/// cave vines, a hit taking an item frame or its item, or untying a lead
/// or cutting it with shears. `player` names them; `block` or `entity` says
/// what dropped them. API @API:use_drops@. A mob that a player killed while
/// leashed drops its lead on its next tick as a call of its own (`entity`
/// the mob, `player` its killer, `table` empty, `used` false). API
/// @API:lead_drops@.
pub(crate) used: bool,
}
/// A depth-tested outline of the client's block selection shape.
@@ -805,11 +817,12 @@ pub struct NativeScreen {
pub own_labels: bool,
}
/// The open screen, for the screen hooks (`native_screen_render_post`,
/// `native_screen_mouse_button_pressed_pre`, `..._released_pre`,
/// `native_screen_mouse_scrolled_pre`): what it is, its menu and title, where
/// its container panel is, how many slots its menu has, and where the mouse
/// and the frame's partial tick are.
/// The open screen, for the screen hooks (`native_screen_render_post`, the
/// mouse ones `native_screen_mouse_button_pressed_pre`, `..._released_pre` and
/// `native_screen_mouse_scrolled_pre`, and the key ones
/// `native_screen_key_pressed_pre` and `native_screen_char_typed_pre`): what
/// it is, its menu and title, where its container panel is, how many slots its
/// menu has, and where the mouse and the frame's partial tick are.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeScreenView<'a> {
@@ -897,7 +910,7 @@ pub(crate) struct NativePlayer {
pub(crate) struct NativeClock {
/// Ticks since the world was created (`Level.getGameTime`).
pub(crate) game_time: i64,
/// The time of day in ticks (`Level.getDayTime`): 0 at sunrise, 24000 a
/// The time of day in ticks (`Level.getOverworldClockTime`): 0 at sunrise, 24000 a
/// day. It keeps counting past 24000 (`/time add`, days going by), so take
/// it modulo 24000 for the hour; `/time set` can make it negative.
pub(crate) day_time: i64,
@@ -1089,6 +1102,36 @@ const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
("key.use", None),
];
/// What a `ScreenEvent.CharacterTyped.Pre` leaves of `text`, given `canceled`
/// (whether a hook cancelled character `c`): the cancelled printable
/// characters are gone and every other character stays, control characters
/// included — a text box reads Backspace, Delete or Escape as keys and vanilla
/// fires no `charTyped` for them, so they are never offered to the hook.
/// `None` when nothing is left to type.
fn screen_text_hooked(text: &str, mut canceled: impl FnMut(char) -> bool) -> Option<String> {
let mut left = String::with_capacity(text.len());
for c in text.chars() {
if !c.is_control() && canceled(c) {
continue;
}
left.push(c);
}
if left.is_empty() && !text.is_empty() {
return None;
}
Some(left)
}
/// What a key press's characters leave for a text box after the
/// `ScreenEvent.CharacterTyped.Pre` hook: the characters no mod cancelled,
/// control ones gone (the key's own handling takes those, and vanilla fires
/// no `charTyped` for them). `None` when nothing is left to type.
fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<String> {
let left = screen_text_hooked(text, canceled)?;
let text: String = left.chars().filter(|c| !c.is_control()).collect();
(!text.is_empty()).then_some(text)
}
/// The hook points. Each does nothing in Horizon.
impl Game<'_> {
/// Once a frame, in a world or not, after Horizon's extras.
@@ -1598,15 +1641,17 @@ impl Game<'_> {
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
/// `screen.mouse` where the pointer is (so there is no position of its
/// own), `scroll` the wheel's movement in x and y as the game's own scroll
/// handler scales it (a notch is ±1 — its sign only — times the wheel
/// sensitivity, and a touchpad's pixels are divided by 30), and
/// handler scales it (a line times the wheel sensitivity: with Discrete
/// Scrolling on, a notch is ±1, its sign only; a touchpad's pixels are
/// divided by 30), and
/// `modifiers` GLFW's bits (shift 1, control 2, alt 4, super 8). Set
/// `canceled` and nothing the screen would scroll moves: not the bundle
/// under the pointer, nor a list, slider, scrollbar or map. Nothing else
/// reads the wheel while a screen of the game's is open, so no hotbar slot
/// changes either.
///
/// It fires before the screen scrolls anything, on the window only, and
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
@@ -1625,6 +1670,72 @@ impl Game<'_> {
) {
let _ = (screen, scroll, modifiers, canceled);
}
/// A key went down over the open screen (`ScreenEvent.KeyPressed.Pre`,
/// vanilla's `Screen.keyPressed`): `screen` is the screen it is, `key`
/// vanilla's name for the key (`key.keyboard.e`, the name `native_key`
/// hands over) — a key with no name fires nothing — `repeat` whether it is
/// the keyboard's auto-repeat of a held press (Horizon's extra; NeoForge
/// folds repeats into the event), and `modifiers` GLFW's bits (shift 1,
/// control 2, alt 4, super 8). Set `canceled` and the press goes no
/// further: no focus move, no Escape-close, no inventory or hotbar key, no
/// typing by the key itself, no `native_key`.
///
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
/// listener sees that release without its press, and its characters are
/// GLFW's separate char callback and still come; see
/// `native_screen_char_typed_pre`.
/// API @API:screen_key_input@.
pub(super) fn native_screen_key_pressed_pre(
&self,
screen: &NativeScreenView<'_>,
key: &str,
repeat: bool,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, key, repeat, modifiers, canceled);
}
/// A character was typed at the open screen
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
/// bits (shift 1, control 2, alt 4, super 8). Set `canceled` and that one
/// character never reaches a text box.
///
/// It fires once per printable character of a key press's text and once
/// per character of the input method's committed text, never for a control
/// character (Enter, Backspace, Tab, Delete, Escape, a Control letter):
/// those are keys, and the game reads them as keys, not as text. A key's
/// characters fire after the key has acted, as GLFW's char callback comes
/// after its key callback (`KeyboardHandler.charTyped`): `screen` is the
/// screen open then, so a key that closes the screen (the inventory key)
/// leaves none to hear its character, where vanilla's closed screen hears
/// nothing, and one that moves the focus types into the box it moved to.
/// They fire even when `native_screen_key_pressed_pre` cancelled the press
/// that carried the text, as NeoForge delivers `CharacterTyped.Pre` for a
/// cancelled `KeyPressed.Pre`: the characters left reach the text box
/// without the key's own effects. Cancelling one character drops only that
/// one. The control socket's `type` fires it too, but not for its `\b`
/// backspace. Same screens as the key press, and it fires with no text box
/// focused (there is nothing to take the character then).
/// API @API:screen_key_input@.
pub(super) fn native_screen_char_typed_pre(
&self,
screen: &NativeScreenView<'_>,
c: char,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, c, modifiers, canceled);
}
/// The open screen as a native mod sees it (`NativeScreenView`): its id,
/// the open menu's type and title, its slot count, and its panel's
/// rectangle, with the mouse and the partial tick. `screen` is the screen
@@ -1683,6 +1794,74 @@ impl Game<'_> {
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
}
/// Whether the key press `key` over the open screen went no further
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
/// control socket's `key` command both ask before a screen acts on a key.
/// False when no screen of the hooks' is open, and for a key with no
/// vanilla name.
pub(super) fn screen_key_pressed(&self, key: KeyCode, repeat: bool) -> bool {
let Some(name) = keybinds::key_name(key) else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_key_pressed_pre(
&screen,
name,
repeat,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// Text on its way to a text box after the open screen's character hooks
/// (`native_screen_char_typed_pre` per printable character): the cancelled
/// characters are gone and every other one stays, control characters
/// included (a text box reads Backspace, Delete and the like as keys).
/// `None` when none of them is left to type.
pub(super) fn screen_text_typed(&self, text: &str) -> Option<String> {
let Some(screen) = self.screen_view_now() else {
return Some(text.to_owned());
};
let modifiers = super::glfw_modifiers(self.modifiers);
screen_text_hooked(text, |c| {
let mut canceled = false;
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
canceled
})
}
/// A key press's characters (`ScreenEvent.CharacterTyped.Pre`, vanilla's
/// `KeyboardHandler.charTyped`), dispatched once the screen has acted on
/// the key: GLFW's char callback comes after its key callback, so the
/// characters go to the screen that is open now — a key that closed the
/// screen leaves none to hear them, as vanilla's closed screen hears
/// nothing, and one that moved the focus types into the box it moved to.
/// Each printable one is offered to `native_screen_char_typed_pre`; the
/// survivors go to the focused text box, control characters (keys) gone.
pub(super) fn screen_chars_typed(&mut self, text: Option<&str>) {
let Some(text) = text else {
return;
};
let Some(screen) = self.screen_view_now() else {
return;
};
let modifiers = super::glfw_modifiers(self.modifiers);
let Some(text) = chars_to_type(text, |c| {
let mut canceled = false;
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
canceled
}) else {
return;
};
// A held edit modifier makes a shortcut of the key, not text.
if !self.types_text() {
return;
}
// `Err` only says no text box of the screen takes the characters.
let _ = self.type_chars_into_screen(&text);
}
/// A left or right (`right`) press at `at` (GUI pixels from the screen's
/// top left, so outside it too) on block `block`'s screen, a mod's, over
/// no slot: set `button` to a menu button id when it is on one of yours.
@@ -1757,6 +1936,39 @@ impl Game<'_> {
) {
let _ = (player, command, ran);
}
/// A command a player typed (NeoForge's `CommandEvent`, before
/// `Commands.performCommand` runs it), without its slash: any command,
/// vanilla's and the mods' own, also one the player may not run. Set
/// `canceled` to stop it: nothing runs and nothing is answered but the
/// lines pushed to `replies`, which go to the player as system chat.
/// No player's state is swapped in and the server's player list is busy
/// with this one, so `native_tell` and the other helpers that take a UUID
/// reach nobody here: answer through `replies`. Once for each command a
/// player sends, on a server, and for each line of a quiet command batch
/// (`horizon:quiet_commands`, whose replies are dropped). A command the
/// server refuses before reading it (unsigned chat arguments, the chat
/// rate limit) never reaches it. @API:native_player_command@.
pub(super) fn native_player_command(
&mut self,
player: u128,
name: &str,
command: &str,
canceled: &mut bool,
replies: &mut Vec<Text>,
) {
let _ = (player, name, command, canceled, replies);
}
/// A command a mod registered, typed on the dedicated server's console
/// or sent through `horizon-server console`
/// (`DedicatedServer.handleConsoleInputs`), without its slash: set `ran`
/// when it is yours. There is no player: the source is the console, at
/// permission level 4, so `native_acting_player` is None and the
/// player-only helpers give nothing; `native_feedback` answers on the
/// console. A name no mod registered goes to vanilla's dispatcher as
/// before. @API:native_perform_console_command@.
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
let _ = (command, ran);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
@@ -2151,7 +2363,9 @@ impl Game<'_> {
/// entity's data (a shulker box's items) after this. Add, change or take out
/// stacks; empty ones don't drop (NeoForge's global loot modifiers, Fabric's
/// `LootTableEvents.MODIFY_DROPS`); `context` says what dropped them and
/// how, and it is called even when `drops` is empty. It hears blocks that a
/// how, and it is called even when `drops` is empty. A player's use drops
/// through it too (`NativeLootDrops::used`: shears, a brush, an item
/// frame), with that player named. It hears blocks that a
/// player, an explosion or a mob breaks, not other breaks (leaves decaying,
/// a torch losing its wall, a piston, `/setblock ... destroy`), nor a
/// creative break or a tool that can't harvest the block, nor experience.
@@ -2477,6 +2691,10 @@ impl GameLevel<'_> {
/// directional particle. The return value means accepted, not delivered.
/// Block particle types require their block state; item, trail and
/// vibration particles are not supported by the current network dispatch.
/// `ParticleOptions::named("mymod:spark")` sends a mod's own type
/// (API @API:mod_particle_types@): Horizon clients draw it from the sprite
/// set in the mod's `assets/<namespace>/particles/<path>.json`, a client
/// without that file draws nothing, and vanilla clients aren't sent it.
pub(super) fn native_send_particles(
&mut self,
options: &ParticleOptions,
@@ -2487,6 +2705,16 @@ impl GameLevel<'_> {
count: i32,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !self.native_authoritative || !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(|id| self.registry.state(id).is_some())
@@ -2713,6 +2941,62 @@ impl Game<'_> {
let _ = self.process_level_events();
changed
}
/// The strongest redstone signal reaching the block at `pos` from its six
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
/// a client's copy of the world and for another dimension. A mod's
/// explosive, launcher or machine reads it in its block entity's tick to
/// go off or switch on. API @API:native_neighbor_signal@.
pub(super) fn native_neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return 0;
}
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at))
}
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
/// A solar panel checks for full sky light above it, a lamp or a mob farm
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
/// reads here it reads the world this game holds: the level in place on
/// a server, the server's copy on a client. API @API:native_world_state@.
pub(super) fn native_light(&self, pos: [i32; 3]) -> (u8, u8) {
if !self.world.loaded(pos[0], pos[2]) {
return (0, 0);
}
let light = self.world.light(pos[0], pos[1], pos[2]);
(light & 15, light >> 4)
}
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
/// no weather (`Level.canHaveWeather`). API @API:native_world_state@.
pub(super) fn native_weather(&self) -> (f32, f32) {
if self.dim != super::Dim::Overworld {
return (0.0, 0.0);
}
self.world.weather.levels(1.0)
}
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
/// take it modulo 24000 for the hour. API @API:native_world_state@.
pub(super) fn native_day_time(&self) -> i64 {
self.world.day_time
}
/// The lowest block y and the height of the world in place
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
/// miner may dig. API @API:native_world_state@.
pub(super) fn native_world_height(&self) -> (i32, i32) {
(self.world.shape.min_y, self.world.shape.height)
}
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
/// or `None` for an unknown item: for a mod's container that fills or
/// merges stacks itself. API @API:native_world_state@.
pub(super) fn native_max_stack_size(&self, item: &str) -> Option<u32> {
self.res.items().get(item).map(|i| i.max_stack)
}
/// The random of the server level `dimension`: vanilla's `Level.random`
/// (`level.getRandom()`), the one its explosions, block drops and other
/// level draws use, so a mod's draws move vanilla's later ones as a Java
@@ -3343,6 +3627,33 @@ impl Game<'_> {
player,
tool,
explosion,
used: false,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
}
/// The mods' say on what the acting player's use drops
/// (`NativeLootDrops::used`): `table` is the loot table rolled, if any.
pub(super) fn native_use_drops(
&mut self,
table: &str,
origin: [f64; 3],
block: Option<([i32; 3], BlockStateId)>,
entity: Option<(u32, &str)>,
tool: Option<&ItemStack>,
drops: &mut Vec<ItemStack>,
) {
let player = Some(self.native_acting_id());
let context = NativeLootDrops {
table,
dimension: self.dim.id(),
origin,
block,
entity,
player,
tool,
explosion: None,
used: true,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
@@ -4207,6 +4518,54 @@ fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) ->
#[cfg(test)]
mod tests {
/// The characters a `ScreenEvent.CharacterTyped.Pre` sees are the
/// printable ones of a text: a cancelled one leaves that text and a
/// control character (a key, not text) is never offered a hook.
#[test]
fn typed_text_keeps_control_characters_and_drops_cancelled_ones() {
let mut offered = Vec::new();
let left = super::screen_text_hooked("a\u{7f}b\u{1b}c", |c| {
offered.push(c);
c == 'b'
});
assert_eq!(offered, ['a', 'b', 'c']);
assert_eq!(left.as_deref(), Some("a\u{7f}\u{1b}c"));
// Every printable character cancelled: no text is left to type.
assert_eq!(super::screen_text_hooked("ab", |_| true), None);
// Nothing to offer: an empty text stays empty and keys alone stay whole.
assert_eq!(super::screen_text_hooked("", |_| true).as_deref(), Some(""));
assert_eq!(
super::screen_text_hooked("\u{7f}\t", |_| true).as_deref(),
Some("\u{7f}\t")
);
// What no hook cancels is handed back as it came in.
assert_eq!(
super::screen_text_hooked("hello", |_| false).as_deref(),
Some("hello")
);
}
/// The window's key path: a key whose press a mod cancelled still types
/// the characters no mod cancelled (vanilla's separate `charTyped`), while
/// the key characters never reach the text box.
#[test]
fn a_cancelled_key_still_types_the_characters_left() {
// `axb` with 'x' cancelled: the key went no further, `ab` still types.
assert_eq!(
super::chars_to_type("axb", |c| c == 'x').as_deref(),
Some("ab")
);
// Backspace, Escape and the like are keys, not text.
assert_eq!(
super::chars_to_type("a\u{7f}\u{1b}b", |_| false).as_deref(),
Some("ab")
);
// Every character cancelled: the box takes none of them.
assert_eq!(super::chars_to_type("ab", |_| true), None);
// Nothing typed: nothing to hand over.
assert_eq!(super::chars_to_type("", |_| false), None);
}
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
/// value that isn't finite is none.
#[test]
@@ -0,0 +1,82 @@
//! The player's motion for mods (docs/native-mods.md, "A player's motion"): a push for the local player, on a
//! client, and a player's creative flying speed, on a server.
use super::Game;
/// Each component of a push is clamped to this, in blocks a tick.
const PUSH_MAX: f32 = 4.0;
/// The fastest flying speed a mod may set: 1000 times vanilla's 0.05, for crossing a world's distances in seconds.
/// It only stops a stray value from throwing the player millions of blocks in a tick; past about 2 the player
/// outruns the chunks loading. Past about 0.45 (sprinting; 0.9 without) a player flies more than 10 blocks a
/// tick: the single-player owner is never checked, as in vanilla, but anyone else meets the server's moved-too-quickly
/// check, which can send them back (`Hub::position_change`, `ServerGamePacketListenerImpl.handleMovePlayer`).
const FLYING_MAX: f32 = 50.0;
/// Vanilla's creative flying speed (`Abilities.flyingSpeed`).
const FLYING_VANILLA: f32 = 0.05;
/// The velocity after a push: `kick` added, each component clamped; none when one of `kick`'s isn't a number.
fn pushed(velocity: [f64; 3], kick: [f32; 3]) -> Option<[f64; 3]> {
if !kick.iter().all(|v| v.is_finite()) {
return None;
}
Some(std::array::from_fn(|i| {
velocity[i] + f64::from(kick[i].clamp(-PUSH_MAX, PUSH_MAX))
}))
}
#[allow(dead_code)]
impl Game<'_> {
/// For a client mod: a push added to the local player's velocity, in blocks a tick, as an
/// explosion's knockback is (the window's player moves itself, as vanilla's client does). Each component is
/// clamped to -4..4; a spectator, or a value that is not a number, is left alone. API @API:native_motion@.
pub(super) fn native_push_player(&mut self, kick: [f32; 3]) {
if let Some(p) = self.player.as_mut()
&& !p.spectator
&& let Some([x, y, z]) = pushed([p.velocity.x, p.velocity.y, p.velocity.z], kick)
{
p.velocity = horizon_core::geometry::Vec3::new(x, y, z);
if kick[1] > 0.0 {
p.ground.on_ground = false;
}
}
}
/// For a server mod: `player`'s creative flying speed (vanilla's 0.05 when none, at most `FLYING_MAX`), kept on
/// the server's player and sent to their client. A respawn or a game mode change keeps it, and it is saved
/// with the player (`abilities.flySpeed`, as vanilla saves it). False for a player not here or a client of a
/// server, and inside a player's hook, where the server's player list is busy: call it from a tick hook.
/// API @API:native_motion@.
pub(super) fn native_set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
let speed = speed
.filter(|s| s.is_finite())
.map_or(FLYING_VANILLA, |s| s.clamp(0.0, FLYING_MAX));
let Some(host) = self.lan.as_mut() else {
return false;
};
let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == player && g.placed)
.map(|g| g.conn)
else {
return false;
};
host.hub.set_flying_speed(conn, speed);
true
}
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn a_push_adds_to_the_velocity_clamped() {
assert_eq!(
pushed([0.1, -0.08, 0.0], [0.8, 0.55, -0.5]),
Some([0.1 + 0.8f32 as f64, -0.08 + 0.55f32 as f64, -0.5])
);
assert_eq!(pushed([0.0; 3], [9.0, -9.0, 0.0]), Some([4.0, -4.0, 0.0]));
assert_eq!(pushed([1.0; 3], [f32::NAN, 0.0, 0.0]), None);
assert_eq!(pushed([1.0; 3], [0.0, f32::INFINITY, 0.0]), None);
}
}
@@ -695,6 +695,188 @@ impl Game<'_> {
count,
)
}
/// Gives `stack` whole (its damage, name, enchantments... kept) to the
/// player `uuid`, as `Inventory.add` places it: onto equal stacks first,
/// then into empty inventory slots, never the armour or an empty off
/// hand. What did not fit comes back (nothing is dropped): all of it for
/// a player who isn't here, is dead, or whose state is swapped out by
/// another player's (inside that player's hook, only they are reached).
/// The player's screen follows as for any change to their inventory.
/// What an auto-pickup or a machine's output to a player needs, from any
/// server hook. API @API:give_stack@.
pub(super) fn native_give_stack(
&mut self,
uuid: u128,
mut stack: ItemStack,
) -> Option<ItemStack> {
if stack.count == 0 {
return None;
}
if uuid == self.native_acting_id() {
// Their state is in place: the host's, or a guest's swapped in.
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return Some(stack);
}
return self.give(stack);
}
// Inside a player's hook their state is in place and the records of
// the others may hold swapped state (turns nest): only they are
// reached, as the doc says.
if self.lan_acting.is_some() {
return Some(stack);
}
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
let Some(host) = self.lan.as_mut() else {
return Some(stack);
};
let Some(g) = host
.hub
.guests
.iter_mut()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
else {
return Some(stack);
};
// inventory_add indexes the 36 main slots.
if g.inventory.len() < 36 {
g.inventory.resize(36, None);
}
let left = super::inventory_add(
&mut g.inventory,
&mut g.offhand,
g.selected,
max,
&mut stack,
);
left.then_some(stack)
}
}
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
/// `ExperienceOrb.value`: the points one pickup gives.
pub value: u32,
/// `ExperienceOrb.count`: orbs merged into this one, each worth `value`.
pub count: u32,
}
/// Whether an orb at `position` (its box: 0.5 wide and high, standing on
/// it) meets the box `min`..`max`, touching faces excluded (`AABB.intersects`).
fn orb_meets(position: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
position[0] - 0.25 < max[0]
&& position[0] + 0.25 > min[0]
&& position[1] < max[1]
&& position[1] + 0.5 > min[1]
&& position[2] - 0.25 < max[2]
&& position[2] + 0.25 > min[2]
}
/// Experience for server mods. API @API:give_experience@.
#[allow(dead_code)]
impl Game<'_> {
/// The experience orbs of the server level `dimension` whose box meets
/// the box `min`..`max`, in the level's order: what a mod reads before it
/// cancels an orb in `native_entity_join_level`, to give its points with
/// `native_give_experience`. Empty for a level not in place and on a
/// client of a server.
pub(super) fn native_experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return Vec::new();
}
self.orbs
.iter()
.filter(|o| o.count > 0)
.map(|o| NativeExperienceOrb {
id: o.id,
position: [o.position.x, o.position.y, o.position.z],
value: o.value,
count: o.count,
})
.filter(|o| orb_meets(o.position, min, max))
.collect()
}
/// Gives `points` of experience to the player `uuid` as an orb they touch
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
/// with it first (`repairPlayerItems`), then the rest goes to their
/// experience (`Player.giveExperiencePoints`), with the level-up sound
/// every fifth level. No orb pickup sound. False, and nothing given, for
/// a player who isn't here or is dead, and for another player than the one
/// whose state is swapped in (inside that player's hook, only they are
/// reached), and while the server's player list is busy elsewhere in the
/// tick: give first, and cancel an orb only when this returned true.
/// Their bar follows as for any change to their experience.
pub(super) fn native_give_experience(&mut self, uuid: u128, points: u32) -> bool {
self.native_as_player(uuid, |game| {
let rest = if points > 0 { game.mending(points) } else { 0 };
if rest > 0 {
game.give_orb_points(rest);
}
})
}
/// Adds `levels` to the player `uuid`'s level, or takes them off when
/// negative (`Player.giveExperienceLevels`): what the anvil and the
/// enchanting table charge with, and a mod can charge or reward with.
/// Below level 0 the level and the points are 0. The progress toward the
/// next level is kept. False, and nothing changed, as for
/// `native_give_experience`.
pub(super) fn native_give_levels(&mut self, uuid: u128, levels: i32) -> bool {
self.native_as_player(uuid, |game| game.give_levels(levels))
}
/// Runs `f` with the player `uuid`'s state in place: theirs already when
/// they are the acting player (the host, or a guest swapped in), else a
/// guest's through `as_guest`, only where no other player is swapped in.
/// False when it didn't run: no such player here, or dead.
fn native_as_player(&mut self, uuid: u128, f: impl FnOnce(&mut Self)) -> bool {
if uuid == self.native_acting_id() {
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return false;
}
f(self);
return true;
}
if self.lan_acting.is_some() {
return false;
}
let Some(mut host) = self.lan.take() else {
return false;
};
let conn = host
.hub
.guests
.iter()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
.map(|g| g.conn);
let ran = conn
.and_then(|conn| self.as_guest(&mut host, conn, f))
.is_some();
self.lan = Some(host);
ran
}
}
fn system_chat(line: &Text) -> C {
@@ -766,6 +948,30 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// A console line for the mods: true when a mod registered its command
/// (`native_register_commands`). The console is level 4, so a registered
/// usage is always allowed; a command no mod takes answers as vanilla's
/// unknown command.
pub(super) fn perform_native_console_command(&mut self, line: &str) -> bool {
let Some(host) = &self.lan else {
return false;
};
if Self::native_command_level(host, line).is_none() {
return false;
}
let mut ran = false;
self.native_perform_console_command(line, &mut ran);
if !ran {
self.native_feedback(
Text::translate("command.unknown.command", &[])
.with_color(horizon_client::text::RED),
);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -836,6 +1042,59 @@ mod tests {
);
}
#[test]
fn orbs_are_found_by_their_half_block_box() {
// A block's cell, as a mod asks for a broken block's drops.
let (min, max) = ([10.0, 64.0, -3.0], [11.0, 65.0, -2.0]);
assert!(orb_meets([10.5, 64.2, -2.5], min, max), "inside");
assert!(
orb_meets([11.2, 64.0, -2.5], min, max),
"its box reaches in"
);
assert!(
orb_meets([10.5, 63.6, -2.5], min, max),
"standing just below"
);
assert!(!orb_meets([11.25, 64.0, -2.5], min, max), "a face touching");
assert!(!orb_meets([10.5, 63.5, -2.5], min, max), "its top touching");
assert!(!orb_meets([10.5, 65.0, -2.5], min, max), "standing on top");
}
#[test]
fn whole_stacks_are_given_onto_equal_stacks_only() {
use super::super::inventory_add;
let mut inv = vec![stack("minecraft:dirt", 64); 36];
inv[2] = stack("minecraft:iron_sword", 1);
inv[2].as_mut().unwrap().damage = 5;
inv[5] = stack("minecraft:arrow", 60);
inv[7] = None;
inv[20] = None;
let mut off = stack("minecraft:arrow", 62);
// A damaged sword never joins another stack: the first free slot.
let mut sword = stack("minecraft:iron_sword", 1).unwrap();
sword.damage = 40;
assert!(!inventory_add(&mut inv, &mut off, 5, 1, &mut sword));
assert_eq!(inv[7].as_ref().unwrap().damage, 40, "its damage is kept");
assert_eq!(inv[2].as_ref().unwrap().damage, 5);
// Arrows: the selected slot, then the off hand, then a free slot,
// and what is left over comes back.
let mut arrows = stack("minecraft:arrow", 80).unwrap();
assert!(inventory_add(&mut inv, &mut off, 5, 64, &mut arrows));
assert_eq!(inv[5].as_ref().unwrap().count, 64);
assert_eq!(off.as_ref().unwrap().count, 64);
assert_eq!(inv[20].as_ref().unwrap().count, 64);
assert_eq!(arrows.count, 80 - 4 - 2 - 64);
// A full inventory with an empty off hand takes nothing.
let mut off = None;
let mut apple = stack("minecraft:apple", 3).unwrap();
assert!(inventory_add(&mut inv, &mut off, 0, 64, &mut apple));
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
assert_eq!(apple.count, 3);
}
#[test]
fn usages_merge_into_one_tree_with_runnable_nodes() {
let (t, levels, problems) = tree(&[
@@ -62,15 +62,38 @@ impl Game<'_> {
let options = &pack.manifest.options;
let values = horizon_client::shader_pack_list::load_values(path, options);
let constants = options.iter().map(|o| o.id.clone()).zip(values).collect();
let sprites = self.pack.render.sprite_layers.iter();
let tags = pack
.manifest
.sprite_tags(sprites.map(|(name, layer)| (name.as_str(), *layer)));
let sprites = || {
let layers = self.pack.render.sprite_layers.iter();
layers.map(|(name, layer)| (name.as_str(), *layer))
};
let tags = pack.manifest.sprite_tags(sprites());
let materials = pack.manifest.sprite_materials(sprites());
// Each light-giving block state's `[[lights]]` colour, with its level.
let mut lights = horizon_client::gfx::block_light::LightTable::default();
if !pack.manifest.lights.is_empty() {
for (id, state) in self.res.facts().states.iter().enumerate() {
if state.emission == 0 {
continue;
}
if let Some(color) = pack.manifest.light_of(&state.name) {
lights.insert(id as u32, (color, state.emission));
}
}
}
let labels = renderer
.set_native_shader_pack(Some((report, constants, tags)))
.set_native_shader_pack(Some((report, constants, tags, materials, lights)))
.map_err(|e| vec![e])?;
Ok((pack.manifest, labels))
});
// The resources were loaded without a native pack, so without the resource packs'
// LabPBR maps its material stage reads: load them once, now that one runs.
if result.as_ref().is_ok_and(|(m, _)| m.material.is_some())
&& !horizon_client::sprites::labpbr_maps()
{
horizon_client::sprites::set_labpbr_maps(true);
log::info!("Horizon: reloading resources for the LabPBR maps a native pack reads");
self.reload_packs();
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
match result {
Ok((m, labels)) => {
@@ -6,10 +6,9 @@
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
//! the player's End portals are game/end.rs's.
//!
//! Only one dimension is loaded at a time. The player's destination is found
//! once the other dimension has loaded around it. An item or mob that goes
//! through waits as an `Arrival` (its saved compound and where it stood in its
//! portal) until the player is in that dimension and its exit is loaded.
//! A server keeps one level per dimension. Entity portal processes and
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
//! saved compound until its destination chunks are loaded.
use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::fast_hash::FxHashMap;
@@ -86,7 +85,7 @@ pub(super) struct Portals {
/// `LocalPlayer.portalProcess.isInsidePortalThisTick`.
client_inside: bool,
/// Items and mobs, by entity id.
entities: HashMap<u32, PortalState>,
pub(super) entities: HashMap<u32, PortalState>,
/// Positions before this tick's moves, by entity id.
pub(super) before: FxHashMap<u32, Vec3>,
arrivals: Vec<Arrival>,
@@ -1103,6 +1102,60 @@ impl Game<'_> {
}
}
/// `FallingBlockEntity.tick`: block effects mark its portal, then
/// `Entity.handlePortal` transfers it before the landing branch runs.
pub(super) fn falling_through_portal(&mut self, f: &mut super::FallingBlock) -> Option<Dim> {
if self.remote.is_some() {
return None;
}
let mut sections = std::mem::take(&mut self.portals.sections);
let entry = self.swept_portal(f.previous, f.position, 0.98, 0.98, &mut sections);
self.portals.sections = sections;
if let Some(entry) = entry {
f.portal.set_inside(entry, ENTITY_DELAY);
}
let entry = f.portal.handle(true, 0, ENTITY_DELAY)?;
let plan = self.entity_plan(entry, f.position, 0.98, 0.98, false);
if !self.may_enter(plan.dim) {
return None;
}
let uuid = f.saved.as_ref().map_or_else(
|| {
if f.id == 0 {
horizon_client::save::random_uuid()
} else {
self.uuid_for(f.id)
}
},
|saved| saved.0,
);
let modelled = horizon_client::entity_nbt::falling_block_to_nbt(
self.state_to_nbt(f.state),
f.position,
f.velocity,
f.age,
f.drop_item,
f.damage,
f.fall_distance,
uuid,
);
let mut tag = super::persist::with_raw(modelled, f.saved.as_ref().map(|s| &s.1));
if let Tag::Compound(fields) = &mut tag {
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
if f.no_gravity {
fields.insert("NoGravity".into(), Tag::Byte(1));
} else {
fields.remove(&"NoGravity".into());
}
}
if f.id != 0 {
self.forget_entity(f.id);
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
Some(plan.dim)
}
// ---- persistence ----
fn arrivals_path(&self) -> std::path::PathBuf {
@@ -441,6 +441,11 @@ impl Game<'_> {
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
pub(super) fn apply_ui_settings(&mut self) {
if horizon_client_render::mipmap::set_dither_cutouts(
self.settings.horizon.dither_cutout_mips,
) {
self.reload_packs();
}
self.apply_fullscreen();
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
+2
View File
@@ -331,6 +331,8 @@ impl Game<'_> {
.set_resource_root(self.options.resources.clone());
self.maps.slots.forget();
self.map_patches.clear();
// Kept mod block entities name the old atlas's sprites.
self.kept_block_entities.get_mut().clear();
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
+14 -4
View File
@@ -94,7 +94,7 @@ fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) {
}
/// The loaded compound underneath the fields Horizon models: what the entity was
/// loaded with, with every key of `modelled` replacing its loaded value.
fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
pub(super) fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
let (Some(Tag::Compound(raw)), Tag::Compound(modelled)) = (raw, &modelled) else {
return modelled;
};
@@ -407,6 +407,14 @@ impl Game<'_> {
velocity,
age,
no_gravity,
portal: horizon_client::nether_portal::PortalState {
cooldown: horizon_client::save::number(
tag.get("PortalCooldown"),
)
.unwrap_or(0)
as i32,
process: None,
},
drop_item,
fall_distance,
stuck: Vec3::ZERO,
@@ -1094,9 +1102,11 @@ impl Game<'_> {
f.fall_distance,
uuid,
);
out.entry(chunk_of(f.position))
.or_default()
.push(with_raw(tag, raw.as_ref()));
let mut tag = with_raw(tag, raw.as_ref());
if let Tag::Compound(fields) = &mut tag {
fields.insert("PortalCooldown".into(), Tag::Int(f.portal.cooldown));
}
out.entry(chunk_of(f.position)).or_default().push(tag);
}
}
let boats: Vec<usize> = (0..self.boats.len())
+5 -1
View File
@@ -115,7 +115,11 @@ impl Game<'_> {
return;
}
let g = host.hub.guest(conn).expect("found above");
if excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
// A client that hasn't said it loaded the level is still on its loading screen, where it
// doesn't move (the server stopped waiting for it after 60 ticks): the replay falls or drifts
// where it stands. Its first move counts as any other.
let loading = g.loading && !over.moved;
if loading || excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
if let Some(replay) = host
.hub
.guest_mut(conn)
+11 -4
View File
@@ -42,11 +42,18 @@ impl Game<'_> {
// ItemEntity in front of the carved side, moved by the level random
// (no pickup delay).
let (dx, dz) = (f64::from(dx), f64::from(dz));
for seeds in self.sequence_loot(
"minecraft:carve/pumpkin",
"minecraft:carve/pumpkin",
let table = "minecraft:carve/pumpkin";
let mut drops = self.sequence_loot(table, table, Some(shears));
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
table,
origin,
Some((pos, state)),
None,
Some(shears),
) {
&mut drops,
);
for seeds in drops {
let at = [
f64::from(pos[0]) + 0.5 + dx * 0.65,
f64::from(pos[1]) + 0.1,
+2
View File
@@ -1064,6 +1064,8 @@ impl Game<'_> {
self.titles.overlay_time = 0;
// GameRenderer.resetData and narrator.clear (Minecraft.disconnect).
self.item_activation.reset();
// Kept mod block entity geometry belongs to the world leaving.
self.kept_block_entities.get_mut().clear();
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
@@ -297,6 +297,7 @@ impl Game<'_> {
velocity,
age: 0,
no_gravity: false,
portal: Default::default(),
drop_item: false,
fall_distance: 0.0,
stuck: Vec3::ZERO,
@@ -195,7 +195,7 @@ impl Game<'_> {
}
Ok(registry.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})?)
}
@@ -81,6 +81,18 @@ fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) ->
}
}
/// Whether the pack `settings` pick (or `HORIZON_SHADER_PACK`) is one of Horizon's own: only
/// then do resource loads read the resource packs' LabPBR maps (`sprites::set_labpbr_maps`).
pub(super) fn picks_native_pack(settings: &horizon_client::settings::Settings) -> bool {
let dev = std::env::var("HORIZON_SHADER_PACK").ok();
let picked = active_pack(
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
);
!picked.is_empty()
&& horizon_shader_api::package::is_pack(&Game::shader_packs_dir().join(picked))
}
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
/// geometry.
pub(super) struct PackIds {
+105 -3
View File
@@ -222,11 +222,54 @@ pub(super) struct Spill {
/// Block entities the behaviour removed, for the game's own follow-ups
/// (open screens, playing songs).
pub(super) removed: Vec<([i32; 3], BlockEntity)>,
/// Runs of `spawns` a player's use popped (a picked berry bush), for the
/// mods' `native_loot_drops`.
pub(super) used: Vec<UsedDrops>,
}
/// `spawns[start..end]` came from `table` at the block `pos` in `state`.
pub(super) struct UsedDrops {
start: usize,
end: usize,
pos: [i32; 3],
state: BlockStateId,
table: &'static str,
}
impl Spill {
pub(super) fn is_empty(&self) -> bool {
self.spawns.is_empty() && self.orbs.is_empty() && self.removed.is_empty()
self.spawns.is_empty()
&& self.orbs.is_empty()
&& self.removed.is_empty()
&& self.used.is_empty()
}
}
/// Lets `say` change each use's run of `spawns`, newest first so the earlier
/// ranges hold. A run's stacks keep their spots, and a stack `say` adds takes
/// the run's last spot, else the block's top.
fn retell_runs(
spawns: &mut Vec<Spawn>,
used: &[UsedDrops],
mut say: impl FnMut(&UsedDrops, [f64; 3], &mut Vec<ItemStack>),
) {
for used in used.iter().rev() {
let run: Vec<Spawn> = spawns.drain(used.start..used.end).collect();
let mut drops: Vec<ItemStack> = run.iter().map(|s| s.stack.clone()).collect();
let origin = used.pos.map(|c| f64::from(c) + 0.5);
say(used, origin, &mut drops);
let top = [origin[0], origin[1] + 0.5, origin[2]];
let kept: Vec<Spawn> = drops
.into_iter()
.enumerate()
.map(|(i, stack)| Spawn {
stack,
pos: run.get(i).or(run.last()).map_or(top, |s| s.pos),
motion: run.get(i).and_then(|s| s.motion),
delay: run.get(i).map_or(10, |s| s.delay),
})
.collect();
spawns.splice(used.start..used.start, kept);
}
}
@@ -324,7 +367,12 @@ impl GameLevel<'_> {
}
/// `Block.dropFromBlockInteractLootTable` with `Block.popResource` of
/// every stack.
pub(super) fn drop_interact_loot(&mut self, pos: BlockPos, state: BlockStateId, table: &str) {
pub(super) fn drop_interact_loot(
&mut self,
pos: BlockPos,
state: BlockStateId,
table: &'static str,
) {
let drops = self.items.interact_loot(
table,
self.registry,
@@ -333,11 +381,19 @@ impl GameLevel<'_> {
self.rand_sequences,
self.seed,
);
let start = self.spill.spawns.len();
for stack in drops {
if &*stack.item != "minecraft:air" && stack.count > 0 {
self.pop_resource([pos.x, pos.y, pos.z], stack);
}
}
self.spill.used.push(UsedDrops {
start,
end: self.spill.spawns.len(),
pos: [pos.x, pos.y, pos.z],
state,
table,
});
}
/// `Block.dropResources(state, level, pos, blockEntity)`: the block's loot
/// (from its random sequence, else the level random), with what its block
@@ -648,7 +704,13 @@ impl Game<'_> {
if self.spill.is_empty() {
return;
}
let spill = std::mem::take(&mut self.spill);
let mut spill = std::mem::take(&mut self.spill);
let used = std::mem::take(&mut spill.used);
let tool = self.held().cloned();
retell_runs(&mut spill.spawns, &used, |used, origin, drops| {
let block = Some((used.pos, used.state));
self.native_use_drops(used.table, origin, block, None, tool.as_ref(), drops);
});
for s in spill.spawns {
let motion = s.motion.unwrap_or_else(new_item_motion);
self.spawn_item(s.stack, s.pos, motion, s.delay);
@@ -676,6 +738,46 @@ mod tests {
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
use horizon_world::nbt::Tag;
#[test]
fn a_used_run_is_retold_in_place() {
let spawn = |item: &str, x: f64| Spawn {
stack: ItemStack::new(item, 1),
pos: [x, 0.0, 0.0],
motion: None,
delay: 10,
};
let mut spawns = vec![
spawn("minecraft:stone", 0.0),
spawn("minecraft:sweet_berries", 1.0),
spawn("minecraft:sweet_berries", 2.0),
spawn("minecraft:dirt", 3.0),
];
let used = [UsedDrops {
start: 1,
end: 3,
pos: [4, 64, 4],
state: BlockStateId(0),
table: "minecraft:harvest/sweet_berry_bush",
}];
retell_runs(&mut spawns, &used, |used, origin, drops| {
assert_eq!(used.table, "minecraft:harvest/sweet_berry_bush");
assert_eq!(origin, [4.5, 64.5, 4.5]);
assert_eq!(drops.len(), 2);
drops.remove(0);
drops.push(ItemStack::new("minecraft:diamond", 1));
});
let items: Vec<(&str, f64)> = spawns.iter().map(|s| (&*s.stack.item, s.pos[0])).collect();
assert_eq!(
items,
[
("minecraft:stone", 0.0),
("minecraft:sweet_berries", 1.0),
("minecraft:diamond", 2.0),
("minecraft:dirt", 3.0),
]
);
}
#[test]
fn ominous_banner_survives_placement_save_and_drop() {
let original = raider::ominous_banner();
+52
View File
@@ -574,6 +574,58 @@ mod tests {
);
}
/// An unbreakable damaged stack must keep both components through inventory
/// sync, including the integrated server's window (#5721).
#[test]
fn unbreakable_survives_inventory_sync() {
use horizon_protocol::buf::{Codec, Reader, Writer};
use horizon_protocol::item::Slot;
let mut components = vec![String::new(); component::UNBREAKABLE as usize + 1];
components[component::DAMAGE as usize] = "minecraft:damage".into();
components[component::UNBREAKABLE as usize] = "minecraft:unbreakable".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:diamond_hoe".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
for unbreakable in [false, true] {
let mut hoe = ItemStack::new("minecraft:diamond_hoe", 1);
hoe.damage = 58;
let marker = Tag::Compound(Default::default());
if unbreakable {
hoe.data_mut()
.set_other("minecraft:unbreakable", Some(marker.clone()));
}
let sent = crate::lan::guest::stack_to_network(|_| 1, |_, _| None, &Some(hoe));
let mut w = Writer::new();
Slot::write(&sent, &mut w);
let mut r = Reader::new(&w.data);
let received = Slot::read(&mut r).unwrap().unwrap();
assert_eq!(r.remaining(), 0);
assert_eq!(
received.component(component::UNBREAKABLE),
unbreakable.then_some(&Component::Unit)
);
let back = from_network(&received, &builtin, &Dynamic::default());
assert_eq!(
(&*back.item, back.count, back.damage),
("minecraft:diamond_hoe", 1, 58)
);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:unbreakable")),
unbreakable.then_some(&marker)
);
}
}
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
/// through the packet's bytes, so its tooltip names the Bad Omen level.
#[test]
+10 -3
View File
@@ -39,6 +39,13 @@ const KEEP_ALIVE_EVERY: Duration = Duration::from_secs(15);
/// ReadTimeoutHandler(30) in ServerConnectionListener: a connection that sends
/// nothing for 30 s.
const READ_TIMEOUT: Duration = Duration::from_secs(30);
/// How long the handshake, a status request and the login may wait for the client's next packet,
/// where vanilla waits 30 s: a client answers these at once, and each connection holds a thread
/// and one of the places the server keeps for connections, which a crowd of silent or slow
/// sockets would otherwise take for 30 s each. Configuration and play keep [`READ_TIMEOUT`] (a
/// resource pack downloads in configuration).
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
/// Packets queued for one client beyond this many bytes disconnect it.
const MAX_PENDING: usize = 96 << 20;
/// Vanilla's server-side disconnect during login is a JSON component.
@@ -480,9 +487,7 @@ fn serve_connection(
shared: Arc<Shared>,
) -> Result<(), String> {
let e = |e: horizon_protocol::Error| e.to_string();
c.reader
.set_read_timeout(Some(Duration::from_secs(30)))
.map_err(e)?;
c.reader.set_read_timeout(Some(LOGIN_TIMEOUT)).map_err(e)?;
let (id, payload) = c.recv().map_err(e)?;
let intention = match handshake::Serverbound::decode(id, &payload).map_err(e)? {
handshake::Serverbound::Intention(i) => i,
@@ -881,6 +886,7 @@ fn login_and_configure(
_ => return Err("unexpected packet during login".into()),
}
}
c.reader.set_read_timeout(Some(READ_TIMEOUT)).map_err(e)?;
// Configuration.
let sync_data = shared.sync();
let sync: &SyncData = &sync_data;
@@ -1187,6 +1193,7 @@ fn wait_alive<T>(
}
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
shared.entered_play();
let conn_id = shared.next_id();
let events = shared.events.clone();
let _ = c.reader.set_read_timeout(Some(READ_TIMEOUT));
+25
View File
@@ -173,6 +173,10 @@ pub struct Guest {
pub moved: bool,
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
pub loaded: bool,
/// Since it joined or respawned, the client hasn't said it loaded the level
/// (ServerboundPlayerLoadedPacket): it may still be on its loading screen, where it doesn't
/// move, even once the server stops waiting for it (`load_timeout`).
pub loading: bool,
/// Ticks left before the server stops waiting for that packet
/// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and
/// after a respawn, 0 once it comes.
@@ -274,6 +278,9 @@ pub struct Guest {
pub death_time: u32,
/// The join packets were sent (PlayerList.placeNewPlayer ran).
pub placed: bool,
/// `Abilities.flyingSpeed`: vanilla's 0.05, unless a server mod set
/// another (`native_set_flying_speed`). Saved with the player.
pub flying_speed: f32,
/// The level the player is in (`ServerPlayer.level().dimension()`): the
/// hub's level passes only reach the players in theirs.
pub dimension: String,
@@ -370,6 +377,7 @@ impl Guest {
changes: Default::default(),
moved: false,
loaded: false,
loading: true,
load_timeout: CLIENT_LOADED_TIMEOUT,
center: None,
sent_chunks: Default::default(),
@@ -422,6 +430,7 @@ impl Guest {
dead: false,
death_time: 0,
placed: false,
flying_speed: 0.05,
dimension: "minecraft:overworld".into(),
menu: None,
next_window: 0,
@@ -439,6 +448,14 @@ impl Guest {
recipes_unlocked: Vec::new(),
}
}
/// `ServerPlayer.onUpdateAbilities`: the guest's abilities, flying speed included.
pub fn abilities(&self) -> play::Clientbound {
play::Clientbound::PlayerAbilities(play::PlayerAbilities {
flags: self.ability_flags(),
flying_speed: self.flying_speed,
walking_speed: 0.1,
})
}
pub fn send(&self, packet: &play::Clientbound) {
if self.anticheat.record.is_some() {
self.note_change(packet);
@@ -736,6 +753,14 @@ fn network_stack(
));
}
if let Some(d) = s.data() {
// Unbreakable is a unit component; clients need its presence for the
// durability bar, tooltip and item model, including in single player.
if d.other("minecraft:unbreakable").is_some() {
stack.added.push((
horizon_protocol::item::component::UNBREAKABLE,
horizon_protocol::item::Component::Unit,
));
}
for (name, value) in &d.other {
let (removed, kind) = match name.strip_prefix('!') {
Some(kind) => (true, kind),
+30 -22
View File
@@ -933,11 +933,7 @@ impl Hub {
difficulty: ctx.difficulty,
locked: ctx.difficulty_locked,
}));
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
g.send(&C::SetHeldSlot(play::SetHeldSlot {
slot: g.selected as i32,
}));
@@ -2410,9 +2406,16 @@ impl Hub {
// track_entities). On the first tick vanilla's ran before
// ServerPlayer.tick, whose updatePlayerAttributes (and doTick's
// removeFrost) created the instances the guest's tick just made: they
// go out on the next tick, after the first chunk batch (#5560).
// go out on the next tick, after the first chunk batch (#5560). This
// server's first batch waits for the player's chunks to be ready, so
// they wait for it too: a slow load must not put them before it.
let g = &mut self.guests[i];
if let Some(m) = g.motion.as_mut().filter(|_| !first_tick) {
let after_first_batch = g.unacknowledged > 0 || g.max_unacknowledged > 1;
if let Some(m) = g
.motion
.as_mut()
.filter(|_| !first_tick && after_first_batch)
{
let values = super::attributes::take_dirty(&mut m.attributes);
if !values.is_empty() {
g.send(&C::UpdateAttributes(play::UpdateAttributes {
@@ -2756,6 +2759,7 @@ impl Hub {
S::PlayerLoaded(_) => {
let g = &mut self.guests[i];
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
@@ -3256,11 +3260,7 @@ impl Hub {
event: game_event::CHANGE_GAME_MODE,
param: mode as f32,
}));
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: 4,
entries: vec![play::PlayerInfo {
@@ -3289,6 +3289,21 @@ impl Hub {
}));
}
/// Set a guest's flying speed and tell it (`Abilities.setFlyingSpeed`, then
/// `onUpdateAbilities`). The abilities packets after carry it too, so a
/// respawn or a game mode change keeps it.
pub fn set_flying_speed(&mut self, conn: u32, speed: f32) {
let Some(g) = self.guest_mut(conn) else {
return;
};
g.flying_speed = speed;
// The server's copy of the body, which the movement replay reads.
if let Some(motion) = g.motion.as_mut() {
motion.flying_speed = speed;
}
g.send(&g.abilities());
}
/// The host's game mode changed (PlayerInfo UPDATE_GAME_MODE for everyone).
pub fn set_host_game_mode(&self, uuid: u128, mode: i32) {
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
@@ -3366,6 +3381,7 @@ impl Hub {
// restartClientLoadTimerAfterRespawn: the client loads a whole new level before it
// reports it loaded.
g.load_timeout = super::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
// ServerLevel.removePlayerImmediately: the dead player's
// Entity.setRemoved (KILLED) stops its riding; the new player rides
// nothing.
@@ -3385,11 +3401,7 @@ impl Hub {
self.respawned(id);
let g = &mut self.guests[i];
// ServerPlayer.restoreFrom: the game mode again (onUpdateAbilities).
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
// The new player faces the level's respawn direction (snapTo).
g.yaw = 0.0;
g.pitch = 0.0;
@@ -3525,11 +3537,7 @@ impl Hub {
self.resend_teleport(i, None);
}
let g = &mut self.guests[i];
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
self.send_level_info(i, ctx, !portal);
// PlayerList.sendAllPlayerInfo: the inventory and the held slot.
self.send_inventory(i);
+275 -11
View File
@@ -37,16 +37,16 @@ use horizon_protocol::{
text::translatable,
};
use std::{
collections::HashSet,
collections::{HashMap, HashSet},
io,
net::{SocketAddr, TcpListener, TcpStream},
net::{IpAddr, SocketAddr, TcpListener, TcpStream},
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicUsize, Ordering},
mpsc::{Receiver, Sender, channel},
},
thread::JoinHandle,
time::Duration,
time::{Duration, Instant},
};
/// IntegratedServer.MAX_PLAYERS.
@@ -339,6 +339,13 @@ pub struct Shared {
random: Mutex<u64>,
/// Connections accepted from the network whose thread still runs.
connections: AtomicUsize,
/// Those connections by address.
by_address: Mutex<HashMap<IpAddr, usize>>,
/// Those not playing yet (handshake, status, login, configuration), oldest first.
waiting: Mutex<Vec<Waiting>>,
next_connection: AtomicU64,
/// Connections turned away since the last report of them, and when that was.
turned_away: Mutex<(usize, Option<Instant>)>,
}
impl Shared {
/// The registries and tags a new connection is sent.
@@ -539,6 +546,10 @@ impl Lan {
next: AtomicU32::new(1),
stop: AtomicBool::new(false),
connections: AtomicUsize::new(0),
by_address: Mutex::new(HashMap::new()),
waiting: Mutex::new(Vec::new()),
next_connection: AtomicU64::new(0),
turned_away: Mutex::new((0, None)),
random: Mutex::new(crate::server::java_properties::now_millis() as u64 | 1),
config,
});
@@ -652,14 +663,183 @@ impl Drop for Lan {
/// Connections from the network accepted beyond the server's player limit.
const SPARE_CONNECTIONS: usize = 64;
/// Connections from one address at once (a household or a school behind one address plays well
/// under it): past it a new one is closed, so that one machine can't take the places
/// [`SPARE_CONNECTIONS`] leaves for everyone's logins and server-list pings. Not for a local or
/// private address: a proxy, a tunnel or a container's port forward hands every player over from
/// one of those.
const PER_ADDRESS: usize = 10;
/// Whether connections from `ip` may be many: it is this machine or the local network's.
fn shared_address(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => v4.is_loopback() || v4.is_private() || v4.is_link_local(),
IpAddr::V6(v6) => {
v6.is_loopback()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6
.to_ipv4_mapped()
.is_some_and(|v4| shared_address(IpAddr::V4(v4)))
}
}
}
/// How often the log says how many connections were turned away, and who held the places.
const TURNED_AWAY_EVERY: Duration = Duration::from_secs(60);
/// How long a connection may wait before a full server closes it for a new one.
const MAKE_ROOM_AFTER: Duration = Duration::from_secs(2);
/// A network connection not playing yet.
struct Waiting {
id: u64,
since: Instant,
stream: TcpStream,
}
thread_local! {
/// The [`Waiting`] connection this thread serves.
static WAITING: std::cell::Cell<Option<u64>> = const { std::cell::Cell::new(None) };
}
/// An accepted connection, uncounted once its thread ends (or couldn't start).
struct Counted(Arc<Shared>);
struct Counted(Arc<Shared>, IpAddr, u64);
impl Drop for Counted {
fn drop(&mut self) {
self.0
.waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|w| w.id != self.2);
let mut by = self.0.by_address.lock().unwrap_or_else(|e| e.into_inner());
if let Some(n) = by.get_mut(&self.1) {
*n -= 1;
if *n == 0 {
by.remove(&self.1);
}
}
self.0.connections.fetch_sub(1, Ordering::AcqRel);
}
}
impl Shared {
/// Counts a connection from `ip`, or None when `ip` holds its own most, or the server its
/// most and no connection that waits could be closed to make room ([`Shared::make_room`]).
fn count(self: &Arc<Self>, ip: IpAddr) -> Option<Counted> {
let most = self.max_players().saturating_add(SPARE_CONNECTIONS);
let here = |by: &HashMap<IpAddr, usize>| by.get(&ip).copied().unwrap_or(0);
let by = self.by_address.lock().unwrap_or_else(|e| e.into_inner());
if here(&by) >= PER_ADDRESS && !shared_address(ip) {
return None;
}
// make_room logs, which reads by_address: never call it holding that lock.
drop(by);
if self.connections.load(Ordering::Acquire) >= most && !self.make_room() {
return None;
}
let mut by = self.by_address.lock().unwrap_or_else(|e| e.into_inner());
let n = here(&by) + 1;
by.insert(ip, n);
self.connections.fetch_add(1, Ordering::AcqRel);
let id = self.next_connection.fetch_add(1, Ordering::Relaxed);
Some(Counted(self.clone(), ip, id))
}
/// Notes `stream`, counted as `counted`, as waiting until it plays.
fn wait(&self, counted: &Counted, stream: &TcpStream) {
if let Ok(stream) = stream.try_clone() {
let w = Waiting {
id: counted.2,
since: Instant::now(),
stream,
};
self.waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.push(w);
}
}
/// The connection this thread serves plays now: a full server no longer closes it.
pub(crate) fn entered_play(&self) {
if let Some(id) = WAITING.take() {
self.waiting
.lock()
.unwrap_or_else(|e| e.into_inner())
.retain(|w| w.id != id);
}
}
/// The server is full: closes the connection that has waited longest, if for more than
/// [`MAKE_ROOM_AFTER`] (a login or a server-list ping takes a fraction of that), so that a
/// crowd of sockets that never finish can't keep players out. Its thread ends at once. A
/// player in the game is never closed.
fn make_room(&self) -> bool {
let mut waiting = self.waiting.lock().unwrap_or_else(|e| e.into_inner());
let Some(oldest) = waiting
.first()
.filter(|w| w.since.elapsed() > MAKE_ROOM_AFTER)
else {
return false;
};
let _ = oldest.stream.shutdown(std::net::Shutdown::Both);
waiting.remove(0);
drop(waiting);
self.turned_away();
true
}
/// A connection was turned away: once a minute the log says how many, how many places were
/// taken and by which addresses most.
fn turned_away(&self) {
let mut away = self.turned_away.lock().unwrap_or_else(|e| e.into_inner());
away.0 += 1;
let now = Instant::now();
if away.1.is_some_and(|last| now < last + TURNED_AWAY_EVERY) {
return;
}
let count = std::mem::take(&mut away.0);
away.1 = Some(now);
drop(away);
let Some(log) = &self.config.log else {
return;
};
let most = self.max_players().saturating_add(SPARE_CONNECTIONS);
let mut held: Vec<(IpAddr, usize)> = self
.by_address
.lock()
.unwrap_or_else(|e| e.into_inner())
.iter()
.map(|(&ip, &n)| (ip, n))
.collect();
held.sort_by_key(|a| std::cmp::Reverse(a.1));
let addresses = held.len();
let top = held
.iter()
.take(5)
.map(|(ip, n)| {
if log.log_ips {
format!("{ip} ({n})")
} else {
format!("<ip address withheld> ({n})")
}
})
.collect::<Vec<_>>()
.join(", ");
(log.write)(
"Server Connection Thread",
"WARN",
&format!(
"Turned away {count} connection(s), or closed one that waited to make room: {} of \
{most} places taken, by {addresses} address(es), most by {top} (at most \
{PER_ADDRESS} an address)",
self.connections.load(Ordering::Acquire)
),
);
}
}
fn accept_loop(listener: TcpListener, shared: Arc<Shared>, stop: &AtomicBool) {
while !shared.stop.load(Ordering::Relaxed) && !stop.load(Ordering::Relaxed) {
match listener.accept() {
@@ -668,16 +848,18 @@ fn accept_loop(listener: TcpListener, shared: Arc<Shared>, stop: &AtomicBool) {
// the server takes and [`SPARE_CONNECTIONS`] more (logins, server-list pings), a
// new one is closed at once, so that a flood of sockets can't use up the threads a
// container allows and crash the server.
let most = shared.max_players().saturating_add(SPARE_CONNECTIONS);
if shared.connections.fetch_add(1, Ordering::AcqRel) >= most {
shared.connections.fetch_sub(1, Ordering::AcqRel);
let Some(counted) = shared.count(addr.ip()) else {
drop(stream);
shared.turned_away();
continue;
}
let counted = Counted(shared.clone());
};
shared.wait(&counted, &stream);
let _ = std::thread::Builder::new()
.name("horizon-lan-conn".into())
.spawn(move || handle(stream, addr, counted.0.clone()));
.spawn(move || {
WAITING.set(Some(counted.2));
handle(stream, addr, counted.0.clone());
});
}
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
std::thread::sleep(Duration::from_millis(25));
@@ -753,4 +935,86 @@ mod tests {
std::thread::sleep(Duration::from_millis(10));
}
}
/// One public address takes at most [`PER_ADDRESS`] places, its own coming back as they end;
/// another address, or a local one (a proxy hands everyone over from it), still gets one.
#[test]
fn one_address_takes_at_most_its_share() {
let config = NetConfig {
announce: false,
listen: false,
..NetConfig::lan(0, "Share".into(), 20)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
let mut one: Vec<Counted> = (0..PER_ADDRESS)
.map(|_| lan.shared.count(ip("203.0.113.7")).unwrap())
.collect();
assert!(lan.shared.count(ip("203.0.113.7")).is_none());
assert!(lan.shared.count(ip("198.51.100.2")).is_some());
let local: Vec<Counted> = (0..PER_ADDRESS + 5)
.map(|_| lan.shared.count(ip("127.0.0.1")).unwrap())
.collect();
assert!(lan.shared.count(ip("172.17.0.1")).is_some());
one.pop();
assert!(lan.shared.count(ip("203.0.113.7")).is_some());
drop((one, local));
assert!(lan.shared.by_address.lock().unwrap().is_empty());
assert_eq!(lan.shared.connections.load(Ordering::Acquire), 0);
}
#[test]
fn a_full_server_closes_the_connection_that_waited_longest() {
use std::io::Read;
// With a log writer, as on a dedicated server: making room logs, and must not deadlock.
let lines = Arc::new(Mutex::new(Vec::<String>::new()));
let into = lines.clone();
let config = NetConfig {
announce: false,
listen: false,
log: Some(NetLog {
write: Arc::new(move |_, _, line| into.lock().unwrap().push(line.to_owned())),
lang: Arc::new(Default::default()),
log_ips: true,
}),
..NetConfig::lan(0, "Room".into(), 1)
};
let lan = Lan::start_with(Arc::new(data::SyncData::empty()), config).unwrap();
let shared = &lan.shared;
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let mut client = TcpStream::connect(listener.local_addr().unwrap()).unwrap();
let (server_side, _) = listener.accept().unwrap();
let ip: IpAddr = "127.0.0.1".parse().unwrap();
let most = shared.max_players() + SPARE_CONNECTIONS;
let mut held: Vec<Counted> = (0..most).map(|_| shared.count(ip).unwrap()).collect();
// A player who reached the game, and a socket that never finished its login.
let playing = shared.count(ip);
assert!(playing.is_none(), "full");
shared.wait(&held[1], &server_side);
assert!(shared.count(ip).is_none(), "a fresh wait isn't closed");
shared.waiting.lock().unwrap()[0].since -= MAKE_ROOM_AFTER * 2;
let newcomer = shared.count(ip).expect("room made");
let mut byte = [0u8; 1];
client
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
assert_eq!(
client.read(&mut byte).unwrap(),
0,
"the waiting socket was closed"
);
assert!(shared.waiting.lock().unwrap().is_empty());
assert!(shared.count(ip).is_none(), "nothing else to close");
let logged = lines.lock().unwrap().clone();
assert_eq!(logged.len(), 1, "one report a minute: {logged:?}");
assert!(logged[0].contains("127.0.0.1"), "{logged:?}");
// Its thread ends and uncounts it; a connection that plays is never closed.
held.remove(1);
shared.wait(&newcomer, &server_side);
WAITING.set(Some(newcomer.2));
shared.entered_play();
assert!(shared.waiting.lock().unwrap().is_empty());
drop((held, newcomer));
assert_eq!(shared.connections.load(Ordering::Acquire), 0);
}
}
@@ -111,6 +111,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
guest.effects = crate::effects::PlayerEffects::from_saved(&level.active_effects);
guest.game_mode = level.mode();
guest.flying = level.flying;
guest.flying_speed = level.fly_speed.filter(|s| s.is_finite()).unwrap_or(0.05);
guest.selected = (level.selected_slot.max(0) as usize).min(8);
// A point that is the world spawn is none of the player's own: it
// respawns wherever the world spawn is then (`setworldspawn`).
@@ -203,6 +204,7 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
level.game_mode = guest.game_mode;
level.creative = guest.game_mode == 1;
level.flying = guest.flying;
level.fly_speed = Some(guest.flying_speed);
level.selected_slot = guest.selected as i32;
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
@@ -537,4 +539,51 @@ mod tests {
store(&back, &mut again, None);
assert_eq!(again.player_tag().get("RootVehicle"), Some(&root));
}
fn abilities(g: &Guest) -> (i8, f32) {
match g.abilities() {
horizon_protocol::packets::play::Clientbound::PlayerAbilities(p) => {
(p.flags, p.flying_speed)
}
other => panic!("not the abilities: {other:?}"),
}
}
/// A flying speed a mod set goes out in every abilities packet (a game
/// mode change's, a respawn's), is saved as vanilla's `abilities.flySpeed`
/// and comes back when the player does; a file without one is vanilla's 0.05.
#[test]
fn a_set_flying_speed_is_sent_kept_and_saved() {
let dir = world("fly-speed");
let mut g = guest();
assert_eq!(abilities(&g).1, 0.05);
g.flying_speed = 2.5;
g.game_mode = 1;
assert_eq!(abilities(&g), (1 | 4 | 8, 2.5));
g.game_mode = 0;
assert_eq!(abilities(&g), (0, 2.5));
save(&g, &dir);
let tag = read_dat(&path(&dir, g.uuid)).unwrap();
let saved = tag.get("abilities").and_then(|a| a.get("flySpeed"));
assert_eq!(saved, Some(&Tag::Float(2.5f32.to_bits())));
let mut back = guest();
assert!(load(&mut back, &dir));
assert_eq!(back.flying_speed, 2.5);
// A vanilla file's own speed is read, and one without is 0.05.
let mut level = Level::default();
level.read_player(&Tag::compound([(
"abilities",
Tag::compound([("flySpeed", Tag::Float(0.1f32.to_bits()))]),
)]));
let mut other = guest();
apply(&mut other, &level);
assert_eq!(other.flying_speed, 0.1);
level.read_player(&Tag::compound([(
"abilities",
Tag::Compound(Default::default()),
)]));
apply(&mut other, &level);
assert_eq!(other.flying_speed, 0.05);
let _ = std::fs::remove_dir_all(&dir);
}
}
+74 -9
View File
@@ -49,6 +49,8 @@ pub struct Over {
pub weight: f32,
/// The farthest tick, as played: what the log tells of it.
pub at_worst: Option<Played>,
/// One of them came with a move.
pub moved: bool,
}
/// Ticks within the tolerance in a row after which a replay that lands off takes the client's
@@ -93,6 +95,12 @@ pub struct Played {
pub moved: f64,
/// What the replayed body was doing when the tick ended ([`Played::state`]).
pub state: u8,
/// The tick's keys (ServerboundPlayerInputPacket's flags).
pub input: u8,
/// Where the client said it ended the tick.
pub at: [f64; 3],
/// The replay's velocity at the end of the tick, in blocks a tick.
pub velocity: [f64; 3],
}
impl Played {
@@ -106,7 +114,9 @@ impl Played {
pub const FLYING: u8 = 64;
pub const RIDING: u8 = 128;
/// What a log line says of the tick: `off +0.000 +0.173 +0.000, moved 0.281, air sprinting`.
/// What a log line says of the tick, so a flag can be found again in the world and played:
/// `off +0.000 +0.173 +0.000, moved 0.281, air sprinting, keys forward jump, at 12.30 64.00
/// -5.50, velocity +0.000 -0.078 +0.000`.
pub fn describe(&self) -> String {
let names = [
(Self::WATER, "water"),
@@ -129,8 +139,24 @@ impl Played {
}
}
let [x, y, z] = self.off;
let keys = [
"forward", "backward", "left", "right", "jump", "shift", "sprint",
]
.into_iter()
.enumerate()
.filter(|&(bit, _)| self.input & (1 << bit) != 0)
.map(|(_, name)| name)
.collect::<Vec<_>>();
let keys = if keys.is_empty() {
"none".to_owned()
} else {
keys.join(" ")
};
let [ax, ay, az] = self.at;
let [vx, vy, vz] = self.velocity;
format!(
"off {x:+.3} {y:+.3} {z:+.3}, moved {:.3}, {state}",
"off {x:+.3} {y:+.3} {z:+.3}, moved {:.3}, {state}, keys {keys}, at {ax:.2} {ay:.2} \
{az:.2}, velocity {vx:+.3} {vy:+.3} {vz:+.3}",
self.moved
)
}
@@ -314,6 +340,7 @@ impl Replay {
} else {
counted = true;
over.ticks += 1;
over.moved |= tick.position.is_some();
if offset > over.worst {
over.at_worst = self.ticks.back().copied();
}
@@ -459,6 +486,8 @@ impl Replay {
};
let body = &mut self.body;
let end = said.unwrap_or(last);
// The replay's own velocity, before an excuse brings it to the client's.
let velocity = body.velocity;
let off = [
end.x - body.position.x,
end.y - body.position.y,
@@ -514,6 +543,7 @@ impl Replay {
let ground = tick
.flags
.is_none_or(|f| (f & 1 != 0) == self.body.ground.on_ground);
let velocity = [velocity.x, velocity.y, velocity.z];
let played = Played {
offset,
candidate,
@@ -521,6 +551,9 @@ impl Replay {
off,
moved,
state,
input: tick.input,
at: [end.x, end.y, end.z],
velocity,
};
if self.ticks.len() == KEPT {
self.ticks.pop_front();
@@ -641,6 +674,9 @@ mod tests {
/// On tick `.0`, the server sends the player attributes (the server's body has no sprint
/// modifier): `.1` as in [`Change::Attributes`], which the client takes.
attributes: Option<(usize, Option<bool>)>,
/// The first ticks the client is on its loading screen: it ends them without moving or
/// sending a move.
frozen: usize,
}
impl Run {
@@ -655,6 +691,7 @@ mod tests {
burst: None,
blocks: Vec::new(),
attributes: None,
frozen: 0,
}
}
}
@@ -733,6 +770,7 @@ mod tests {
let run_teleport = run.teleport;
let run_burst = run.burst;
let run_attributes = run.attributes;
let run_frozen = run.frozen;
let mut back = start;
for (i, (input, yaw)) in run.script.into_iter().enumerate() {
let tick = i as u64;
@@ -757,13 +795,16 @@ mod tests {
back = before;
}
client.yaw = yaw;
world(&mut client, &mut |w| {
w.controlled_simulation_tick(keys(input)).unwrap()
});
client.position = cheat(i, before, client.position);
if i >= run_frozen {
world(&mut client, &mut |w| {
w.controlled_simulation_tick(keys(input)).unwrap()
});
client.position = cheat(i, before, client.position);
let p = client.position;
let ground = u8::from(client.ground.on_ground);
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
}
let p = client.position;
let ground = u8::from(client.ground.on_ground);
record.moved(Some([p.x, p.y, p.z]), Some((yaw, run.pitch)), ground);
record.tick_end(input, client.sprinting);
// A burst: the server hears these ticks now and plays them together at the last one.
if run_burst.is_some_and(|(from, to)| (from..to).contains(&i)) {
@@ -798,6 +839,26 @@ mod tests {
Some(played)
}
/// A client still loading the level (#5849) neither moves nor sends a move, while the replay
/// falls from where it joined: those ticks are over, but none with a move, which is what lets
/// the server excuse them. Its first move after them counts as any other.
#[test]
fn a_client_on_its_loading_screen_is_over_without_a_move() {
let run = Run {
start: Vec3::new(20.5, 12.0, 20.5),
script: vec![(0, 0.0); 20],
frozen: 7,
..Run::walk()
};
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
return;
};
let (frozen, after) = steps.split_at(7);
assert!(frozen.iter().any(|s| s.over.ticks > 0), "the replay falls");
assert!(frozen.iter().all(|s| !s.over.moved));
assert!(after.iter().all(|s| s.over.ticks == 0 || s.over.moved));
}
#[test]
fn a_played_tick_tells_its_offset_and_state() {
let played = Played {
@@ -807,10 +868,14 @@ mod tests {
off: [0.0, 0.1729, -0.001],
moved: 0.2806,
state: Played::SPRINTING | Played::WATER,
input: 1 | 16 | 64,
at: [12.3, 63.004, -5.5],
velocity: [0.0, 0.0412, -0.25],
};
assert_eq!(
played.describe(),
"off +0.000 +0.173 -0.001, moved 0.281, air water sprinting"
"off +0.000 +0.173 -0.001, moved 0.281, air water sprinting, keys forward jump sprint, \
at 12.30 63.00 -5.50, velocity +0.000 +0.041 -0.250"
);
}
@@ -528,7 +528,7 @@ fn content_root_loads_with_every_table() {
BlockFacts::load_with_behaviours(&root, new, &shared_json).unwrap();
let independent_behaviours = horizon_world::level::Behaviours::load(new, &root).unwrap();
let (deferred_facts, gameplay) =
BlockFacts::load_with_projection(&root, new, &shared_json).unwrap();
BlockFacts::load_with_projection(&root, new, &shared_json, true).unwrap();
let deferred_behaviours =
horizon_world::level::Behaviours::load_with_gameplay(new, &root, &shared_json, &gameplay)
.unwrap();
@@ -744,7 +744,7 @@ fn shared_behaviour_document_matches_independent_loaders() {
let original = BlockFacts::load(&root, assets.registry).unwrap();
let (shared, b) = BlockFacts::load_with_behaviours(&root, assets.registry, &doc).unwrap();
let (deferred_facts, projection) =
BlockFacts::load_with_projection(&root, assets.registry, &doc).unwrap();
BlockFacts::load_with_projection(&root, assets.registry, &doc, true).unwrap();
let deferred = horizon_world::level::Behaviours::load_with_gameplay(
assets.registry,
&root,
+9
View File
@@ -3456,6 +3456,8 @@ pub struct Level {
pub food_tick_timer: i32,
/// Abilities.flying: whether creative/spectator flight is active.
pub flying: bool,
/// Abilities.flyingSpeed, when the file has one (vanilla's is 0.05).
pub fly_speed: Option<f32>,
/// The vehicle the player rides, by UUID (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), so the ride comes back on the next login.
pub root_vehicle: Option<[i32; 4]>,
@@ -3696,6 +3698,10 @@ impl Level {
.get("abilities")
.and_then(|a| number(a.get("flying")))
.is_some_and(|v| v != 0);
self.fly_speed = p
.get("abilities")
.and_then(|a| float(a.get("flySpeed")))
.map(|v| v as f32);
if let Some(d) = text(p.get("Dimension")) {
self.dimension = d;
}
@@ -3845,6 +3851,9 @@ impl Level {
}
if let Tag::Compound(map) = &mut abilities {
map.entry("mayBuild".into()).or_insert(Tag::Byte(1));
if let Some(speed) = self.fly_speed {
map.insert("flySpeed".into(), Tag::Float(speed.to_bits()));
}
map.entry("flySpeed".into())
.or_insert(Tag::Float(0.05f32.to_bits()));
map.entry("walkSpeed".into())
+1 -1
View File
@@ -194,7 +194,7 @@ mod tests {
BlockPos::new(1, 75, 1),
BlockState {
name: "minecraft:air".into(),
properties: BTreeMap::new(),
properties: Default::default(),
},
)
.unwrap();
+30 -5
View File
@@ -241,12 +241,14 @@ impl BlockFacts {
}
/// Decode state facts once, retaining only the small owned fields needed later.
/// `parallel`: the JSON validation pass may run on a thread of its own.
pub fn load_with_projection(
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
parallel: bool,
) -> Result<(Self, Vec<GameplayProjection>)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Self::load_with_callback(root, registry, behaviours, parallel, |entries| {
let mut strings = HashMap::new();
Ok(entries
.iter()
@@ -260,7 +262,7 @@ impl BlockFacts {
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<Self> {
Ok(Self::load_with_callback(root, registry, behaviours, |_| Ok(()))?.0)
Ok(Self::load_with_callback(root, registry, behaviours, true, |_| Ok(()))?.0)
}
/// Build both tables from the same behaviour document and gameplay decoder.
@@ -269,7 +271,7 @@ impl BlockFacts {
registry: &BlockRegistry,
behaviours: &Value,
) -> Result<(Self, Behaviours)> {
Self::load_with_callback(root, registry, behaviours, |entries| {
Self::load_with_callback(root, registry, behaviours, true, |entries| {
Ok(Behaviours::load_with_gameplay(
registry, root, behaviours, entries,
)?)
@@ -280,11 +282,34 @@ impl BlockFacts {
root: &Path,
registry: &BlockRegistry,
behaviours: &Value,
parallel: bool,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let bytes = fs::read(root.join("gameplay-facts.json"))?;
serde_json::from_slice::<decode::Validate>(&bytes)?;
let raw: decode::Gameplay<'_> = serde_json::from_slice(&bytes)?;
let validate = || serde_json::from_slice::<decode::Validate>(&bytes).map(drop);
if !parallel {
validate()?;
return Self::decode_with_callback(&bytes, registry, behaviours, callback);
}
// The validation pass (a third of the load) only reports errors: it
// reads the file beside the decode, and its error still wins.
std::thread::scope(|scope| {
let validated = scope.spawn(validate);
let decoded = Self::decode_with_callback(&bytes, registry, behaviours, callback);
validated
.join()
.unwrap_or_else(|panic| std::panic::resume_unwind(panic))?;
decoded
})
}
fn decode_with_callback<T>(
bytes: &[u8],
registry: &BlockRegistry,
behaviours: &Value,
callback: impl FnOnce(&[decode::Entry<'_>]) -> Result<T>,
) -> Result<(Self, T)> {
let raw: decode::Gameplay<'_> = serde_json::from_slice(bytes)?;
let entries = raw
.states
.0
@@ -135,3 +135,32 @@ fn raw_shape_capture_keeps_original_json_errors() {
);
}
}
#[test]
fn the_validation_error_still_fails_the_load_first() {
// The typed decode reads this shape (raw), the validation pass rejects
// its number, and the empty behaviour document would fail the load next.
let facts = br#"{"states":[{"id":0,"outline":[[1e400]]}]}"#;
let expected = serde_json::from_slice::<Value>(facts)
.unwrap_err()
.to_string();
let root = std::env::temp_dir().join(format!("horizon-facts-validate-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(root.join("gameplay-facts.json"), facts).unwrap();
let registry = horizon_world::registry::BlockRegistry::from_facts_json(
&br#"{"states":[["minecraft:air",true,false,"","minecraft:air"]],"defaults":{"minecraft:air":0}}"#[..],
)
.unwrap();
for parallel in [false, true] {
let error = super::super::BlockFacts::load_with_projection(
&root,
&registry,
&Value::Null,
parallel,
)
.err()
.unwrap();
assert_eq!(error.to_string(), expected, "parallel: {parallel}");
}
std::fs::remove_dir_all(&root).unwrap();
}
+11 -11
View File
@@ -7,7 +7,7 @@ use horizon_world::loot::PackOverlay;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{HashMap, HashSet},
fs,
path::Path,
sync::Arc,
@@ -1043,7 +1043,7 @@ impl Items {
table: &Value,
random: &mut R,
) -> Vec<ItemStack> {
let empty = BTreeMap::new();
let empty = horizon_world::chunk::BlockProperties::new();
let ctx = LootContext {
items: self,
block: "",
@@ -1236,7 +1236,7 @@ impl Items {
let Some(table) = self.loot.get(id) else {
return vec![];
};
let empty = BTreeMap::new();
let empty = horizon_world::chunk::BlockProperties::new();
let ctx = LootContext {
items: self,
block: "",
@@ -1295,7 +1295,7 @@ impl Items {
let Some(table) = self.loot.get(id) else {
return vec![];
};
let empty = BTreeMap::new();
let empty = horizon_world::chunk::BlockProperties::new();
let ctx = LootContext {
items: self,
block: "",
@@ -1318,7 +1318,7 @@ impl Items {
let Some(table) = self.loot.get(&format!("minecraft:entities/{name}")) else {
return vec![];
};
let empty = BTreeMap::new();
let empty = horizon_world::chunk::BlockProperties::new();
let ctx = LootContext {
items: self,
block: "",
@@ -1365,7 +1365,7 @@ impl LootContext<'_> {
struct LootContext<'a> {
items: &'a Items,
block: &'a str,
properties: &'a BTreeMap<String, String>,
properties: &'a horizon_world::chunk::BlockProperties,
held: Option<&'a ItemStack>,
killed_by_player: bool,
/// `LootContextParams.EXPLOSION_RADIUS`: an explosion that decays drops.
@@ -2097,7 +2097,7 @@ mod tests {
fn ctx<'a>(
items: &'a Items,
held: Option<&'a ItemStack>,
props: &'a BTreeMap<String, String>,
props: &'a horizon_world::chunk::BlockProperties,
block: &'a str,
) -> LootContext<'a> {
LootContext {
@@ -2114,7 +2114,7 @@ mod tests {
#[test]
fn fortune_silk_touch_and_looting() {
let items = Items::for_tests(HashMap::new());
let props = BTreeMap::new();
let props = horizon_world::chunk::BlockProperties::new();
let mut pick = ItemStack::new("minecraft:diamond_pickaxe", 1);
pick.enchant("minecraft:fortune", 3);
let ore = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:alternatives","children":[
@@ -2186,7 +2186,7 @@ mod tests {
#[test]
fn set_potion_gives_the_potion_its_contents() {
let items = Items::for_tests(HashMap::new());
let props = BTreeMap::new();
let props = horizon_world::chunk::BlockProperties::new();
let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:splash_potion",
"modifier":{"type":"minecraft:set_potion","id":"minecraft:fire_resistance"}}]}]});
let drops = ctx(&items, None, &props, "").evaluate(&table, &mut LegacyRandom::new(1));
@@ -2203,7 +2203,7 @@ mod tests {
let Some(items) = super::prepared_items() else {
return;
};
let props = BTreeMap::new();
let props = horizon_world::chunk::BlockProperties::new();
let mut random = LegacyRandom::new(11);
// A sword at levels 20..39 comes out enchanted, as in an End city.
let levels = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
@@ -2264,7 +2264,7 @@ mod tests {
let Some(items) = super::prepared_items() else {
return;
};
let props = BTreeMap::new();
let props = horizon_world::chunk::BlockProperties::new();
let table = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item",
"name":"minecraft:book","modifier":[
{"type":"minecraft:enchant_randomly","options":"minecraft:mending"},
@@ -161,7 +161,14 @@ pub enum ParticleData {
GeyserBase(i32, f32),
/// Vibration: where it travels to, then its arrival time in ticks.
Vibration { source: PositionSource, ticks: i32 },
/// Horizon extension: a native mod's own particle type, by its namespaced
/// name (`mymod:spark`). Vanilla has no id for it; see `MOD_PARTICLE_ID`.
Mod(String),
}
/// Horizon extension for a native mod's particle type. Vanilla's
/// `minecraft:particle_type` ids are registry ids and never negative, so this
/// one cannot collide with a vanilla type.
pub const MOD_PARTICLE_ID: i32 = -1;
/// `PositionSource.STREAM_CODEC`: a `minecraft:position_source_type` id
/// (block 0, entity 1), then that type's data.
#[derive(Clone, Debug, PartialEq)]
@@ -172,6 +179,13 @@ pub enum PositionSource {
impl Particle {
pub fn read(r: &mut Reader) -> Result<Particle> {
let id = r.varint()?;
// A native mod's type: the namespaced name follows the sentinel id.
if id == MOD_PARTICLE_ID {
return Ok(Particle {
id,
data: Some(ParticleData::Mod(r.string(32767)?)),
});
}
// `minecraft:particle_type` registry order as of 26.3.
let data = match id {
1 | 2 | 36 | 121 | 125 => Some(ParticleData::State(r.varint()?)),
@@ -248,6 +262,7 @@ impl Particle {
w.varint(*d);
}
ParticleData::Item(s) => Template::write(s, w),
ParticleData::Mod(name) => w.string(name),
ParticleData::Vibration { source, ticks } => {
match source {
PositionSource::Block(pos) => {
@@ -536,6 +551,11 @@ mod tests {
wire: Default::default(),
})),
),
// A native mod's type: the sentinel id, then the namespaced name.
(
MOD_PARTICLE_ID,
Some(ParticleData::Mod("mymod:spark".to_owned())),
),
(
58,
Some(ParticleData::Vibration {
+1
View File
@@ -9,6 +9,7 @@ description = "Horizon's own shader packs (WGSL, docs/shaders.md): the format, t
[dependencies]
bytemuck = { version = "1.24", features = ["derive"] }
# Parses and validates a pack's WGSL, and translates it as wgpu does for Vulkan, Metal and DirectX 12.
png = "0.18"
naga = { version = "30.0.1", features = ["wgsl-in", "spv-out", "msl-out", "hlsl-out"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+79 -1
View File
@@ -91,10 +91,65 @@ impl CheckedStage {
#[derive(Debug)]
pub struct Report {
pub stages: Vec<CheckedStage>,
/// The manifest's `[sky]`.
pub sky: Option<crate::manifest::Stage>,
/// The manifest's `[reflection]`.
pub reflection: Option<crate::manifest::Reflection>,
/// The manifest's `[shadow]`.
pub shadow: Option<crate::manifest::Shadow>,
/// The manifest's `[material] fog`: whether Horizon fogs the material's colour.
pub material_fog: bool,
/// The manifest's `[[textures]]`, and each image decoded, in that order.
pub textures: Vec<(crate::manifest::PackTexture, Image)>,
/// The manifest the stages were checked against.
pub manifest: crate::manifest::Manifest,
}
#[derive(Clone, Debug)]
pub struct Image {
pub width: u32,
pub height: u32,
/// RGBA, 8 bits a channel, rows from the top.
pub rgba: Vec<u8>,
}
/// Decodes the PNG `bytes` into RGBA8.
pub fn decode_png(bytes: &[u8]) -> Result<Image, String> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let (width, height) = (reader.info().width, reader.info().height);
let max = crate::manifest::MAX_TEXTURE_SIZE;
if width == 0 || height == 0 || width > max || height > max {
return Err(format!(
"{width}×{height}; a texture is at most {max}×{max}"
));
}
let mut buffer = vec![0; reader.output_buffer_size().ok_or("too large")?];
let frame = reader.next_frame(&mut buffer).map_err(|e| e.to_string())?;
let data = &buffer[..frame.buffer_size()];
let pixels = (width * height) as usize;
let rgba = match frame.color_type {
png::ColorType::Rgba => data.to_vec(),
png::ColorType::Rgb => data
.chunks(3)
.flat_map(|c| [c[0], c[1], c[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => data
.chunks(2)
.flat_map(|c| [c[0], c[0], c[0], c[1]])
.collect(),
png::ColorType::Grayscale => data.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err("indexed colour was not expanded".into()),
};
if rgba.len() != pixels * 4 {
return Err("unexpected pixel data".into());
}
Ok(Image {
width,
height,
rgba,
})
}
/// Checks every stage of `pack`, and that each option is an `override` of
@@ -106,6 +161,7 @@ pub fn check(pack: &Pack) -> Result<Report, Vec<Problem>> {
.iter()
.map(|s| (StageKind::Sky, s))
.chain(m.material.iter().map(|s| (StageKind::Material, s)))
.chain(m.weather.iter().map(|s| (StageKind::Weather, s)))
.chain(m.post.iter().map(|s| (StageKind::Post, s)));
let (mut checked, mut problems) = (Vec::new(), Vec::new());
for (kind, stage) in stages {
@@ -138,11 +194,22 @@ pub fn check(pack: &Pack) -> Result<Report, Vec<Problem>> {
}
}
}
let mut textures = Vec::new();
for t in &m.textures {
match decode_png(pack.source_bytes(&t.file).unwrap_or_default()) {
Ok(image) => textures.push((t.clone(), image)),
Err(e) => problems.push(problem(&t.file, e)),
}
}
if problems.is_empty() {
Ok(Report {
sky: pack.manifest.sky.clone(),
stages: checked,
textures,
reflection: m.reflection.clone(),
shadow: m.shadow.clone(),
material_fog: m.material.as_ref().and_then(|s| s.fog).unwrap_or(true),
manifest: m.clone(),
})
} else {
Err(problems)
@@ -156,7 +223,12 @@ pub fn check_stage(pack: &Pack, kind: StageKind, file: &str) -> Result<CheckedSt
let mut own = compose(pack, kind, file);
let entries = own.parts.pop().expect("entries are the last part");
own.text.truncate(entries.1.start);
// The material prelude's LabPBR functions call into the renderer: a stand-in here.
if kind == StageKind::Material {
own.text += crate::stages::PBR_STUB;
}
let module = parse(&own)?;
own.text.truncate(entries.1.start);
let mut problems = signature(&module, kind, file);
problems.extend(api_rules(&module, kind, &own));
problems.extend(options(&module, &pack.manifest.options, file));
@@ -308,7 +380,13 @@ fn names(module: &naga::Module) -> HashSet<String> {
/// A pack's files declare no bindings, no entry points and no `horizon_`
/// names: what the prelude doesn't define is the pack's.
fn api_rules(module: &naga::Module, kind: StageKind, own: &Composed) -> Vec<Problem> {
let prelude = naga::front::wgsl::parse_str(&kind.prelude()).expect("the prelude parses");
// The prelude as composed: the stage's own, with the pack's `[[textures]]`. The material
// prelude's LabPBR functions call into the renderer: its stand-in is ours too.
let mut text = own.text[own.parts[0].1.clone()].to_owned();
if kind == StageKind::Material {
text += crate::stages::PBR_STUB;
}
let prelude = naga::front::wgsl::parse_str(&text).expect("the prelude parses");
let ours = names(&prelude);
let file = &own.parts[own.parts.len() - 1].0;
let mut problems = Vec::new();
+14
View File
@@ -234,6 +234,7 @@ pub const MATERIAL_INPUT: Struct = Struct {
uv: "vec2<f32>" = "The texture coordinate inside the block atlas.",
translucent: "bool" = "Drawn in the translucent pass (water, stained glass, ice).",
tags: "u32" = "The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`.",
material: "u32" = "The `[[materials]]` id of this surface's sprite (0 when no entry names it).",
screen: "vec2<f32>" = "Where the point is on the screen, 0..1 from the top left (`opaque_depth` and `opaque_color` at `screen * frame.resolution`).",
face: "u32" = "For a fluid, which face of the block the point is on, `Direction` order: 0 down, 1 up, 2 north, 3 south, 4 west, 5 east. 0 elsewhere.",
fluid: "u32" = "0 not a fluid, 1 water, 2 lava.",
@@ -264,6 +265,19 @@ pub const SKY_INPUT: Struct = Struct {
},
};
/// The weather stage's input: one point of a rain or snow streak.
pub const WEATHER_INPUT: Struct = Struct {
name: "WeatherInput",
doc: "One point of a rain or snow streak, for `fn weather(input: WeatherInput) -> vec4<f32>`, \
which returns the colour blended over the world (alpha its opacity).",
fields: fields! {
color: "vec4<f32>" = "The rain or snow texture's colour here times the streak's vertex colour (alpha 0.1..1).",
light: "vec2<f32>" = "Block and sky light, 0..1.",
position: "vec3<f32>" = "The point, camera-relative, in blocks.",
snow: "bool" = "A snow streak (rain otherwise).",
},
};
#[cfg(test)]
mod tests {
use super::*;
+4 -1
View File
@@ -12,6 +12,9 @@ pub mod stages;
pub use check::{Problem, Report, check};
pub use inputs::Frame;
pub use manifest::{API, MANIFEST, Manifest, OptionKind, PackOption, SCHEMA, SpriteTag, Stage};
pub use manifest::{
API, LightColor, MANIFEST, Manifest, Material, OptionKind, OptionPage, PackOption, PackTexture,
PageItem, SCHEMA, SpriteTag, Stage,
};
pub use package::{EXTENSION, Pack};
pub use stages::{Composed, StageKind, compose};
+561 -22
View File
@@ -12,8 +12,18 @@ pub const API: &str = "0.1";
pub const MAX_POST_PASSES: usize = 8;
/// Options a pack may declare.
pub const MAX_OPTIONS: usize = 64;
/// Settings pages a pack may declare, and the items one page may hold.
pub const MAX_PAGES: usize = 32;
pub const MAX_PAGE_ITEMS: usize = 64;
/// The most GPU time a pack may declare for itself, in milliseconds.
pub const MAX_GPU_MS: f32 = 16.0;
/// `[[materials]]` entries a manifest may declare.
pub const MAX_MATERIALS: usize = 1024;
/// `[[textures]]` a pack may declare, and the side of the largest.
pub const MAX_TEXTURES: usize = 8;
pub const MAX_TEXTURE_SIZE: u32 = 2048;
/// `[[lights]]` entries a manifest may declare.
pub const MAX_LIGHTS: usize = 256;
/// Sprite tags a pack may declare: the bits of `MaterialInput.tags`.
pub const MAX_TAGS: usize = 32;
@@ -33,11 +43,23 @@ pub struct Manifest {
pub shared: Vec<String>,
pub sky: Option<Stage>,
pub material: Option<Stage>,
/// The colour of rain and snow streaks (Minecraft's own when absent).
pub weather: Option<Stage>,
/// Full-screen passes, in the order they run.
pub post: Vec<Stage>,
pub options: Vec<PackOption>,
/// How the settings page lays the options out; the first page is the one it opens on.
/// Empty: one page with every option.
pub pages: Vec<OptionPage>,
/// Sprite tags the material stage reads, in bit order.
pub tags: Vec<SpriteTag>,
/// Material ids by sprite: the first entry with a matching pattern wins.
pub materials: Vec<Material>,
/// Images every stage reads, in order (`TEXTURE_BINDING` + their index).
pub textures: Vec<PackTexture>,
/// The colour of block light by the block giving it off: the first entry with a matching
/// pattern wins (`Manifest::light_of`).
pub lights: Vec<LightColor>,
pub budget: Budget,
/// When the material stage's water mirror (`reflection`) is drawn.
pub reflection: Option<Reflection>,
@@ -45,8 +67,8 @@ pub struct Manifest {
pub shadow: Option<Shadow>,
}
/// `[shadow]`: the renderer draws the solid terrain as the sun (the moon by night) sees it into
/// a square depth map each frame, which the material and post stages read.
/// `[shadow]`: the renderer draws the solid terrain and entities as the sun (the moon by night)
/// sees them into a square depth map each frame, which the material and post stages read.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Shadow {
@@ -56,6 +78,10 @@ pub struct Shadow {
/// Half the side of the square the map covers around the camera, in blocks.
#[serde(default = "default_shadow_distance")]
pub distance: f32,
/// `[[materials]]` ids drawn into the map whole, their cutouts ignored: leaves cast
/// a full shadow, say, whatever holes they show.
#[serde(default)]
pub solid_materials: Vec<u32>,
}
/// The shadow map's side, in texels.
@@ -82,6 +108,31 @@ pub struct Reflection {
pub option: Option<String>,
}
/// `[[textures]]`: a PNG of the pack every stage reads, as `<id>` and `<id>_sampler`.
#[derive(Clone, Debug, PartialEq)]
pub struct PackTexture {
/// The WGSL name of the texture.
pub id: String,
/// The PNG, a plain path in the pack.
pub file: String,
/// Bilinear filtering (else the nearest texel).
pub linear: bool,
/// Coordinates outside 0..1 repeat the image (else they clamp to its edges).
pub repeat: bool,
}
/// `[[lights]]`: the colour of the block light from some blocks, as the material stage reads
/// it through `block_light_color`.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct LightColor {
/// Each channel 0 to 4.
pub color: [f32; 3],
/// Block names such as `minecraft:soul_torch` (the namespace defaults to `minecraft`),
/// each with any number of `*` matching any run of characters.
pub blocks: Vec<String>,
}
/// A set of block sprites the material stage can tell apart: its bit in
/// `MaterialInput.tags` is its index in the manifest.
#[derive(Clone, Debug, Deserialize, PartialEq)]
@@ -97,33 +148,95 @@ pub struct SpriteTag {
impl SpriteTag {
/// Whether the sprite `name` (`namespace:path`) is one of the tag's.
pub fn matches(&self, name: &str) -> bool {
self.sprites.iter().any(|pattern| {
let pattern = match pattern.contains(':') {
true => std::borrow::Cow::Borrowed(pattern.as_str()),
false => std::borrow::Cow::Owned(format!("minecraft:{pattern}")),
};
match pattern.split_once('*') {
None => pattern == name,
Some((head, tail)) => {
name.len() >= head.len() + tail.len()
&& name.starts_with(head)
&& name.ends_with(tail)
}
}
})
self.sprites.iter().any(|p| sprite_matches(p, name))
}
}
/// `[[materials]]`: a number the material stage reads as `MaterialInput.material`, for the
/// many kinds of surface a pack treats differently, where `[[tags]]` hold 32 sets.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Material {
/// 1 to 65535 (0 is a sprite without an entry). Entries may share one.
pub id: u32,
/// Sprite names such as `minecraft:block/oak_leaves` (the namespace defaults to
/// `minecraft`), each with any number of `*` matching any run of characters.
pub sprites: Vec<String>,
}
impl Material {
/// Whether the sprite `name` (`namespace:path`) has this entry's id.
pub fn matches(&self, name: &str) -> bool {
self.sprites.iter().any(|p| sprite_matches(p, name))
}
}
/// Whether the sprite or block `name` (`namespace:path`) fits `pattern`: the namespace
/// defaults to `minecraft`, and each `*` matches any run of characters.
fn sprite_matches(pattern: &str, name: &str) -> bool {
let pattern = match pattern.contains(':') {
true => std::borrow::Cow::Borrowed(pattern),
false => std::borrow::Cow::Owned(format!("minecraft:{pattern}")),
};
let Some((head, rest)) = pattern.split_once('*') else {
return pattern == name;
};
let Some(mut left) = name.strip_prefix(head) else {
return false;
};
let (middle, tail) = rest.rsplit_once('*').unwrap_or(("", rest));
for part in middle.split('*').filter(|p| !p.is_empty()) {
let Some(at) = left.find(part) else {
return false;
};
left = &left[at + part.len()..];
}
left.ends_with(tail)
}
/// One stage: the WGSL file that defines its function.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Stage {
pub file: String,
/// WGSL files put before `file`, after the shared ones, for this stage only.
#[serde(default)]
pub files: Vec<String>,
/// Shown in the GPU time overlay; the file name when absent.
#[serde(default)]
pub name: Option<String>,
/// The material stage only: whether Horizon applies its fog after the pack's colour
/// (true when absent); false for a pack that fogs its surfaces itself.
#[serde(default)]
pub fog: Option<bool>,
/// The sky stage only: whether Minecraft's clouds still draw (true when absent); false for
/// a pack whose sky draws its own.
#[serde(default)]
pub clouds: Option<bool>,
/// The sky stage only: whether Minecraft's sun still draws over the sky (true when absent);
/// false for a pack whose sky draws its own. The moon stays.
#[serde(default)]
pub sun: Option<bool>,
/// The sky stage only: whether Minecraft's stars still draw over the sky (true when absent);
/// false for a pack whose sky draws its own.
#[serde(default)]
pub stars: Option<bool>,
/// A post pass only: its target's size against the world's, 1, 0.5 or 0.25 (1 when
/// absent); below 1 the pass writes an `output`.
#[serde(default)]
pub scale: Option<f32>,
/// A post pass only: the pack target it writes in place of the chain of passes. The
/// passes after it read that target by name, and the next one's `previous` is still the
/// chain's last picture.
#[serde(default)]
pub output: Option<String>,
}
/// The scales a post pass may draw at.
pub const POST_SCALES: [f32; 3] = [1.0, 0.5, 0.25];
/// The most targets a pack's post passes may write.
pub const MAX_POST_TARGETS: usize = 4;
/// An option the player can change: a WGSL `override` constant of the
/// same name, set when the pipelines are built.
#[derive(Clone, Debug, PartialEq)]
@@ -152,6 +265,31 @@ pub enum OptionKind {
Choice { values: Vec<String>, default: u32 },
}
/// `[[pages]]`: a page of the pack's settings screen.
#[derive(Clone, Debug, PartialEq)]
pub struct OptionPage {
/// What `page:<id>` items link to.
pub id: String,
/// The page's title, and the label of the buttons linking to it.
pub name: String,
/// The tooltip of the buttons linking to it.
pub description: String,
/// 1 or 2: full-width buttons, or two a row.
pub columns: u8,
pub items: Vec<PageItem>,
}
/// What a page shows in one slot, in order: left to right, then down.
#[derive(Clone, Debug, PartialEq)]
pub enum PageItem {
/// `"<id>"`: the option of that id.
Option(usize),
/// `"page:<id>"`: a button opening `Manifest::pages[i]`.
Page(usize),
/// `""`: an empty slot.
Empty,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Budget {
/// The GPU time the pack aims to add to a frame, in milliseconds; the
@@ -177,12 +315,22 @@ struct Raw {
sky: Option<Stage>,
material: Option<Stage>,
#[serde(default)]
weather: Option<Stage>,
#[serde(default)]
post: Vec<Stage>,
#[serde(default)]
options: Vec<RawOption>,
#[serde(default)]
pages: Vec<RawPage>,
#[serde(default)]
tags: Vec<SpriteTag>,
#[serde(default)]
materials: Vec<Material>,
#[serde(default)]
textures: Vec<RawTexture>,
#[serde(default)]
lights: Vec<LightColor>,
#[serde(default)]
budget: RawBudget,
reflection: Option<Reflection>,
shadow: Option<Shadow>,
@@ -210,6 +358,77 @@ struct RawBudget {
gpu_ms: Option<f64>,
}
/// `[[post]] scale` and `output`: scales from `POST_SCALES`, below 1 only with an output,
/// the last pass drawing the picture itself, outputs named as WGSL names no other resource
/// takes, each at one scale, `MAX_POST_TARGETS` at most.
fn check_post_targets(post: &[Stage]) -> Result<(), String> {
let reserved = [
"frame",
"scene_color",
"scene_depth",
"previous",
"linear_sampler",
"nearest_sampler",
"shadow_map",
"shadow_sampler",
"history",
"shadow_map_translucent",
"shadow_color",
"scene_surface",
];
let mut targets: Vec<(&str, f32)> = Vec::new();
for (i, p) in post.iter().enumerate() {
let scale = p.scale.unwrap_or(1.0);
if !POST_SCALES.contains(&scale) {
return Err(format!(
"{}: scale {scale} is not one of {POST_SCALES:?}",
p.file
));
}
if i + 1 == post.len() && (p.output.is_some() || scale != 1.0) {
return Err(format!(
"{}: the last [[post]] pass draws the picture: no `output` or `scale`",
p.file
));
}
let Some(name) = &p.output else {
if scale != 1.0 {
return Err(format!("{}: a scaled pass writes an `output`", p.file));
}
continue;
};
let valid = name.len() <= 32
&& name.starts_with(|c: char| c.is_ascii_lowercase())
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
&& !name.starts_with("horizon_");
if !valid || reserved.contains(&name.as_str()) {
return Err(format!(
"{}: output {name:?} is not a free WGSL name ([a-z][a-z0-9_]*, at most 32)",
p.file
));
}
match targets.iter().find(|t| t.0 == name) {
Some(&(_, s)) if s != scale => {
return Err(format!(
"{}: output {name:?} is written at two scales",
p.file
));
}
Some(_) => {}
None => targets.push((name, scale)),
}
}
if targets.len() > MAX_POST_TARGETS {
return Err(format!(
"{} [[post]] outputs; a pack may write {MAX_POST_TARGETS}",
targets.len()
));
}
Ok(())
}
impl Manifest {
pub fn parse(text: &str) -> Result<Manifest, String> {
let raw: Raw = toml::from_str(text).map_err(|e| format!("{MANIFEST}: {e}"))?;
@@ -237,23 +456,54 @@ impl Manifest {
if raw.name.trim().is_empty() || raw.version.trim().is_empty() {
return Err(err("name and version can't be empty".into()));
}
if raw.sky.is_none() && raw.material.is_none() && raw.post.is_empty() {
if raw.sky.is_none()
&& raw.material.is_none()
&& raw.weather.is_none()
&& raw.post.is_empty()
{
return Err(err(
"no stage: give it a [sky], a [material] or a [[post]]".into()
"no stage: give it a [sky], a [material], a [weather] or a [[post]]".into(),
));
}
if raw.sky.iter().chain(&raw.post).any(|s| s.fog.is_some()) {
return Err(err("`fog` is a key of [material] only".into()));
}
if raw
.sky
.iter()
.chain(&raw.material)
.any(|s| s.scale.is_some() || s.output.is_some())
{
return Err(err("`scale` and `output` are keys of [[post]] only".into()));
}
check_post_targets(&raw.post).map_err(err)?;
if raw.post.len() > MAX_POST_PASSES {
return Err(err(format!(
"{} post passes; a pack may chain {MAX_POST_PASSES}",
raw.post.len()
)));
}
let stages = raw.sky.iter().chain(&raw.material).chain(&raw.post);
for file in raw.shared.iter().chain(stages.map(|s| &s.file)) {
let stages = raw
.sky
.iter()
.chain(&raw.material)
.chain(&raw.weather)
.chain(&raw.post);
for file in raw
.shared
.iter()
.chain(stages.flat_map(|s| s.files.iter().chain(std::iter::once(&s.file))))
{
if !crate::package::plain_path(file) || !file.ends_with(".wgsl") {
return Err(err(format!("{file:?} is not a plain path to a .wgsl file")));
}
}
let sky_only = |s: &Stage| s.clouds.is_some() || s.sun.is_some() || s.stars.is_some();
if raw.material.iter().chain(&raw.post).any(sky_only) {
return Err(err(
"`clouds`, `sun` and `stars` are keys of [sky] only".into()
));
}
if raw.options.len() > MAX_OPTIONS {
return Err(err(format!(
"{} options; a pack may declare {MAX_OPTIONS}",
@@ -295,6 +545,101 @@ impl Manifest {
)));
}
}
let solid_ids = raw.shadow.iter().any(|s| !s.solid_materials.is_empty());
if !raw.materials.is_empty() && raw.material.is_none() && !solid_ids {
return Err(err(
"materials are read by the material stage and shadow.solid_materials: \
give it a [material] or use them there"
.into(),
));
}
if raw.materials.len() > MAX_MATERIALS {
return Err(err(format!(
"{} materials; a pack may declare {MAX_MATERIALS}",
raw.materials.len()
)));
}
for m in &raw.materials {
if !(1..=65535).contains(&m.id) {
return Err(err(format!("material id {} is not in 1..=65535", m.id)));
}
if m.sprites.is_empty() || m.sprites.iter().any(String::is_empty) {
return Err(err(format!("material {}: needs sprites", m.id)));
}
}
for id in raw.shadow.iter().flat_map(|s| &s.solid_materials) {
if !raw.materials.iter().any(|m| m.id == *id) {
return Err(err(format!(
"shadow.solid_materials: no [[materials]] entry has id {id}"
)));
}
}
let pages = check_pages(raw.pages, &options).map_err(err)?;
if raw.textures.len() > MAX_TEXTURES {
return Err(err(format!(
"{} [[textures]]; a pack may declare {MAX_TEXTURES}",
raw.textures.len()
)));
}
let mut textures: Vec<PackTexture> = Vec::new();
for t in raw.textures {
if !pack_identifier(&t.id) || textures.iter().any(|o| o.id == t.id) {
return Err(err(format!(
"texture id {:?} is not a WGSL identifier, or is declared twice",
t.id
)));
}
if !crate::package::plain_path(&t.file) || !t.file.ends_with(".png") {
return Err(err(format!(
"{:?} is not a plain path to a .png file",
t.file
)));
}
let linear = match t.filter.as_deref() {
None | Some("linear") => true,
Some("nearest") => false,
Some(f) => return Err(err(format!("texture filter {f:?}: linear or nearest"))),
};
let repeat = match t.wrap.as_deref() {
None | Some("repeat") => true,
Some("clamp") => false,
Some(w) => return Err(err(format!("texture wrap {w:?}: repeat or clamp"))),
};
textures.push(PackTexture {
id: t.id,
file: t.file,
linear,
repeat,
});
}
if !raw.lights.is_empty() && raw.material.is_none() {
return Err(err(
"lights are read by the material stage: give it a [material]".into(),
));
}
if raw.lights.len() > MAX_LIGHTS {
return Err(err(format!(
"{} [[lights]]; a pack may declare {MAX_LIGHTS}",
raw.lights.len()
)));
}
if let Some(l) = raw
.lights
.iter()
.find(|l| l.color.iter().any(|c| !(0.0..=4.0).contains(c)))
{
return Err(err(format!(
"light color {:?} is not in 0..=4 per channel",
l.color
)));
}
if raw
.lights
.iter()
.any(|l| l.blocks.is_empty() || l.blocks.iter().any(String::is_empty))
{
return Err(err("a [[lights]] entry needs blocks".into()));
}
let gpu_ms = match raw.budget.gpu_ms {
Some(ms) if !(ms > 0.0 && ms <= f64::from(MAX_GPU_MS)) => {
return Err(err(format!(
@@ -341,9 +686,14 @@ impl Manifest {
shared: raw.shared,
sky: raw.sky,
material: raw.material,
weather: raw.weather,
post: raw.post,
options,
pages,
tags: raw.tags,
materials: raw.materials,
textures,
lights: raw.lights,
budget: Budget { gpu_ms },
reflection: raw.reflection,
shadow: raw.shadow,
@@ -373,11 +723,64 @@ impl Manifest {
layers.into_iter().collect()
}
/// The material id of each block sprite `(name, layer)` that has one, as `(layer, id)` in
/// layer order: what the material stage's `MaterialInput.material` reads.
pub fn sprite_materials<'a>(
&self,
sprites: impl IntoIterator<Item = (&'a str, u32)>,
) -> Vec<(u32, u32)> {
// Identical stills share one layer under several names: the earliest entry any of
// them matches decides, whatever order the names come in.
let mut layers = std::collections::BTreeMap::<u32, usize>::new();
for (name, layer) in sprites {
if let Some(entry) = self.materials.iter().position(|m| m.matches(name)) {
let best = layers.entry(layer).or_insert(entry);
*best = entry.min(*best);
}
}
layers
.into_iter()
.map(|(layer, entry)| (layer, self.materials[entry].id))
.collect()
}
/// The `[[lights]]` colour of the block `name` (`namespace:path`): the first entry with a
/// matching pattern.
pub fn light_of(&self, name: &str) -> Option<[f32; 3]> {
self.lights
.iter()
.find(|l| l.blocks.iter().any(|pattern| sprite_matches(pattern, name)))
.map(|l| l.color)
}
/// The pack targets its post passes write (`[[post]] output`), in the order first
/// written, with their scale.
pub fn post_targets(&self) -> Vec<(&str, f32)> {
let mut out: Vec<(&str, f32)> = Vec::new();
for p in &self.post {
if let Some(name) = &p.output
&& !out.iter().any(|t| t.0 == name)
{
out.push((name, p.scale.unwrap_or(1.0)));
}
}
out
}
/// Every WGSL file the manifest names, shared files first, each once.
pub fn files(&self) -> Vec<&str> {
let mut files: Vec<&str> = Vec::new();
let stages = self.sky.iter().chain(&self.material).chain(&self.post);
for file in self.shared.iter().chain(stages.map(|s| &s.file)) {
let stages = self
.sky
.iter()
.chain(&self.material)
.chain(&self.weather)
.chain(&self.post);
for file in self
.shared
.iter()
.chain(stages.flat_map(|s| s.files.iter().chain(std::iter::once(&s.file))))
{
if !files.contains(&file.as_str()) {
files.push(file);
}
@@ -396,6 +799,142 @@ pub fn pack_identifier(name: &str) -> bool {
&& !name.starts_with("horizon_")
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawPage {
id: String,
name: String,
#[serde(default)]
description: String,
#[serde(default = "default_columns")]
columns: u8,
items: Vec<String>,
}
fn default_columns() -> u8 {
2
}
/// `[[pages]]` checked against the options: known ids, each page's own id once, links to
/// other pages only, and every page and option reached from the first page.
fn check_pages(raw: Vec<RawPage>, options: &[PackOption]) -> Result<Vec<OptionPage>, String> {
let pages = read_pages(&raw, options)?;
if pages.is_empty() {
return Ok(pages);
}
let (mut seen, mut next, mut shown) = (
vec![false; pages.len()],
vec![0],
vec![false; options.len()],
);
seen[0] = true;
while let Some(at) = next.pop() {
for item in &pages[at].items {
match *item {
PageItem::Option(i) => shown[i] = true,
PageItem::Page(i) if !seen[i] => {
seen[i] = true;
next.push(i);
}
_ => {}
}
}
}
if let Some(i) = seen.iter().position(|s| !s) {
return Err(format!(
"page {:?} can't be reached from the first page, {:?}",
pages[i].id, pages[0].id
));
}
if let Some(i) = shown.iter().position(|s| !s) {
return Err(format!(
"option {:?} is on no page the settings reach",
options[i].id
));
}
Ok(pages)
}
fn read_pages(raw: &[RawPage], options: &[PackOption]) -> Result<Vec<OptionPage>, String> {
if raw.len() > MAX_PAGES {
return Err(format!(
"{} pages; a pack may declare {MAX_PAGES}",
raw.len()
));
}
for (i, page) in raw.iter().enumerate() {
if !pack_identifier(&page.id) {
return Err(format!(
"page {:?}: the id must be a letter, then letters, digits or _, up to 64",
page.id
));
}
if raw[..i].iter().any(|p| p.id == page.id) {
return Err(format!("page {:?} is declared twice", page.id));
}
}
raw.iter()
.enumerate()
.map(|(at, page)| {
let fail = |e: String| format!("page {:?}: {e}", page.id);
if page.name.trim().is_empty() {
return Err(fail("needs a name".into()));
}
if !(1..=2).contains(&page.columns) {
return Err(fail(format!("columns = {}; 1 or 2", page.columns)));
}
if page.items.len() > MAX_PAGE_ITEMS {
return Err(fail(format!(
"{} items; a page may hold {MAX_PAGE_ITEMS}",
page.items.len()
)));
}
let mut items = Vec::with_capacity(page.items.len());
for item in &page.items {
let item = if item.is_empty() {
PageItem::Empty
} else if let Some(link) = item.strip_prefix("page:") {
match raw.iter().position(|p| p.id == link) {
Some(i) if i == at => return Err(fail("links to itself".into())),
Some(i) => PageItem::Page(i),
None => return Err(fail(format!("no page {link:?} to link to"))),
}
} else {
match options.iter().position(|o| o.id == *item) {
Some(i) => PageItem::Option(i),
None => return Err(fail(format!("no option {item:?}"))),
}
};
if item != PageItem::Empty && items.contains(&item) {
return Err(fail(format!(
"{:?} is on the page twice",
page.items[items.len()]
)));
}
items.push(item);
}
Ok(OptionPage {
id: page.id.clone(),
name: page.name.clone(),
description: page.description.clone(),
columns: page.columns,
items,
})
})
.collect()
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTexture {
id: String,
file: String,
#[serde(default)]
filter: Option<String>,
#[serde(default)]
wrap: Option<String>,
}
impl RawOption {
fn check(self) -> Result<PackOption, String> {
if !pack_identifier(&self.id) {
+17 -2
View File
@@ -111,11 +111,14 @@ impl Pack {
return Err(format!("{name}: not UTF-8"));
}
MAX_WGSL
} else if name == MANIFEST || EXTRAS.contains(&name.as_str()) {
} else if name == MANIFEST
|| EXTRAS.contains(&name.as_str())
|| manifest.textures.iter().any(|t| &t.file == name)
{
MAX_OTHER
} else {
return Err(format!(
"{name}: a pack holds only {MANIFEST}, .wgsl files and {}",
"{name}: a pack holds only {MANIFEST}, .wgsl files, its [[textures]] and {}",
EXTRAS.join(", ")
));
};
@@ -126,12 +129,24 @@ impl Pack {
if total > MAX_TOTAL {
return Err(format!("the pack is over {} MiB", MAX_TOTAL >> 20));
}
if let Some(t) = manifest
.textures
.iter()
.find(|t| !files.contains_key(&t.file))
{
return Err(format!("{}: named in {MANIFEST} but missing", t.file));
}
if let Some(missing) = used.iter().find(|f| !files.contains_key(**f)) {
return Err(format!("{missing}: named in {MANIFEST} but missing"));
}
Ok(Pack { manifest, files })
}
/// The bytes of the pack's file `file` (a `[[textures]]` image).
pub fn source_bytes(&self, file: &str) -> Option<&[u8]> {
self.files.get(file).map(Vec::as_slice)
}
/// A WGSL file's text (every file the manifest names is checked UTF-8).
pub fn source(&self, file: &str) -> Option<&str> {
self.files
+20 -3
View File
@@ -45,6 +45,7 @@ pub fn markdown() -> String {
StageKind::Post => "[[post]]",
StageKind::Sky => "[sky]",
StageKind::Material => "[material]",
StageKind::Weather => "[weather]",
};
out += &format!("| {} | `{key}` | `{}` |\n", kind.name(), kind.signature());
}
@@ -62,20 +63,36 @@ pub fn markdown() -> String {
out += &format!("| {} | `{}` | {} |\n", r.binding, r.ty, r.doc);
}
out += "\n";
if kind != StageKind::Sky {
out += "Helpers:\n\n";
}
if kind == StageKind::Post {
out += "Helpers:\n\n\
- `fn depth_at(uv: vec2<f32>) -> f32`: the world's depth at `uv`.\n\
out += "- `fn depth_at(uv: vec2<f32>) -> f32`: the world's depth at `uv`.\n\
- `fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32>`: the camera-relative position, in blocks, of\n \
the point at `uv` and `depth`.\n";
}
if kind == StageKind::Material {
out += "Helpers:\n\n";
out += "- `fn block_pbr_maps() -> bool`: whether the resource packs ship LabPBR maps (`_n`, `_s`).\n\
- `fn block_normal_map(uv: vec2<f32>) -> vec4<f32>`: the surface's LabPBR normal map at `uv` (rg the \
normal along `input.tangent` and down the texture, b ambient occlusion, a height); (0.5, 0.5, 1, 1) \
without one.\n\
- `fn block_specular_map(uv: vec2<f32>) -> vec4<f32>`: its LabPBR specular map (r smoothness, g F0 or a \
metal, b porosity or subsurface, a emission, 1 none); (0, 0, 0, 0) without one.\n\
- `fn block_normal_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32>`, \
`fn block_specular_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32>`: the same at mip level `lod`.\n\
- `fn block_pbr_size() -> vec2<f32>`: the LabPBR maps' size in texels.\n";
}
if kind != StageKind::Sky {
out += "- `fn shadow_position(position: vec3<f32>) -> vec3<f32>`: where the camera-relative point `position` is\n \
stored in the shadow map, for `textureSampleCompareLevel`.\n\
- `fn shadow_distort(xy: vec2<f32>) -> vec2<f32>`: the map's distortion of a point at shadow clip `xy`.\n\n";
}
if kind == StageKind::Post {
out += "A `[[post]]` entry with `output = \"<name>\"` writes a target of the pack's own, at its `scale` of the\n\
world's size (1, 0.5 or 0.25), instead of the next pass's `previous`; the prelude of every pass declares\n\
`var <name>: texture_2d<f32>` (group 3 from binding 32), which holds last frame's until written again (a\n\
pass reads a blank one in place of the target it writes).\n\n";
}
}
out += "## The frame\n\n";
out += &table(&Frame::WGSL, Some(Frame::OFFSETS));
+174 -13
View File
@@ -2,7 +2,7 @@
//! that Horizon calls inside its own passes, and the WGSL put around a
//! pack's files to build a stage: the prelude before (the inputs and the
//! resources), the entry points after.
use crate::inputs::{Frame, MATERIAL_INPUT, POST_INPUT, SKY_INPUT, SURFACE, Struct};
use crate::inputs::{Frame, MATERIAL_INPUT, POST_INPUT, SKY_INPUT, SURFACE, Struct, WEATHER_INPUT};
use crate::package::Pack;
/// The bind group of a pack's resources; groups 0 to 2 are the renderer's.
@@ -14,6 +14,8 @@ pub enum StageKind {
Sky,
/// The colour of block surfaces (terrain), before the alpha test and fog.
Material,
/// The colour of rain and snow streaks.
Weather,
/// A full-screen pass over the world's picture, under the hand and HUD.
Post,
}
@@ -40,7 +42,7 @@ const fn shadow_map(binding: u32) -> Resource {
binding,
name: "shadow_map",
ty: "var shadow_map: texture_depth_2d",
doc: "This frame's shadow map: the depth of the solid terrain the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything.",
doc: "This frame's shadow map: the depth of the solid terrain and entities the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything.",
}
}
@@ -137,7 +139,7 @@ const POST_RESOURCES: [Resource; 11] = [
/// The material stage's resources: the frame, and the world as it was before translucent
/// terrain drew (for water and glass to see what lies behind them).
const MATERIAL_RESOURCES: [Resource; 9] = [
const MATERIAL_RESOURCES: [Resource; 11] = [
FRAME,
Resource {
binding: 1,
@@ -161,6 +163,18 @@ const MATERIAL_RESOURCES: [Resource; 9] = [
ty: "var opaque_depth: texture_depth_2d",
doc: "The world's depth before translucent terrain (reversed, like `scene_depth`), for the translucent pass: `textureLoad(opaque_depth, vec2<i32>(input.screen * frame.resolution), 0)` is what lies behind water or glass. Stale while solid terrain draws.",
},
Resource {
binding: 9,
name: "block_light_colors",
ty: "var block_light_colors: texture_3d<f32>",
doc: "The colour of the block light around the camera, from the `[[lights]]` of the light sources nearby: rgb their colours averaged by how near each is, over 4; a how strongly they reach the cell. Read it through `block_light_color`.",
},
Resource {
binding: 10,
name: "block_light_grid",
ty: "var<uniform> block_light_grid: vec4<f32>",
doc: "Where `block_light_colors` lies: xyz its corner, camera-relative, w the side of one of its cells in blocks.",
},
Resource {
binding: 11,
name: "opaque_color",
@@ -188,6 +202,52 @@ fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
/// What the material entries (the checker's below and the renderer's) share: the
/// surface's normal and tangent from the derivatives of its position and uv.
const MATERIAL_HELPERS: &str = "\
// The colour of the block light at the camera-relative `position` (rgb, from the `[[lights]]`
// of the sources nearby) and how strongly they reach it (a, 0 where none does). Block light
// keeps its own brightness (`input.light.x`): this is its colour only.
fn block_light_color(position: vec3<f32>) -> vec4<f32> {
let size = vec3<f32>(textureDimensions(block_light_colors));
let uvw = (position - block_light_grid.xyz) / (block_light_grid.w * size);
if any(uvw < vec3<f32>(0.0)) || any(uvw > vec3<f32>(1.0)) {
return vec4<f32>(0.0);
}
let c = textureSampleLevel(block_light_colors, reflection_sampler, uvw, 0.0);
return vec4<f32>(c.rgb * 4.0, c.a);
}
// The point being shaded: its layer and mip level in the LabPBR maps (the entries set them).
var<private> horizon_block_layer: u32;
var<private> horizon_block_lod: f32;
// LabPBR: whether the resource packs ship normal (`_n`) or specular (`_s`) maps beside their
// block textures. Without them `block_normal_map` and `block_specular_map` give the defaults
// below.
fn block_pbr_maps() -> bool {
return horizon_block_pbr_present();
}
// The surface's LabPBR normal map at `uv` (the sprite's space, as `input.uv`): rg the normal's
// x and y in the texture's space (x along `input.tangent`, y down the texture; 0..1 for
// -1..1), b ambient occlusion (1 none), a height (1 the top). (0.5, 0.5, 1, 1) where a texture
// has none.
fn block_normal_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 0u);
}
// `block_normal_map` at mip level `lod` (0 the full-size map).
fn block_normal_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 0u);
}
// The surface's LabPBR specular map at `uv`: r perceptual smoothness, g F0 up to 229/255 and
// a metal's id above, b porosity or subsurface scattering, a emission (up to 254/255; 1
// none). (0, 0, 0, 0) where a texture has none.
fn block_specular_map(uv: vec2<f32>) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, horizon_block_lod, 1u);
}
// `block_specular_map` at mip level `lod`.
fn block_specular_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32> {
return horizon_block_pbr(uv, horizon_block_layer, lod, 1u);
}
// The LabPBR maps' size, in texels (they may be smaller than the block's texture).
fn block_pbr_size() -> vec2<f32> {
return horizon_block_pbr_size();
}
// Block faces are flat: the derivatives of the position give the face's normal, turned
// toward the camera. Far away they lose precision, so a normal within about 3.6 degrees of
// an axis (every full block face) is that axis exactly: no shimmer on distant faces. The
@@ -240,9 +300,29 @@ struct HorizonPostOut {
}
";
/// What the renderer gives the material prelude's LabPBR functions (`native_material.rs`); the
/// check and the entries below put a stand-in in its place.
macro_rules! pbr_stub {
() => {
"fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
fn horizon_block_pbr_present() -> bool {
return false;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(16.0);
}
"
};
}
pub const PBR_STUB: &str = pbr_stub!();
/// Entry points with the material stage's inputs: the renderer calls
/// `material` from its own terrain shader in the same way.
const MATERIAL_ENTRIES: &str = "\
const MATERIAL_ENTRIES: &str = concat!(
pbr_stub!(),
"\
struct HorizonMaterialIn {
@builtin(position) position: vec4<f32>,
@location(0) albedo: vec4<f32>,
@@ -256,16 +336,18 @@ struct HorizonMaterialIn {
@location(8) @interpolate(flat) face: u32,
@location(9) @interpolate(flat) fluid_quad: u32,
@location(10) @interpolate(flat) emission: f32,
@location(11) @interpolate(flat) material: u32,
}
@fragment fn horizon_fs_material(in: HorizonMaterialIn) -> @location(0) vec4<f32> {
let fluid = select(0u, select(2u, 1u, in.translucent != 0u), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.world);
let tangent = horizon_material_tangent(in.world, in.uv, normal);
let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags,
let input = MaterialInput(in.albedo, in.tint, in.light, in.lightmap, in.world, in.uv, in.translucent != 0u, in.tags, in.material,
in.position.xy / frame.resolution, in.face, fluid, in.emission, normal, tangent);
return material(input).color;
}
";
",
);
/// Entry points with the sky stage's inputs: the renderer calls `sky` from
/// its own sky shader in the same way.
@@ -280,14 +362,35 @@ struct HorizonSkyIn {
}
";
impl StageKind {
pub const ALL: [StageKind; 3] = [StageKind::Sky, StageKind::Material, StageKind::Post];
/// An entry point with the weather stage's inputs: the renderer calls `weather` from its own
/// weather shader in the same way.
const WEATHER_ENTRIES: &str = "\
struct HorizonWeatherIn {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) light: vec2<f32>,
@location(2) world: vec3<f32>,
@location(3) @interpolate(flat) snow: u32,
}
@fragment fn horizon_fs_weather(in: HorizonWeatherIn) -> @location(0) vec4<f32> {
return weather(WeatherInput(in.color, in.light, in.world, in.snow != 0u));
}
";
/// The manifest's name for the stage (`[sky]`, `[material]`, `[[post]]`).
impl StageKind {
pub const ALL: [StageKind; 4] = [
StageKind::Sky,
StageKind::Material,
StageKind::Weather,
StageKind::Post,
];
/// The manifest's name for the stage (`[sky]`, `[material]`, `[weather]`, `[[post]]`).
pub fn name(self) -> &'static str {
match self {
StageKind::Sky => "sky",
StageKind::Material => "material",
StageKind::Weather => "weather",
StageKind::Post => "post",
}
}
@@ -301,6 +404,7 @@ impl StageKind {
match self {
StageKind::Sky => &SKY_INPUT,
StageKind::Material => &MATERIAL_INPUT,
StageKind::Weather => &WEATHER_INPUT,
StageKind::Post => &POST_INPUT,
}
}
@@ -309,7 +413,7 @@ impl StageKind {
pub fn output(self) -> &'static str {
match self {
StageKind::Material => SURFACE.name,
StageKind::Sky | StageKind::Post => "vec4<f32>",
StageKind::Sky | StageKind::Weather | StageKind::Post => "vec4<f32>",
}
}
@@ -326,7 +430,7 @@ impl StageKind {
pub fn resources(self) -> &'static [Resource] {
match self {
StageKind::Post => &POST_RESOURCES,
StageKind::Sky => std::slice::from_ref(&FRAME),
StageKind::Sky | StageKind::Weather => std::slice::from_ref(&FRAME),
StageKind::Material => &MATERIAL_RESOURCES,
}
}
@@ -352,7 +456,7 @@ impl StageKind {
out += MATERIAL_HELPERS;
out += SHADOW_HELPERS;
}
StageKind::Sky => {}
StageKind::Sky | StageKind::Weather => {}
}
out
}
@@ -362,11 +466,33 @@ impl StageKind {
match self {
StageKind::Sky => SKY_ENTRIES,
StageKind::Material => MATERIAL_ENTRIES,
StageKind::Weather => WEATHER_ENTRIES,
StageKind::Post => POST_ENTRIES,
}
}
}
/// The first binding of a pack's `[[textures]]` in group 3; their samplers follow at
/// `TEXTURE_SAMPLER_BINDING`.
pub const TEXTURE_BINDING: u32 = 16;
pub const TEXTURE_SAMPLER_BINDING: u32 = 24;
/// The declarations of a pack's `[[textures]]`, part of every stage's prelude.
pub fn texture_declarations(textures: &[crate::manifest::PackTexture]) -> String {
let mut out = String::new();
for (i, t) in textures.iter().enumerate() {
out += &format!(
"@group({GROUP}) @binding({}) var {}: texture_2d<f32>;\n\
@group({GROUP}) @binding({}) var {}_sampler: sampler;\n",
TEXTURE_BINDING + i as u32,
t.id,
TEXTURE_SAMPLER_BINDING + i as u32,
t.id
);
}
out
}
/// A stage's WGSL, and where each part of it came from.
#[derive(Debug)]
pub struct Composed {
@@ -391,6 +517,24 @@ impl Composed {
}
}
/// The binding of a pack's first post target (`[[post]] output`) in group 3, the others after.
pub const POST_TARGET_BINDING: u32 = 32;
/// The declarations of a pack's post targets, part of the post stage's prelude.
pub fn post_target_declarations(manifest: &crate::manifest::Manifest) -> String {
manifest
.post_targets()
.iter()
.enumerate()
.map(|(i, (name, _))| {
format!(
"@group({GROUP}) @binding({}) var {name}: texture_2d<f32>;\n",
POST_TARGET_BINDING + i as u32
)
})
.collect()
}
/// The WGSL of the stage `kind` whose own file is `file`: the prelude, the
/// pack's shared files in order, the file, then the entry points.
pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
@@ -408,10 +552,27 @@ pub fn compose(pack: &Pack, kind: StageKind, file: &str) -> Composed {
.parts
.push((name.to_owned(), start..composed.text.len()));
};
push("<prelude>", &kind.prelude());
let mut prelude = kind.prelude() + &texture_declarations(&pack.manifest.textures);
if kind == StageKind::Post {
prelude += &post_target_declarations(&pack.manifest);
}
push("<prelude>", &prelude);
for shared in pack.manifest.shared.iter().filter(|s| *s != file) {
push(shared, pack.source(shared).unwrap_or_default());
}
// The stage's own extra files, between the shared ones and its file.
let m = &pack.manifest;
let stage = match kind {
StageKind::Sky => m.sky.as_ref(),
StageKind::Material => m.material.as_ref(),
StageKind::Weather => m.weather.as_ref(),
StageKind::Post => m.post.iter().find(|s| s.file == file),
};
for extra in stage.into_iter().flat_map(|s| &s.files) {
if extra != file && !m.shared.contains(extra) {
push(extra, pack.source(extra).unwrap_or_default());
}
}
push(file, pack.source(file).unwrap_or_default());
push("<entries>", kind.entries());
composed
+25
View File
@@ -167,3 +167,28 @@ fn reads_follows_the_stage_function_through_its_calls() {
assert!(mirror.reads("reflection_sampler"));
assert!(!mirror.reads("opaque_color"));
}
/// A stage's `files` come before its own file, and no other stage sees them.
#[test]
fn a_stages_files_are_its_own() {
let mut pack = sample();
pack.files.insert(
"only_post.wgsl".into(),
b"fn only_post() -> f32 { return 1.0; }\n".to_vec(),
);
pack.manifest.post[0].files.push("only_post.wgsl".into());
let file = pack.manifest.post[0].file.clone();
let post = horizon_shader_api::compose(&pack, horizon_shader_api::StageKind::Post, &file);
let names: Vec<_> = post.parts.iter().map(|p| p.0.as_str()).collect();
let (extra, own) = (
names.iter().position(|n| *n == "only_post.wgsl").unwrap(),
names.iter().position(|n| *n == file).unwrap(),
);
assert!(extra < own, "{names:?}");
let material = pack.manifest.material.clone().unwrap().file;
let other =
horizon_shader_api::compose(&pack, horizon_shader_api::StageKind::Material, &material);
assert!(other.parts.iter().all(|p| p.0 != "only_post.wgsl"));
assert!(pack.manifest.files().contains(&"only_post.wgsl"));
check(&pack).unwrap();
}
@@ -31,6 +31,9 @@ fn vanilla(kind: StageKind) -> &'static str {
"fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4<f32>(input.lightmap, 1.0));\n}\n"
}
StageKind::Weather => {
"fn weather(input: WeatherInput) -> vec4<f32> { return input.color; }\n"
}
StageKind::Post => "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n",
}
}
+300 -1
View File
@@ -1,5 +1,5 @@
//! Schema 1 of `horizon-shaders.toml` and the `.hshader` package.
use horizon_shader_api::{MANIFEST, Manifest, OptionKind, Pack};
use horizon_shader_api::{MANIFEST, Manifest, OptionKind, Pack, PageItem};
use std::{collections::BTreeMap, path::Path};
fn sample_dir() -> &'static Path {
@@ -115,6 +115,66 @@ fn a_shadow_map_stays_in_range_and_needs_a_stage_to_read_it() {
assert!(m.unwrap_err().to_string().contains("[shadow] needs"));
}
#[test]
fn material_ids_go_by_the_first_entry_and_may_cut_shadows_whole() {
let manifest = |rest: &str| {
Manifest::parse(&format!(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"m.wgsl\"\n{rest}"
))
};
let m = manifest(
"[shadow]\nsolid_materials = [7]\n\
[[materials]]\nid = 7\nsprites = [\"block/oak_leaves\", \"block/*_*_leaves\"]\n\
[[materials]]\nid = 8\nsprites = [\"block/*_leaves\", \"mod:*\"]\n",
)
.unwrap();
assert_eq!(m.shadow.as_ref().unwrap().solid_materials, [7]);
let sprites = [
("minecraft:block/dark_oak_leaves", 1),
("minecraft:block/birch_leaves", 2),
// One layer under two names, in either order: the earlier entry decides.
("minecraft:block/birch_leaves", 3),
("minecraft:block/oak_leaves", 3),
("mod:anything", 4),
("minecraft:block/stone", 5),
];
assert_eq!(
m.sprite_materials(sprites),
[(1, 7), (2, 8), (3, 7), (4, 8)]
);
let refused = |rest: &str, needle: &str| {
let e = manifest(rest).unwrap_err().to_string();
assert!(e.contains(needle), "{e}");
};
refused("[[materials]]\nid = 0\nsprites = [\"a\"]\n", "1..=65535");
refused(
"[[materials]]\nid = 65536\nsprites = [\"a\"]\n",
"1..=65535",
);
refused("[[materials]]\nid = 1\nsprites = []\n", "needs sprites");
refused(
"[shadow]\nsolid_materials = [3]\n",
"no [[materials]] entry",
);
// Without a material stage, only the shadow pass can read the ids.
let post_only = |rest: &str| {
Manifest::parse(&format!(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"p.wgsl\"\n[[materials]]\nid = 7\nsprites = [\"a\"]\n{rest}"
))
};
assert!(post_only("[shadow]\nsolid_materials = [7]\n").is_ok());
assert!(
post_only("")
.unwrap_err()
.to_string()
.contains("materials are read")
);
}
#[test]
fn sprite_tags_name_sprites_with_one_wildcard() {
let mut m = Manifest::parse(&sample_text()).unwrap();
@@ -305,3 +365,242 @@ fn refuses_files_a_pack_does_not_use() {
.contains("missing at the pack's root")
);
}
#[test]
fn fog_is_a_key_of_the_material_stage() {
let with_fog = |stage: &str| {
format!(
"schema = 1\nid = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\napi = \"0.1\"\n\n{stage}\nfile = \"a.wgsl\"\nfog = false\n"
)
};
let material = horizon_shader_api::Manifest::parse(&with_fog("[material]")).unwrap();
assert_eq!(material.material.unwrap().fog, Some(false));
for stage in ["[sky]", "[[post]]"] {
let e = horizon_shader_api::Manifest::parse(&with_fog(stage)).unwrap_err();
assert!(e.contains("`fog`"), "{e}");
}
}
#[test]
fn a_sky_can_keep_or_replace_minecrafts_clouds_sun_and_stars() {
let manifest = |stages: &str| {
Manifest::parse(&format!(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n{stages}"
))
};
let m =
manifest("[sky]\nfile = \"s.wgsl\"\nclouds = false\nsun = false\nstars = true\n").unwrap();
let sky = m.sky.unwrap();
assert_eq!(
(sky.clouds, sky.sun, sky.stars),
(Some(false), Some(false), Some(true))
);
let m = manifest("[sky]\nfile = \"s.wgsl\"\n").unwrap();
assert_eq!(m.sky.unwrap().sun, None);
for key in ["clouds", "sun", "stars"] {
let e = manifest(&format!("[material]\nfile = \"m.wgsl\"\n{key} = false\n"));
assert!(e.unwrap_err().to_string().contains("keys of [sky] only"));
}
}
#[test]
fn reads_settings_pages_and_refuses_broken_links() {
let pages = "[[pages]]\nid = \"main\"\nname = \"Sample\"\nitems = [\"saturation\", \"\", \"page:more\"]\n\
[[pages]]\nid = \"more\"\nname = \"More\"\ndescription = \"The rest.\"\ncolumns = 1\n\
items = [\"tonemap\", \"samples\", \"vignette\", \"reflections\"]\n";
let m = Manifest::parse(&format!("{}{pages}", sample_text())).unwrap();
assert_eq!(m.pages.len(), 2);
assert_eq!(
m.pages[0].items,
[PageItem::Option(0), PageItem::Empty, PageItem::Page(1)]
);
assert_eq!(
(m.pages[1].columns, m.pages[1].description.as_str()),
(1, "The rest.")
);
assert_eq!(
m.pages[1].items,
[
PageItem::Option(4),
PageItem::Option(3),
PageItem::Option(2),
PageItem::Option(1)
]
);
// Without pages, the settings show every option on one.
assert!(Manifest::parse(&sample_text()).unwrap().pages.is_empty());
let cases = [
(
pages.replace("\"page:more\"", "\"page:less\""),
"no page \"less\"",
),
(
pages.replace("\"page:more\"", "\"page:main\""),
"links to itself",
),
(
pages.replace("\"tonemap\"", "\"tone\""),
"no option \"tone\"",
),
(
pages.replace("\"samples\",", "\"tonemap\","),
"on the page twice",
),
(pages.replace("columns = 1", "columns = 3"), "1 or 2"),
(
pages.replace("id = \"more\"", "id = \"main\""),
"declared twice",
),
(
pages.replace(", \"reflections\"]", "]"),
"option \"reflections\" is on no page",
),
(
pages.replace("\"page:more\"", "\"\""),
"page \"more\" can't be reached",
),
];
for (pages, needle) in cases {
let e = refused(&format!("{}{pages}", sample_text()));
assert!(e.contains(needle), "{needle:?} not in {e:?}");
}
}
#[test]
fn a_weather_stage_is_a_stage_of_its_own() {
let m = Manifest::parse(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[weather]\nfile = \"w.wgsl\"\n",
)
.unwrap();
assert_eq!(m.weather.as_ref().unwrap().file, "w.wgsl");
assert_eq!(m.files(), ["w.wgsl"]);
}
fn png_bytes() -> Vec<u8> {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 2, 1);
encoder.set_color(png::ColorType::Rgb);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[255, 0, 0, 0, 255, 0]).unwrap();
writer.finish().unwrap();
bytes
}
#[test]
fn a_pack_texture_is_declared_for_every_stage_and_decoded() {
let head = "schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[sky]\nfile = \"s.wgsl\"\n";
let texture = "[[textures]]\nid = \"noise\"\nfile = \"noise.png\"\nwrap = \"clamp\"\n";
let dir = std::env::temp_dir().join(format!("horizon-pack-texture-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(MANIFEST), format!("{head}{texture}")).unwrap();
std::fs::write(
dir.join("s.wgsl"),
"fn sky(input: SkyInput) -> vec4<f32> {\n \
return textureSampleLevel(noise, noise_sampler, input.direction.xy, 0.0);\n}\n",
)
.unwrap();
// Named but missing, then present.
assert!(Pack::read(&dir).unwrap_err().contains("noise.png"));
std::fs::write(dir.join("noise.png"), png_bytes()).unwrap();
let pack = Pack::read(&dir).unwrap();
let t = &pack.manifest.textures[0];
assert!(t.linear && !t.repeat);
let report = horizon_shader_api::check(&pack).unwrap();
let image = &report.textures[0].1;
assert_eq!((image.width, image.height), (2, 1));
assert_eq!(image.rgba, [255, 0, 0, 255, 0, 255, 0, 255]);
// A file that is no PNG is the check's refusal, not a panic.
std::fs::write(dir.join("noise.png"), b"nope").unwrap();
let pack = Pack::read(&dir).unwrap();
assert!(horizon_shader_api::check(&pack).is_err());
std::fs::remove_dir_all(&dir).unwrap();
let refused = |rest: &str, needle: &str| {
let e = Manifest::parse(&format!("{head}{rest}"))
.unwrap_err()
.to_string();
assert!(e.contains(needle), "{e}");
};
refused(&texture.replace("noise.png", "noise.jpg"), ".png");
refused(&format!("{texture}{texture}"), "declared twice");
refused(&texture.replace("clamp", "mirror"), "repeat or clamp");
refused(
&format!("{texture}filter = \"cubic\"\n"),
"linear or nearest",
);
}
#[test]
fn a_block_takes_the_colour_of_the_first_light_that_names_it() {
let manifest = |rest: &str| {
Manifest::parse(&format!(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[material]\nfile = \"m.wgsl\"\n{rest}"
))
};
let m = manifest(
"[[lights]]\ncolor = [0.2, 0.8, 1.0]\nblocks = [\"soul_*\"]\n\
[[lights]]\ncolor = [1.0, 0.5, 0.1]\nblocks = [\"*torch\", \"mod:*_*\"]\n",
)
.unwrap();
assert_eq!(m.light_of("minecraft:soul_torch"), Some([0.2, 0.8, 1.0]));
assert_eq!(m.light_of("minecraft:wall_torch"), Some([1.0, 0.5, 0.1]));
assert_eq!(m.light_of("mod:glow_stick"), Some([1.0, 0.5, 0.1]));
assert_eq!(m.light_of("mod:glow"), None);
assert_eq!(m.light_of("minecraft:stone"), None);
let refused = |rest: &str, needle: &str| {
let e = manifest(rest).unwrap_err().to_string();
assert!(e.contains(needle), "{e}");
};
refused(
"[[lights]]\ncolor = [5.0, 0.0, 0.0]\nblocks = [\"a\"]\n",
"0..=4",
);
refused(
"[[lights]]\ncolor = [1.0, 0.0, 0.0]\nblocks = []\n",
"needs blocks",
);
let e = Manifest::parse(
"schema = 1\nid = \"a\"\nname = \"A\"\nversion = \"1\"\napi = \"0.1\"\n\
[[post]]\nfile = \"p.wgsl\"\n[[lights]]\ncolor = [1.0, 0.0, 0.0]\nblocks = [\"a\"]\n",
);
assert!(e.unwrap_err().to_string().contains("give it a [material]"));
}
#[test]
fn post_outputs_are_named_scaled_and_never_the_last_pass() {
let head = "schema = 1\nid = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\napi = \"0.1\"\n";
let pack = |passes: &str| horizon_shader_api::Manifest::parse(&format!("{head}{passes}"));
let good = pack(
"[[post]]\nfile = \"a.wgsl\"\noutput = \"bloom\"\nscale = 0.5\n\
[[post]]\nfile = \"b.wgsl\"\n",
)
.unwrap();
assert_eq!(good.post_targets(), [("bloom", 0.5)]);
for (passes, why) in [
("[[post]]\nfile = \"a.wgsl\"\noutput = \"bloom\"\n", "last"),
(
"[[post]]\nfile = \"a.wgsl\"\nscale = 0.5\n[[post]]\nfile = \"b.wgsl\"\n",
"output",
),
(
"[[post]]\nfile = \"a.wgsl\"\noutput = \"b\"\nscale = 0.3\n[[post]]\nfile = \"b.wgsl\"\n",
"scale",
),
(
"[[post]]\nfile = \"a.wgsl\"\noutput = \"scene_color\"\n[[post]]\nfile = \"b.wgsl\"\n",
"free",
),
(
"[material]\nfile = \"a.wgsl\"\noutput = \"b\"\n",
"[[post]]",
),
] {
let e = pack(passes).unwrap_err();
assert!(e.contains(why), "{passes}: {e}");
}
}
@@ -0,0 +1,418 @@
//! A block state's properties: a few name/value pairs, sorted by name, read
//! like the `BTreeMap<String, String>` they replace. A registry's states share
//! their strings (35,723 vanilla states hold 158,409 properties made of 237
//! distinct strings): one string for the whole process instead of a B-tree
//! leaf (544 bytes) and two allocations per property. Shared strings are
//! never freed, nor counted: cloning a state copies pointers, so world
//! generation threads cloning the same states don't contend on counters.
use std::{
borrow::Borrow,
cmp::Ordering,
collections::BTreeMap,
fmt,
hash::{Hash, Hasher},
ops::Deref,
sync::Mutex,
};
/// A property name or value: owned, or one string a registry shares.
#[derive(Clone)]
enum Text {
Owned(String),
Shared(&'static String),
}
impl Deref for Text {
type Target = String;
fn deref(&self) -> &String {
match self {
Self::Owned(s) => s,
Self::Shared(s) => s,
}
}
}
impl Text {
fn into_string(self) -> String {
match self {
Self::Owned(s) => s,
Self::Shared(s) => s.clone(),
}
}
fn to_mut(&mut self) -> &mut String {
if let Self::Shared(s) = self {
*self = Self::Owned((**s).clone());
}
match self {
Self::Owned(s) => s,
Self::Shared(_) => unreachable!(),
}
}
}
type Interned = horizon_core::fast_hash::FxHashMap<&'static str, &'static String>;
/// Every string a registry's states share, for the process: registries'
/// names and values only (a few hundred), never a player's.
static INTERNED: Mutex<Option<Interned>> = Mutex::new(None);
/// The strings one registry's states share ([`BlockProperties::share`]):
/// the process's, with the ones already met at hand.
#[derive(Default)]
pub struct SharedStrings(Interned);
impl SharedStrings {
pub fn get(&mut self, text: &str) -> &'static String {
if let Some(shared) = self.0.get(text) {
return shared;
}
let mut interned = INTERNED.lock().unwrap_or_else(|e| e.into_inner());
let interned = interned.get_or_insert_with(Interned::default);
let shared = match interned.get(text) {
Some(shared) => *shared,
None => {
let shared: &'static String = Box::leak(Box::new(text.to_owned()));
interned.insert(shared.as_str(), shared);
shared
}
};
self.0.insert(shared.as_str(), shared);
shared
}
}
/// A block state's properties by name, in name order.
#[derive(Clone, Default)]
pub struct BlockProperties(Box<[(Text, Text)]>);
impl BlockProperties {
pub fn new() -> Self {
Self::default()
}
fn position<Q: Ord + ?Sized>(&self, key: &Q) -> Result<usize, usize>
where
String: Borrow<Q>,
{
self.0.binary_search_by(|(k, _)| (**k).borrow().cmp(key))
}
pub fn get<Q: Ord + ?Sized>(&self, key: &Q) -> Option<&String>
where
String: Borrow<Q>,
{
self.position(key).ok().map(|i| &*self.0[i].1)
}
pub fn get_mut<Q: Ord + ?Sized>(&mut self, key: &Q) -> Option<&mut String>
where
String: Borrow<Q>,
{
let i = self.position(key).ok()?;
Some(self.0[i].1.to_mut())
}
pub fn contains_key<Q: Ord + ?Sized>(&self, key: &Q) -> bool
where
String: Borrow<Q>,
{
self.position(key).is_ok()
}
/// Sets `key` to `value`; the value it had, if any.
pub fn insert(&mut self, key: String, value: String) -> Option<String> {
match self.position(key.as_str()) {
Ok(i) => Some(std::mem::replace(&mut self.0[i].1, Text::Owned(value)).into_string()),
Err(i) => {
let mut pairs = std::mem::take(&mut self.0).into_vec();
pairs.insert(i, (Text::Owned(key), Text::Owned(value)));
self.0 = pairs.into_boxed_slice();
None
}
}
}
pub fn remove<Q: Ord + ?Sized>(&mut self, key: &Q) -> Option<String>
where
String: Borrow<Q>,
{
let i = self.position(key).ok()?;
let mut pairs = std::mem::take(&mut self.0).into_vec();
let (_, value) = pairs.remove(i);
self.0 = pairs.into_boxed_slice();
Some(value.into_string())
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn iter(&self) -> Iter<'_> {
Iter(self.0.iter())
}
pub fn keys(&self) -> impl DoubleEndedIterator<Item = &String> + ExactSizeIterator {
self.0.iter().map(|(k, _)| &**k)
}
pub fn values(&self) -> impl DoubleEndedIterator<Item = &String> + ExactSizeIterator {
self.0.iter().map(|(_, v)| &**v)
}
/// These properties over the strings `strings` holds, which other states
/// share (a registry's states, built once). Strings already shared stay.
pub fn share(&mut self, strings: &mut SharedStrings) {
for text in self.0.iter_mut().flat_map(|(k, v)| [k, v]) {
if let Text::Owned(owned) = text {
*text = Text::Shared(strings.get(owned));
}
}
}
/// Shared names and values, collected as a `BTreeMap` collects them.
pub fn from_shared(mut pairs: Vec<(&'static String, &'static String)>) -> Self {
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut kept: Vec<(Text, Text)> = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
match kept.last_mut() {
Some(last) if *last.0 == *k => last.1 = Text::Shared(v),
_ => kept.push((Text::Shared(k), Text::Shared(v))),
}
}
Self(kept.into_boxed_slice())
}
}
pub struct Iter<'a>(std::slice::Iter<'a, (Text, Text)>);
impl<'a> Iterator for Iter<'a> {
type Item = (&'a String, &'a String);
fn next(&mut self) -> Option<Self::Item> {
self.0.next().map(|(k, v)| (&**k, &**v))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
impl DoubleEndedIterator for Iter<'_> {
fn next_back(&mut self) -> Option<Self::Item> {
self.0.next_back().map(|(k, v)| (&**k, &**v))
}
}
impl ExactSizeIterator for Iter<'_> {}
impl<'a> IntoIterator for &'a BlockProperties {
type Item = (&'a String, &'a String);
type IntoIter = Iter<'a>;
fn into_iter(self) -> Iter<'a> {
self.iter()
}
}
impl IntoIterator for BlockProperties {
type Item = (String, String);
type IntoIter = IntoIter;
fn into_iter(self) -> IntoIter {
IntoIter(self.0.into_vec().into_iter())
}
}
pub struct IntoIter(std::vec::IntoIter<(Text, Text)>);
impl Iterator for IntoIter {
type Item = (String, String);
fn next(&mut self) -> Option<(String, String)> {
self.0
.next()
.map(|(k, v)| (k.into_string(), v.into_string()))
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.0.size_hint()
}
}
/// As a `BTreeMap` collects: sorted by name, the last value of a name wins.
impl FromIterator<(String, String)> for BlockProperties {
fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
let mut pairs: Vec<(String, String)> = iter.into_iter().collect();
// Stable: equal names keep their order, and the last one is kept.
pairs.sort_by(|a, b| a.0.cmp(&b.0));
let mut kept: Vec<(Text, Text)> = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
match kept.last_mut() {
Some(last) if *last.0 == k => last.1 = Text::Owned(v),
_ => kept.push((Text::Owned(k), Text::Owned(v))),
}
}
Self(kept.into_boxed_slice())
}
}
impl Extend<(String, String)> for BlockProperties {
fn extend<I: IntoIterator<Item = (String, String)>>(&mut self, iter: I) {
for (k, v) in iter {
self.insert(k, v);
}
}
}
impl<const N: usize> From<[(String, String); N]> for BlockProperties {
fn from(pairs: [(String, String); N]) -> Self {
pairs.into_iter().collect()
}
}
impl From<BTreeMap<String, String>> for BlockProperties {
fn from(map: BTreeMap<String, String>) -> Self {
Self(
map.into_iter()
.map(|(k, v)| (Text::Owned(k), Text::Owned(v)))
.collect(),
)
}
}
impl From<BlockProperties> for BTreeMap<String, String> {
fn from(properties: BlockProperties) -> Self {
properties.into_iter().collect()
}
}
impl<Q: Ord + ?Sized> std::ops::Index<&Q> for BlockProperties
where
String: Borrow<Q>,
{
type Output = String;
/// Panics when the key is missing, as a `BTreeMap` does.
fn index(&self, key: &Q) -> &String {
self.get(key).expect("no entry found for key")
}
}
impl serde::Serialize for BlockProperties {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_map(self.iter())
}
}
impl<'de> serde::Deserialize<'de> for BlockProperties {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
BTreeMap::<String, String>::deserialize(deserializer).map(Self::from)
}
}
// Compared, ordered and hashed as the `BTreeMap` they replace: pair by pair.
impl PartialEq for BlockProperties {
fn eq(&self, other: &Self) -> bool {
self.iter().eq(other.iter())
}
}
impl Eq for BlockProperties {}
impl PartialEq<BTreeMap<String, String>> for BlockProperties {
fn eq(&self, other: &BTreeMap<String, String>) -> bool {
self.iter().eq(other.iter())
}
}
impl PartialOrd for BlockProperties {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl Ord for BlockProperties {
fn cmp(&self, other: &Self) -> Ordering {
self.iter().cmp(other.iter())
}
}
impl Hash for BlockProperties {
fn hash<H: Hasher>(&self, state: &mut H) {
state.write_usize(self.len());
for pair in self {
pair.hash(state);
}
}
}
impl fmt::Debug for BlockProperties {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_map().entries(self.iter()).finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[test]
fn reads_and_writes_as_the_map_it_replaces() {
let pairs = [
("waterlogged", "false"),
("facing", "north"),
("half", "top"),
];
let mut map = map(&pairs);
let mut properties: BlockProperties = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
let same = |p: &BlockProperties, m: &BTreeMap<String, String>| {
assert!(p.iter().eq(m.iter()));
assert_eq!(p.len(), m.len());
assert_eq!(format!("{p:?}"), format!("{m:?}"));
};
same(&properties, &map);
assert_eq!(properties.get("facing"), map.get("facing"));
assert_eq!(properties.get("shape"), None);
assert_eq!(
properties.insert("facing".into(), "east".into()),
map.insert("facing".into(), "east".into())
);
assert_eq!(
properties.insert("axis".into(), "y".into()),
map.insert("axis".into(), "y".into())
);
*properties.get_mut("half").unwrap() = "bottom".into();
*map.get_mut("half").unwrap() = "bottom".into();
assert_eq!(properties.remove("waterlogged"), map.remove("waterlogged"));
assert_eq!(properties.remove("waterlogged"), None);
same(&properties, &map);
assert_eq!(BTreeMap::from(properties), map);
}
#[test]
fn collects_as_a_map_does() {
let pairs = [("b", "1"), ("a", "2"), ("b", "3")];
let properties: BlockProperties = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
assert!(properties.iter().eq(map(&pairs).iter()));
}
#[test]
fn orders_and_hashes_as_the_map_does() {
let sets = [
vec![],
vec![("a", "1")],
vec![("a", "2")],
vec![("a", "1"), ("b", "1")],
vec![("b", "0")],
];
let hash = |value: &dyn Fn(&mut std::collections::hash_map::DefaultHasher)| {
let mut hasher = std::collections::hash_map::DefaultHasher::new();
value(&mut hasher);
hasher.finish()
};
for a in &sets {
for b in &sets {
let (pa, pb) = (BlockProperties::from(map(a)), BlockProperties::from(map(b)));
assert_eq!(pa.cmp(&pb), map(a).cmp(&map(b)), "{a:?} {b:?}");
assert_eq!(pa == pb, map(a) == map(b));
}
let p = BlockProperties::from(map(a));
assert_eq!(hash(&|h| p.hash(h)), hash(&|h| map(a).hash(h)));
}
}
#[test]
fn shared_strings_read_and_change_as_owned_ones() {
let mut strings = SharedStrings::default();
let pairs = map(&[("facing", "north"), ("lit", "true")]);
let mut a = BlockProperties::from(pairs.clone());
let mut b = BlockProperties::from(pairs.clone());
a.share(&mut strings);
b.share(&mut strings);
assert_eq!(a, BlockProperties::from(pairs.clone()));
// One string for both states' value.
let (Text::Shared(x), Text::Shared(y)) = (&a.0[0].1, &b.0[0].1) else {
panic!("not shared");
};
assert!(std::ptr::eq(*x, *y));
// Changing one state's value leaves the other's.
a.get_mut("facing").unwrap().push_str("_east");
assert_eq!(a.get("facing").map(String::as_str), Some("north_east"));
assert_eq!(b.get("facing").map(String::as_str), Some("north"));
assert_eq!(BTreeMap::from(b), pairs);
}
}
+9 -8
View File
@@ -1,5 +1,6 @@
//! Saved section palettes and storage-level chunk access. Changes here do not
//! run block behavior, lighting, heightmaps, neighbor updates or scheduled ticks.
pub use crate::block_state_properties::BlockProperties;
use crate::{
DATA_VERSION,
compression::invalid,
@@ -12,7 +13,7 @@ use horizon_core::{
storage::PackedStorage,
ticks::{ChunkTicks, SavedTick, TickTypeId},
};
use std::{collections::BTreeMap, io};
use std::io;
fn key(value: &str) -> JavaString {
JavaString::from(value)
@@ -161,7 +162,7 @@ impl PalettedContainer {
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct BlockState {
pub name: String,
pub properties: BTreeMap<String, String>,
pub properties: BlockProperties,
}
impl BlockState {
pub fn to_nbt(&self) -> Tag {
@@ -184,13 +185,13 @@ impl BlockState {
if let Tag::String(name) = tag {
return Ok(Self {
name: name.to_string().map_err(|e| invalid(e.to_string()))?,
properties: BTreeMap::new(),
properties: BlockProperties::new(),
});
}
let Some(Tag::String(name)) = tag.get("id") else {
return Err(invalid("block state is missing id"));
};
let mut properties = BTreeMap::new();
let mut properties = Vec::new();
if let Some(tag) = tag.get("properties") {
let Tag::Compound(map) = tag else {
return Err(invalid("block properties are not a compound"));
@@ -199,15 +200,15 @@ impl BlockState {
let Tag::String(v) = v else {
return Err(invalid("block property is not a string"));
};
properties.insert(
properties.push((
k.to_string().map_err(|e| invalid(e.to_string()))?,
v.to_string().map_err(|e| invalid(e.to_string()))?,
);
));
}
}
Ok(Self {
name: name.to_string().map_err(|e| invalid(e.to_string()))?,
properties,
properties: properties.into_iter().collect(),
})
}
}
@@ -758,7 +759,7 @@ mod tests {
Tag::compound([("id", Tag::String(format!("minecraft:b{i}").as_str().into()))])
})
.collect();
let defaults: BTreeMap<_, _> = (0..states)
let defaults: std::collections::BTreeMap<_, _> = (0..states)
.map(|i| {
(
JavaString::from(format!("minecraft:b{i}").as_str()),
@@ -3,11 +3,7 @@
//! absent).
use super::*;
use crate::{chunk::BlockState, worldgen::generator::GeneratorData, worldgen::live::LiveFeatures};
use std::{
collections::{BTreeMap, HashMap},
path::PathBuf,
sync::OnceLock,
};
use std::{collections::HashMap, path::PathBuf, sync::OnceLock};
struct Fixture {
registry: &'static BlockRegistry,
@@ -121,7 +117,7 @@ impl World {
properties: props
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<BTreeMap<_, _>>(),
.collect(),
};
self.f
.registry
+2 -2
View File
@@ -5,7 +5,7 @@ use super::*;
use crate::chunk::BlockState;
use horizon_core::random::RandomSource;
use std::{
collections::{BTreeMap, HashMap, HashSet},
collections::{HashMap, HashSet},
path::PathBuf,
sync::OnceLock,
};
@@ -184,7 +184,7 @@ impl<'f> World<'f> {
properties: props
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<BTreeMap<_, _>>(),
.collect(),
};
self.f
.registry
+1
View File
@@ -15,6 +15,7 @@ pub mod block_landing;
pub mod block_placement;
pub mod block_properties;
pub mod block_render;
pub mod block_state_properties;
pub mod block_support;
pub mod block_survival;
pub mod blocking_item_damage;
+1 -1
View File
@@ -167,7 +167,7 @@ fn run(args: &[String]) -> Result<(), Box<dyn Error>> {
block,
BlockState {
name: args[6].clone(),
properties,
properties: properties.into(),
},
)?;
let timestamp = std::time::SystemTime::now()
+7 -3
View File
@@ -75,8 +75,9 @@ impl BlockRegistry {
pub fn from_facts_json_with_flags(
reader: impl io::Read,
) -> io::Result<(Self, Vec<(bool, bool)>)> {
let facts: facts::Facts = serde_json::from_reader(io::BufReader::new(reader))
.map_err(|e| invalid(e.to_string()))?;
let facts: facts::Facts =
facts::with_shared_strings(|| serde_json::from_reader(io::BufReader::new(reader)))
.map_err(|e| invalid(e.to_string()))?;
let flags = facts.states.iter().map(|r| (r.air, r.fluid)).collect();
let states = facts.states.into_iter().map(|r| r.state).collect();
Ok((Self::from_states(states, facts.defaults)?, flags))
@@ -92,7 +93,10 @@ impl BlockRegistry {
collided: BTreeMap::new(),
defaults: BTreeMap::new(),
};
for (i, state) in states.into_iter().enumerate() {
// The states' property names and values: a few hundred strings, shared.
let mut strings = crate::block_state_properties::SharedStrings::default();
for (i, mut state) in states.into_iter().enumerate() {
state.properties.share(&mut strings);
if result.id(&state).is_some() {
return Err(invalid("duplicate block registry state"));
}
+95 -22
View File
@@ -1,11 +1,14 @@
//! The part of `facts.json` the block registry reads, deserialized as it
//! streams: each row's state (its fifth column) and the defaults.
use crate::chunk::BlockState;
use crate::{
block_state_properties::SharedStrings,
chunk::{BlockProperties, BlockState},
};
use serde::{
Deserialize, Deserializer,
de::{self, IgnoredAny, SeqAccess, Visitor},
de::{self, IgnoredAny, MapAccess, SeqAccess, Visitor},
};
use std::{collections::BTreeMap, fmt};
use std::{cell::RefCell, collections::BTreeMap, fmt};
#[derive(Deserialize)]
pub(super) struct Facts {
@@ -48,31 +51,101 @@ impl<'de> Deserialize<'de> for Row {
}
}
thread_local! {
/// The strings the states being read share (`with_shared_strings`).
static STRINGS: RefCell<Option<SharedStrings>> = const { RefCell::new(None) };
}
/// Runs `read` with the states it reads sharing their property strings.
pub(super) fn with_shared_strings<T>(read: impl FnOnce() -> T) -> T {
STRINGS.with(|s| *s.borrow_mut() = Some(SharedStrings::default()));
let out = read();
STRINGS.with(|s| s.borrow_mut().take());
out
}
/// A property name or value, shared with the other states' equal ones.
struct Shared(&'static String);
impl<'de> Deserialize<'de> for Shared {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct SharedVisitor;
impl Visitor<'_> for SharedVisitor {
type Value = Shared;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a string")
}
fn visit_str<E: de::Error>(self, text: &str) -> Result<Shared, E> {
Ok(Shared(STRINGS.with(|s| match s.borrow_mut().as_mut() {
Some(strings) => strings.get(text),
None => SharedStrings::default().get(text),
})))
}
}
deserializer.deserialize_str(SharedVisitor)
}
}
/// A state's `properties` object, read straight into shared strings.
struct Properties(BlockProperties);
impl<'de> Deserialize<'de> for Properties {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
struct PropertiesVisitor;
impl<'de> Visitor<'de> for PropertiesVisitor {
type Value = Properties;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("block state properties")
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Properties, A::Error> {
let mut pairs = Vec::new();
while let Some((Shared(k), Shared(v))) = map.next_entry()? {
pairs.push((k, v));
}
Ok(Properties(BlockProperties::from_shared(pairs)))
}
}
deserializer.deserialize_map(PropertiesVisitor)
}
}
/// A state as BlockState::from_nbt reads it: a name, or `{"id", "properties"}`.
struct State(BlockState);
impl<'de> Deserialize<'de> for State {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(untagged)]
enum Shape {
Name(String),
Full {
id: String,
#[serde(default)]
properties: BTreeMap<String, String>,
},
struct StateVisitor;
impl<'de> Visitor<'de> for StateVisitor {
type Value = State;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a block state name or {\"id\", \"properties\"}")
}
fn visit_str<E: de::Error>(self, name: &str) -> Result<State, E> {
Ok(State(BlockState {
name: name.to_owned(),
properties: BlockProperties::new(),
}))
}
fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<State, A::Error> {
let (mut id, mut properties) = (None, None);
while let Some(key) = map.next_key::<std::borrow::Cow<'de, str>>()? {
match &*key {
"id" if id.is_some() => return Err(de::Error::duplicate_field("id")),
"id" => id = Some(map.next_value::<String>()?),
"properties" if properties.is_some() => {
return Err(de::Error::duplicate_field("properties"));
}
"properties" => properties = Some(map.next_value::<Properties>()?.0),
_ => {
map.next_value::<IgnoredAny>()?;
}
}
}
Ok(State(BlockState {
name: id.ok_or_else(|| de::Error::missing_field("id"))?,
properties: properties.unwrap_or_default(),
}))
}
}
Ok(State(match Shape::deserialize(deserializer)? {
Shape::Name(name) => BlockState {
name,
properties: BTreeMap::new(),
},
Shape::Full { id, properties } => BlockState {
name: id,
properties,
},
}))
deserializer.deserialize_any(StateVisitor)
}
}
@@ -788,7 +788,7 @@ impl StairPillar {
fn stair(
resources: &StructureResources,
block: &str,
from: &std::collections::BTreeMap<String, String>,
from: &crate::chunk::BlockProperties,
facing: Option<&str>,
) -> Option<BlockStateId> {
let registry = resources.registry;
+4 -2
View File
@@ -66,13 +66,15 @@ Every flag goes to `log`, with the time, the player, the check, its level, what
[2026-10-30 17:29:55] Steve failed timer (VL 5.9): 301 ms ahead, sent back
[2026-10-31 18:02:11] Alex failed combat (VL 20.0): attacked without swinging (1 more attacks than swings in a tick), kicked (1 of 2 before a ban)
[2026-10-31 18:09:40] Alex failed combat (VL 20.0): attacked entities 41 and 57 in one tick, banned
[2026-11-01 03:14:02] Steve failed prediction (VL 12.4): moved up to 0.173 blocks off what its keys allow (1 tick; off +0.000 +0.173 +0.000, moved 0.281, air sprinting)
[2026-11-01 03:14:02] Steve failed prediction (VL 12.4): moved up to 0.173 blocks off what its keys allow (1 tick; off +0.000 +0.173 +0.000, moved 0.281, air sprinting, keys forward jump sprint, at 12.30 64.00 -5.50, velocity +0.000 +0.248 +0.000)
```
A `prediction` line ends with its worst tick as the replay played it: where the client said it went less
where the replay ended (x, y, z), how far the client said it moved, and what the replayed body was doing
(`ground` or `air`, then `water`, `gliding`, `sprinting`, `sneaking`, `swimming`, `flying`, `riding`), so a
lag, a glide or a ladder can be told from a cheat before the check acts.
lag, a glide or a ladder can be told from a cheat before the check acts. Then the tick's keys, where the
client said it ended (x, y, z) and the replay's velocity: enough to find the place again and play the tick
there.
## Checks
+43 -2
View File
@@ -13,6 +13,7 @@ bindings and no entry points, and no names starting with `horizon_`.
| --- | --- | --- |
| sky | `[sky]` | `fn sky(input: SkyInput) -> vec4<f32>` |
| material | `[material]` | `fn material(input: MaterialInput) -> Surface` |
| weather | `[weather]` | `fn weather(input: WeatherInput) -> vec4<f32>` |
| post | `[[post]]` | `fn post(input: PostInput) -> vec4<f32>` |
## sky
@@ -52,6 +53,7 @@ One point of a block's surface, for `fn material(input: MaterialInput) -> Surfac
| `uv` | `vec2<f32>` | The texture coordinate inside the block atlas. |
| `translucent` | `bool` | Drawn in the translucent pass (water, stained glass, ice). |
| `tags` | `u32` | The pack's sprite tags this surface's sprite has: bit i for the manifest's i-th `[[tags]]`. |
| `material` | `u32` | The `[[materials]]` id of this surface's sprite (0 when no entry names it). |
| `screen` | `vec2<f32>` | Where the point is on the screen, 0..1 from the top left (`opaque_depth` and `opaque_color` at `screen * frame.resolution`). |
| `face` | `u32` | For a fluid, which face of the block the point is on, `Direction` order: 0 down, 1 up, 2 north, 3 south, 4 west, 5 east. 0 elsewhere. |
| `fluid` | `u32` | 0 not a fluid, 1 water, 2 lava. |
@@ -74,15 +76,49 @@ Resources (bind group 3):
| 0 | `var<uniform> frame: Frame` | The frame (`Frame`). |
| 1 | `var reflection: texture_2d<f32>` | The water mirror: the world as a camera mirrored about the horizontal water plane around the camera (`frame.water_plane`) sees it, at half resolution, mapped like the world's picture, so water samples it at `input.screen`. Solid terrain, cut away below the plane, over the vanilla sky gradient: not the pack's `sky` stage, nor the sun, moon and stars. Drawn only for a pack whose material reads it, while `frame.has_reflection` is 1; else a 1×1 blank. |
| 2 | `var reflection_sampler: sampler` | Bilinear filtering, clamped to the edges. |
| 3 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 3 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain and entities the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 4 | `var shadow_sampler: sampler_comparison` | Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow. |
| 7 | `var shadow_map_translucent: texture_depth_2d` | `shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of. |
| 8 | `var shadow_color: texture_2d<f32>` | The texture colour times the vertex colour (rgb: the tint, the face's directional shade and ambient occlusion) of the nearest translucent surface the light passes through at each texel of `shadow_map_translucent`, white where there is none; alpha is 1 on a fluid (water) and the texture's alpha, at most 0.99, on anything else. Read it with `textureLoad` at `shadow_position`'s xy times `textureDimensions(shadow_color)`. |
| 6 | `var opaque_depth: texture_depth_2d` | The world's depth before translucent terrain (reversed, like `scene_depth`), for the translucent pass: `textureLoad(opaque_depth, vec2<i32>(input.screen * frame.resolution), 0)` is what lies behind water or glass. Stale while solid terrain draws. |
| 9 | `var block_light_colors: texture_3d<f32>` | The colour of the block light around the camera, from the `[[lights]]` of the light sources nearby: rgb their colours averaged by how near each is, over 4; a how strongly they reach the cell. Read it through `block_light_color`. |
| 10 | `var<uniform> block_light_grid: vec4<f32>` | Where `block_light_colors` lies: xyz its corner, camera-relative, w the side of one of its cells in blocks. |
| 11 | `var opaque_color: texture_2d<f32>` | The world's picture before translucent terrain (sky, solid terrain, entities), the size of `opaque_depth`, for the translucent pass: what a surface refracts. Read it with `textureLoad`. Stale while solid terrain draws; black when the world draws straight to the window (a pack without `[[post]]` passes, at full resolution). |
Helpers:
- `fn block_pbr_maps() -> bool`: whether the resource packs ship LabPBR maps (`_n`, `_s`).
- `fn block_normal_map(uv: vec2<f32>) -> vec4<f32>`: the surface's LabPBR normal map at `uv` (rg the normal along `input.tangent` and down the texture, b ambient occlusion, a height); (0.5, 0.5, 1, 1) without one.
- `fn block_specular_map(uv: vec2<f32>) -> vec4<f32>`: its LabPBR specular map (r smoothness, g F0 or a metal, b porosity or subsurface, a emission, 1 none); (0, 0, 0, 0) without one.
- `fn block_normal_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32>`, `fn block_specular_map_level(uv: vec2<f32>, lod: f32) -> vec4<f32>`: the same at mip level `lod`.
- `fn block_pbr_size() -> vec2<f32>`: the LabPBR maps' size in texels.
- `fn shadow_position(position: vec3<f32>) -> vec3<f32>`: where the camera-relative point `position` is
stored in the shadow map, for `textureSampleCompareLevel`.
- `fn shadow_distort(xy: vec2<f32>) -> vec2<f32>`: the map's distortion of a point at shadow clip `xy`.
## weather
`fn weather(input: WeatherInput) -> vec4<f32>`
### `WeatherInput`
One point of a rain or snow streak, for `fn weather(input: WeatherInput) -> vec4<f32>`, which returns the colour blended over the world (alpha its opacity).
| Field | Type | Meaning |
| --- | --- | --- |
| `color` | `vec4<f32>` | The rain or snow texture's colour here times the streak's vertex colour (alpha 0.1..1). |
| `light` | `vec2<f32>` | Block and sky light, 0..1. |
| `position` | `vec3<f32>` | The point, camera-relative, in blocks. |
| `snow` | `bool` | A snow streak (rain otherwise). |
Resources (bind group 3):
| Binding | Declaration | Meaning |
| --- | --- | --- |
| 0 | `var<uniform> frame: Frame` | The frame (`Frame`). |
Helpers:
- `fn shadow_position(position: vec3<f32>) -> vec3<f32>`: where the camera-relative point `position` is
stored in the shadow map, for `textureSampleCompareLevel`.
- `fn shadow_distort(xy: vec2<f32>) -> vec2<f32>`: the map's distortion of a point at shadow clip `xy`.
@@ -112,7 +148,7 @@ Resources (bind group 3):
| 3 | `var previous: texture_2d<f32>` | The previous pass's output (`scene_color` for the first pass). |
| 4 | `var linear_sampler: sampler` | Bilinear filtering, clamped to the edges. |
| 5 | `var nearest_sampler: sampler` | Nearest texel, clamped to the edges. |
| 6 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 6 | `var shadow_map: texture_depth_2d` | This frame's shadow map: the depth of the solid terrain and entities the light sees, 0 at the light, 1 away from it (and where nothing is), stored at `shadow_position`'s mapping. Valid while `frame.has_shadow` is 1 (the pack asks for it with `[shadow]`), else a 1×1 map that lights everything. |
| 7 | `var shadow_sampler: sampler_comparison` | Depth comparison, bilinear (2×2 percentage-closer filtering): `textureSampleCompareLevel(shadow_map, shadow_sampler, p.xy, p.z)` is 1 where the point is lit, 0 in shadow. |
| 8 | `var history: texture_2d<f32>` | Last frame's input to the last pass (what the pass before it wrote), for temporal passes (TAA, denoising): put such a pass second to last and it reads its own previous output; reproject with `frame.previous_view_projection`. Black on the first frame, after a gap of over half a second, and with a single pass. |
| 9 | `var shadow_map_translucent: texture_depth_2d` | `shadow_map` with the translucent terrain (water, stained glass, ice) the light passes through drawn in too, read with `shadow_sampler` the same way: a point lit in `shadow_map` but not here lies under such a surface, which `shadow_color` gives the colour of. |
@@ -127,6 +163,11 @@ Helpers:
stored in the shadow map, for `textureSampleCompareLevel`.
- `fn shadow_distort(xy: vec2<f32>) -> vec2<f32>`: the map's distortion of a point at shadow clip `xy`.
A `[[post]]` entry with `output = "<name>"` writes a target of the pack's own, at its `scale` of the
world's size (1, 0.5 or 0.25), instead of the next pass's `previous`; the prelude of every pass declares
`var <name>: texture_2d<f32>` (group 3 from binding 32), which holds last frame's until written again (a
pass reads a blank one in place of the target it writes).
## The frame
### `Frame`
+53
View File
@@ -51,17 +51,28 @@ shared = ["common.wgsl"] # optional: WGSL put before every stage's file, in or
[sky] # each stage is optional, but a pack has at least one
file = "sky.wgsl"
clouds = true # optional: false when the pack draws its own clouds
sun = true # optional: false when the pack draws its own sun (the moon stays)
stars = true # optional: false when the pack draws its own stars
[material]
file = "material.wgsl"
files = ["pbr.wgsl"] # optional, on any stage: WGSL put after `shared`, before this stage's file
fog = false # optional: the pack fogs its surfaces itself (Horizon does when absent or true)
[weather] # the colour of rain and snow streaks
file = "weather.wgsl"
[shadow] # optional, needs [material] or [[post]]: a shadow map from the sun (the moon by night)
resolution = 2048 # its side in texels, 256 to 8192
distance = 128.0 # half the side of the square it covers, in blocks, 32 to 512
solid_materials = [10] # optional: [[materials]] ids drawn into the map whole, cutouts ignored
[[post]] # full-screen passes, in the order they run (8 at most)
file = "post/grade.wgsl"
name = "Colour grade" # optional: the GPU time overlay's label
output = "bloom" # optional, not on the last pass: writes this target, read by name in later passes
scale = 0.5 # optional, with output: 1, 0.5 or 0.25 of the world's size
[[options]] # 64 at most; `id` is the WGSL `override` it sets
id = "saturation"
@@ -72,6 +83,32 @@ min = 0.0
max = 2.0
step = 0.05
[[materials]] # 1024 at most: MaterialInput.material, the first entry that matches
id = 10 # 1 to 65535; 0 is a sprite no entry names
sprites = ["block/*_leaves"] # sprite names, any number of * each
[[pages]] # optional: the options page's layout, the first the one it opens on
id = "main"
name = "Sample"
items = ["page:colour", ""] # a button opening a page, an empty slot (or an option's id)
[[pages]]
id = "colour"
name = "Colour" # the page's title and its button's label
description = "How colourful the picture is." # optional: the button's tooltip
columns = 1 # optional: 1 or 2 (default) buttons a row
items = ["saturation"]
[[textures]] # 8 at most: a PNG every stage reads as noise and noise_sampler
id = "noise"
file = "noise.png"
filter = "linear" # optional: linear (default) or nearest
wrap = "repeat" # optional: repeat (default) or clamp
[[lights]] # 256 at most: block_light_color's colour, the first entry that matches
color = [0.3, 0.7, 1.0] # each channel 0 to 4
blocks = ["soul_*"] # block names, any number of * each
[[tags]] # 32 at most: bit i of MaterialInput.tags is the i-th tag
id = "glass"
sprites = ["block/glass", "block/*_stained_glass"] # sprite names, at most one * each
@@ -107,6 +144,18 @@ stages.
## Changelog
- 2026-11-01: API 0.1 (additions): `[[materials]]` and `MaterialInput.material`, an id by sprite,
and `[shadow] solid_materials`.
- 2026-11-01: API 0.1 (additions): entities cast into `shadow_map`, the player's own model in first person too, and the round
blob under entities is left out in the Overworld while a pack draws a shadow map, so under a
roof they have no shadow but what the pack gives them. Only entities the camera's culling keeps
cast: one off screen or behind a wall leaves the map, and its shadow with it.
- 2026-11-01: API 0.1 (additions): `[sky] clouds`, `sun` and `stars`: a sky can replace Minecraft's.
- 2026-11-01: API 0.1 (additions): `[[pages]]`, the layout of a pack's options page.
- 2026-11-01: API 0.1 (additions): the `[weather]` stage
(`fn weather(input: WeatherInput) -> vec4<f32>`) colours rain and snow streaks.
- 2026-11-01: API 0.1 (additions): `[[textures]]`, PNGs bound for every stage.
- 2026-11-01: API 0.1 (additions): `[[lights]]` and the material stage's `block_light_color`.
- 2026-10-31: API 0.1 (additions): translucent terrain in the shadow pass: `shadow_map_translucent`
and `shadow_color`, so a pack can tint the light that passes through water and stained glass.
- 2026-10-31: API 0.1 (additions): `Frame.mods`, eight `vec4<f32>` a native mod hands the pack it bundles each
@@ -143,3 +192,7 @@ stages.
- 2026-10-25: the stages of API 0.1 (sky, material, post), their inputs and the generated reference.
- 2026-10-25: the pack format, schema 1: `horizon-shaders.toml` and the `.hshader` package.
- 2026-10-24: the earlier shader pack code was removed; this API is designed from scratch.
- 2026-11-01: API 0.1 (additions): a stage's `files`.
- 2026-11-01: API 0.1 (additions): `[material] fog`.
- 2026-11-01: API 0.1 (additions): the material stage reads the resource packs' LabPBR maps (`_n`, `_s`): `block_pbr_maps`, `block_normal_map`, `block_specular_map`, their `_level` forms and `block_pbr_size`. The maps load only while a native pack is picked: without one they cost nothing, and the first pick reloads the resources once.
- 2026-11-01: API 0.1 (additions): `[[post]] output` and `scale`, targets of a pack's own at the world's size, half or a quarter of it.
+1
View File
@@ -20,6 +20,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
| | LOD distance, cave culling, chunk updates per frame | `horizon-graphics.txt` |
| Quality | smooth lighting, mipmap levels, biome blend, clouds, cloud distance, weather radius, particles, entity shadows, cutout leaves | `options.txt` |
| | sky, fog, weather, animated textures | `horizon-graphics.txt` |
| | distant plant dither (`ditherCutoutMips`, off by default: cutouts from 64 texels up) | `horizon-graphics.txt` |
| Display | FOV, max framerate, VSync, fullscreen, GUI scale | `options.txt` |
| | resolution scale, anti-aliasing (FXAA or TAA), TAA upscaling, present mode, frame pacing | `horizon-graphics.txt` |
| HDR (its own screen, from Display) | HDR, black level, peak brightness, paper white, UI brightness, a shortcut key to the screen | `horizon-graphics.txt` |
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more