mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge remote-tracking branch 'upstream/main' into feat/native-mob-submit
This commit is contained in:
128 files changed
+6941
-665
No files matched your search
Generated
+1
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, §ions);
|
||||
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, §ions)
|
||||
.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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<'_> {
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -194,7 +194,7 @@ mod tests {
|
||||
BlockPos::new(1, 75, 1),
|
||||
BlockState {
|
||||
name: "minecraft:air".into(),
|
||||
properties: BTreeMap::new(),
|
||||
properties: Default::default(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -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,
|
||||
®istry,
|
||||
&Value::Null,
|
||||
parallel,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert_eq!(error.to_string(), expected, "parallel: {parallel}");
|
||||
}
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
@@ -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
Reference in new issue
Block a user