mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 02:29:50 -10:00
Merge main into live transfer management branch
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
commit
12e9795542
205 files changed
+13786
-1096
No files matched your search
@@ -378,6 +378,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -777,6 +777,9 @@ pub struct Frame<'a> {
|
||||
pub stars: Option<(Mat4, f32)>,
|
||||
/// First-person hand and held item, drawn after clearing depth.
|
||||
pub hand: &'a [Vertex],
|
||||
/// Where the arm and each held item start in `hand` (first vertex), with the
|
||||
/// `currentRenderedItemId` a shader pack's `gbuffers_hand` reads for them.
|
||||
pub hand_items: &'a [(u32, i32)],
|
||||
/// `CameraRenderState.hudFov`: the hand's own field of view
|
||||
/// (`GameRenderer.render3dHud`).
|
||||
pub hand_fov_degrees: f32,
|
||||
@@ -1019,8 +1022,8 @@ pub struct Renderer {
|
||||
entity_skin: Option<entity_skin::EntitySkin>,
|
||||
group: wgpu::BindGroup,
|
||||
globals: wgpu::Buffer,
|
||||
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
|
||||
pbr_maps: Option<[wgpu::Texture; 2]>,
|
||||
/// Resource-pack normal/specular maps, split like the colour sprite arrays.
|
||||
pbr_maps: Option<Vec<[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`...).
|
||||
@@ -1049,6 +1052,7 @@ pub struct Renderer {
|
||||
lightmap_view: wgpu::TextureView,
|
||||
lightmap_sampler: wgpu::Sampler,
|
||||
animations: animate::SpriteAnimator,
|
||||
material_animations: Vec<SpriteAnimation>,
|
||||
active_animated_layers: Vec<u64>,
|
||||
uploads: upload::Uploads,
|
||||
/// Section meshes are staged through their own belt into
|
||||
@@ -1198,6 +1202,7 @@ pub struct Renderer {
|
||||
pack_weather: Option<pack_weather::PackWeather>,
|
||||
/// The block sprites as one atlas, while a shader pack is loaded.
|
||||
pack_atlas: Option<pack_atlas::PackAtlas>,
|
||||
pack_material_atlases: Option<[pack_atlas::PackAtlas; 2]>,
|
||||
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
|
||||
pack_terrain_flat: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
|
||||
@@ -2128,7 +2133,9 @@ impl Renderer {
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps.as_ref().map_or(0, |maps| textures(maps)),
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
@@ -2777,39 +2784,48 @@ 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).
|
||||
// Keep every colour layer's companion, split by the same device limit. Native
|
||||
// material stages read the first array; terrain packs stitch all of 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: &[],
|
||||
.map(|(normals, specular, levels, map_layers, size)| {
|
||||
let layer_bytes = normals.len() / map_layers as usize;
|
||||
(0..map_layers.div_ceil(split))
|
||||
.map(|array| {
|
||||
let first = array * split;
|
||||
let used = (map_layers - first).min(split);
|
||||
let count = array_layers(used, gl);
|
||||
[("LabPBR normals", &normals), ("LabPBR specular", &specular)].map(
|
||||
|(label, data)| {
|
||||
let start = first as usize * layer_bytes;
|
||||
let mut data =
|
||||
data[start..start + used as usize * layer_bytes].to_vec();
|
||||
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
|
||||
| wgpu::TextureUsages::COPY_SRC
|
||||
| wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&data,
|
||||
)
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let sprite_textures: Vec<wgpu::Texture> = prepared
|
||||
.sprites
|
||||
@@ -2847,6 +2863,11 @@ impl Renderer {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let material_animations = animations
|
||||
.iter()
|
||||
.filter(|a| a.has_materials())
|
||||
.cloned()
|
||||
.collect();
|
||||
let animations = animate::SpriteAnimator::new(
|
||||
&device,
|
||||
animations,
|
||||
@@ -4347,6 +4368,7 @@ impl Renderer {
|
||||
lightmap_view,
|
||||
lightmap_sampler,
|
||||
animations,
|
||||
material_animations,
|
||||
active_animated_layers: vec![0; active_layer_words],
|
||||
uploads,
|
||||
section_uploads,
|
||||
@@ -4421,6 +4443,7 @@ impl Renderer {
|
||||
cloud_bindings,
|
||||
pack_weather: None,
|
||||
pack_atlas: None,
|
||||
pack_material_atlases: None,
|
||||
pack_terrain_flat: false,
|
||||
separate_ao: false,
|
||||
hud,
|
||||
@@ -5976,6 +5999,7 @@ impl Renderer {
|
||||
) -> Option<(Vec<String>, Vec<String>)> {
|
||||
if pack.is_none() {
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
}
|
||||
let instance = wgpu::vertex_attr_array![5=>Float32x4];
|
||||
let vertex = wgpu::VertexBufferLayout {
|
||||
@@ -5993,6 +6017,10 @@ impl Renderer {
|
||||
let post = pack.map(|post| {
|
||||
let world = pack_post::World {
|
||||
atlas: self.pack_atlas.as_ref().map(|a| (a.view.clone(), a.size())),
|
||||
material_maps: self
|
||||
.pack_material_atlases
|
||||
.as_ref()
|
||||
.map(|a| [a[0].view.clone(), a[1].view.clone()]),
|
||||
lightmap: self.lightmap_view.clone(),
|
||||
gui_size: self
|
||||
.hud
|
||||
@@ -6004,6 +6032,7 @@ impl Renderer {
|
||||
table: self.flat.as_ref().filter(|_| flat).map(|f| &f.draw_layout),
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: true,
|
||||
ids: None,
|
||||
cull: true,
|
||||
}),
|
||||
@@ -6078,7 +6107,7 @@ impl Renderer {
|
||||
sprite_materials: &sprite_materials,
|
||||
entities: &self.entity_bindings,
|
||||
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
|
||||
pbr: self.pbr_maps.as_ref(),
|
||||
pbr: self.pbr_maps.as_ref().and_then(|arrays| arrays.first()),
|
||||
};
|
||||
let (pack, labels) = native_pack::NativePack::new(
|
||||
&self.device,
|
||||
@@ -6092,6 +6121,7 @@ impl Renderer {
|
||||
if self.native.is_some() {
|
||||
self.pack_post = None;
|
||||
self.pack_atlas = None;
|
||||
self.pack_material_atlases = None;
|
||||
}
|
||||
Ok(labels)
|
||||
}
|
||||
@@ -6146,6 +6176,20 @@ impl Renderer {
|
||||
if let Some(atlas) = &mut atlas {
|
||||
atlas.separate_ao = self.separate_ao;
|
||||
}
|
||||
self.pack_material_atlases = atlas.as_ref().and_then(|_| {
|
||||
let maps = self.pbr_maps.as_ref()?;
|
||||
let make = |kind: usize, encoder: &mut wgpu::CommandEncoder| {
|
||||
let arrays: Vec<_> = maps.iter().map(|pair| pair[kind].clone()).collect();
|
||||
pack_atlas::PackAtlas::material(
|
||||
&self.device,
|
||||
encoder,
|
||||
&arrays,
|
||||
self.sprite_split,
|
||||
layers,
|
||||
)
|
||||
};
|
||||
Some([make(0, &mut encoder)?, make(1, &mut encoder)?])
|
||||
});
|
||||
self.pack_atlas = atlas;
|
||||
self.queue.submit([encoder.finish()]);
|
||||
}
|
||||
@@ -6377,6 +6421,49 @@ impl Renderer {
|
||||
active,
|
||||
self.own_timing.as_mut(),
|
||||
);
|
||||
if let Some(arrays) = &self.pbr_maps {
|
||||
for animation in &self.material_animations {
|
||||
if !self.animations.changed().contains(&animation.layer) {
|
||||
continue;
|
||||
}
|
||||
let Some(pair) = arrays.get((animation.layer / self.sprite_split) as usize) else {
|
||||
continue;
|
||||
};
|
||||
for kind in 0..2 {
|
||||
let Some(pixels) = animation.material_pixels(kind, tick) else {
|
||||
continue;
|
||||
};
|
||||
let texture = &pair[kind];
|
||||
let from = (pixels.len() / 4).isqrt() as u32;
|
||||
for level in 0..texture.mip_level_count() {
|
||||
let size = texture.width() >> level;
|
||||
let pixels = crate::sprites::resample(pixels, from, size);
|
||||
self.uploads.texture(
|
||||
encoder,
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture,
|
||||
mip_level: level,
|
||||
origin: wgpu::Origin3d {
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: animation.layer % self.sprite_split,
|
||||
},
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
size,
|
||||
size,
|
||||
&pixels,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(atlases) = &self.pack_material_atlases {
|
||||
for kind in 0..2 {
|
||||
let arrays: Vec<_> = arrays.iter().map(|pair| pair[kind].clone()).collect();
|
||||
atlases[kind].copy(encoder, &arrays, self.animations.changed().iter().copied());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(atlas) = &self.pack_atlas {
|
||||
atlas.copy(
|
||||
encoder,
|
||||
|
||||
@@ -485,6 +485,7 @@ impl Scene {
|
||||
let f = &self.frame;
|
||||
Frame {
|
||||
weather: &[],
|
||||
hand_items: &[],
|
||||
weather_time: crate::weather::Time::new(0, 0.0),
|
||||
camera: DVec3::from_array(f.camera),
|
||||
yaw: f.yaw,
|
||||
|
||||
@@ -26,6 +26,37 @@ impl PackAtlas {
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
split,
|
||||
layers,
|
||||
sprite,
|
||||
arrays[0].mip_level_count(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded material channels must select an existing texel, including at distance.
|
||||
pub fn material(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
|
||||
}
|
||||
|
||||
fn with_levels(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
levels: u32,
|
||||
) -> Option<Self> {
|
||||
let layers = layers.max(1);
|
||||
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
|
||||
@@ -35,7 +66,7 @@ impl PackAtlas {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let levels = levels.min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -252,6 +283,7 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ impl EntitySpan {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
|
||||
/// `entityColor`.
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
pub(super) struct DrawUniforms {
|
||||
@@ -73,8 +74,10 @@ impl DrawUniforms {
|
||||
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
|
||||
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
|
||||
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
|
||||
// `blockEntityId` outside block entities, as Iris has it.
|
||||
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
|
||||
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
|
||||
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +428,9 @@ impl PackEntities {
|
||||
}),
|
||||
};
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
|
||||
// draws them); beams do not.
|
||||
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
|
||||
assert_eq!(skin.spans.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
// One block entity: a plain face, then an End portal face (overlay layer 9).
|
||||
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
|
||||
.into_iter()
|
||||
.map(|layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
let spans = [EntitySpan {
|
||||
start: 0,
|
||||
id: 99,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut rest = Vec::new();
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&cpu,
|
||||
&spans,
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(geometry.draws.iter())
|
||||
.map(|d| (d.portal, d.shadow))
|
||||
.collect::<Vec<_>>(),
|
||||
[(false, true), (true, true)],
|
||||
"the portal face casts as its block entity does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
|
||||
/// `HzHand`: the hand's projection and its inverse.
|
||||
const UNIFORM: u64 = 2 * 64;
|
||||
|
||||
/// `HzDraw`'s size (pack_draw.rs).
|
||||
const SLOT: u64 = 32;
|
||||
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
|
||||
inputs.item_id_declared = true;
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
|
||||
);
|
||||
@@ -45,10 +50,17 @@ pub struct PackHand {
|
||||
capacity: u32,
|
||||
/// Quads uploaded this frame.
|
||||
pub quads: u32,
|
||||
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
|
||||
slots: wgpu::Buffer,
|
||||
pub slot_group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
|
||||
pub runs: Vec<(u32, u32, u32)>,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
|
||||
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -74,6 +86,28 @@ impl PackHand {
|
||||
});
|
||||
let capacity = 256;
|
||||
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
// The arm and the two hands' items: a few runs a frame.
|
||||
let slots = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
size: SLOTS * stride,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
layout: draws,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &slots,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(SLOT),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
Self {
|
||||
indices: super::pack_entities::quad_indices(device, capacity),
|
||||
layout,
|
||||
@@ -82,6 +116,10 @@ impl PackHand {
|
||||
group,
|
||||
capacity,
|
||||
quads: 0,
|
||||
slots,
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +154,31 @@ impl PackHand {
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
/// Uploads this frame's hand and its projection.
|
||||
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
|
||||
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
hand: &[Vertex],
|
||||
items: &[(u32, i32)],
|
||||
projection: Mat4,
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
self.runs.clear();
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
let mut contents = Vec::new();
|
||||
for (start, quads, id) in runs(self.quads, items) {
|
||||
let slot = (self.runs.len() as u64).min(SLOTS - 1);
|
||||
if slot as usize == self.runs.len() {
|
||||
contents.extend_from_slice(&draw_slot(id));
|
||||
contents.resize(((slot + 1) * self.stride) as usize, 0);
|
||||
}
|
||||
self.runs.push((start, quads, (slot * self.stride) as u32));
|
||||
}
|
||||
queue.write_buffer(&self.slots, 0, &contents);
|
||||
if self.quads > self.capacity {
|
||||
self.capacity = self.quads.next_power_of_two();
|
||||
(self.vertices, self.group) =
|
||||
@@ -144,10 +195,54 @@ impl PackHand {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand's runs a frame keeps slots for.
|
||||
const SLOTS: u64 = 8;
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
|
||||
/// entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
|
||||
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&id.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
|
||||
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
|
||||
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
|
||||
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
|
||||
for &(start, id) in items {
|
||||
let q = (start / 4).min(quads);
|
||||
match starts.last_mut() {
|
||||
Some(last) if last.0 == q => last.1 = id,
|
||||
_ => starts.push((q, id)),
|
||||
}
|
||||
}
|
||||
(0..starts.len())
|
||||
.map(|i| {
|
||||
let end = starts.get(i + 1).map_or(quads, |s| s.0);
|
||||
(starts[i].0, end - starts[i].0, starts[i].1)
|
||||
})
|
||||
.filter(|r| r.1 > 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
|
||||
#[test]
|
||||
fn runs_split_the_hand_by_item() {
|
||||
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
|
||||
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
|
||||
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
|
||||
// An item that drew nothing gives way to the next.
|
||||
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
|
||||
}
|
||||
|
||||
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
|
||||
/// apart by it), and x, y match the plain projection.
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Original synthetic material transport oracle; no external shader algorithms or assets.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Material transport GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.fsh"),
|
||||
r#"#version 120
|
||||
uniform sampler2D gtexture;
|
||||
uniform sampler2D normals;
|
||||
uniform sampler2D specular;
|
||||
void main() {
|
||||
int x = int(floor(gl_FragCoord.x));
|
||||
int layer = x - (x / 5) * 5;
|
||||
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
|
||||
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
|
||||
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
|
||||
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
|
||||
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (split, layers) = (2u32, 5u32);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlases: Vec<_> = (0..3)
|
||||
.map(|kind| {
|
||||
let side = if kind == 0 { 4 } else { 2 };
|
||||
let levels = if kind == 0 { 3 } else { 1 };
|
||||
let arrays: Vec<_> = (0..layers.div_ceil(split))
|
||||
.map(|array| {
|
||||
let count = (layers - array * split).min(split);
|
||||
let pixels: Vec<u8> = (0..count)
|
||||
.flat_map(|l| {
|
||||
let layer = (array * split + l) as u8;
|
||||
(0..levels).flat_map(move |level| {
|
||||
let edge = side >> level;
|
||||
(0..edge * edge).flat_map(move |pixel| {
|
||||
if level != 0 {
|
||||
// Sampling unused source mip levels must fail the oracle.
|
||||
[211, 212, 213, 214]
|
||||
} else {
|
||||
match kind {
|
||||
0 if layer == 3 => crate::sprites::NO_SPECULAR,
|
||||
0 => [
|
||||
17 + layer,
|
||||
if pixel % 2 == 0 { 0 } else { 240 },
|
||||
65,
|
||||
if pixel % 2 == 0 { 0 } else { 255 },
|
||||
],
|
||||
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
|
||||
1 => [128 + layer, 126, 255, 19 + layer],
|
||||
_ => [40 + layer, 50, 60, 255],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Original fixture companion array"),
|
||||
size: wgpu::Extent3d {
|
||||
width: side,
|
||||
height: side,
|
||||
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
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::super::pack_atlas::PackAtlas::material(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&arrays,
|
||||
split,
|
||||
layers,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
queue.submit([encoder.finish()]);
|
||||
let pack = horizon_shaders::Pack::open(&root).unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
// Encoded zero alpha is data here and must remain observable in readback.
|
||||
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", ¯os);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
|
||||
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
|
||||
lightmap: atlases[2].view.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &[],
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: true,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
};
|
||||
let mut post = PackPost::new(
|
||||
&device,
|
||||
&queue,
|
||||
&adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
|
||||
// Request the existing copy usage for the fixture's colour readback.
|
||||
post.copied |= 1;
|
||||
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
|
||||
for case in 0..3 {
|
||||
if case == 1 {
|
||||
pass.material_maps = false;
|
||||
}
|
||||
if case == 2 {
|
||||
pass.material_maps = true;
|
||||
post.material_maps = None;
|
||||
}
|
||||
let maps_expected = case == 0;
|
||||
pass.groups.clear();
|
||||
post.resize(&device, [15, 1]);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Initialize fixture depth"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &post.targets.as_ref().unwrap().depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
{
|
||||
let mut render = post.begin(&mut encoder, &pass);
|
||||
render.set_pipeline(pass.render());
|
||||
render.set_bind_group(0, &group, &[]);
|
||||
if pass.draw {
|
||||
post.draws.bind(&mut render, "TERRAIN_SOLID");
|
||||
}
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 15,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
for i in 0..15usize {
|
||||
let layer = (i % 5) as u8;
|
||||
let expected = if i >= 10 {
|
||||
[40 + layer, 50, 60, 255]
|
||||
} else if i >= 5 && (!maps_expected || layer == 2) {
|
||||
crate::sprites::FLAT_NORMAL
|
||||
} else if i >= 5 {
|
||||
[128 + layer, 126, 255, 19 + layer]
|
||||
} else if !maps_expected || layer == 3 {
|
||||
crate::sprites::NO_SPECULAR
|
||||
} else {
|
||||
[17 + layer, 240, 65, 255]
|
||||
};
|
||||
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
|
||||
}
|
||||
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
|
||||
}
|
||||
// Fault injection: the same production binding must refuse a filtering sampler.
|
||||
// If a regression changed both the binding and sampler, the fractional positive
|
||||
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
|
||||
pass.material_maps = true;
|
||||
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
|
||||
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
|
||||
pass.groups.clear();
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
let error = pollster::block_on(validation.pop())
|
||||
.expect("production material binding must reject a filtering sampler");
|
||||
assert!(
|
||||
error.to_string().to_ascii_lowercase().contains("filtering"),
|
||||
"{error}"
|
||||
);
|
||||
eprintln!("Material oracle negative filtering control: rejected by production binding");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -5,12 +5,13 @@
|
||||
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
|
||||
//!
|
||||
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
|
||||
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
|
||||
//! written last (OQ-8). A cleared buffer has both textures cleared at the start of the frame:
|
||||
//! with an odd number of flips a frame, the texture a program writes was "main" the frame before
|
||||
//! and may still hold what was stored into it after that frame's clear (Iris 1.11.7 blends onto
|
||||
//! the clear value there, probed with a pack of our own).
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). Every
|
||||
//! frame starts with A as "main". A cleared buffer has both textures cleared then (Iris 1.11.7
|
||||
//! reads and blends onto the clear value where a program leaves pixels, probed with a pack of our
|
||||
//! own). A buffer that is not cleared reads back what was written last (OQ-8): when the last
|
||||
//! frame ended on B, B is copied to A, and B keeps what was last written into it, which is what
|
||||
//! a write leaves alone or blends onto there (a model that fits Iris 1.11.7's values in probe
|
||||
//! packs of our own).
|
||||
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
|
||||
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
|
||||
|
||||
@@ -34,6 +35,9 @@ mod world_target_tests;
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -96,22 +100,19 @@ enum SamplerKind {
|
||||
impl SamplerKind {
|
||||
fn of(source: Source, filtering: bool) -> Self {
|
||||
match source {
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
if !filtering =>
|
||||
{
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Self::Nearest
|
||||
}
|
||||
// shadowtex0/1 through a plain sampler: linear, as Iris 1.11.7 reads them, when the
|
||||
// device filters their copies (`ShadowTargets::linear`).
|
||||
Source::ShadowDepth(_, false) if filtering => Self::Linear,
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels.
|
||||
Source::Atlas => Self::NearestRepeat,
|
||||
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
|
||||
Source::Image(_) if filtering => Self::Linear,
|
||||
Source::Image(_) => Self::Nearest,
|
||||
Source::Custom { blur, clamp, .. } => match (blur && filtering, clamp) {
|
||||
@@ -120,12 +121,9 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
| Source::Lightmap => Self::Linear,
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +364,7 @@ struct Pass {
|
||||
draw: bool,
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: bool,
|
||||
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
|
||||
ids: bool,
|
||||
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
|
||||
@@ -437,6 +436,7 @@ struct Targets {
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
pub material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
@@ -522,8 +522,9 @@ pub struct PackPost {
|
||||
lines: Option<Pass>,
|
||||
line_draw: Option<Lines>,
|
||||
cloud_draw: Option<Clouds>,
|
||||
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
|
||||
/// clouds discards vanilla's), or `clouds=off`: the game's clouds are not drawn.
|
||||
/// The pack draws the clouds (`gbuffers_clouds` or its fallback), even with a program that
|
||||
/// writes nothing (a pack drawing its own clouds discards vanilla's), or `clouds=off`: the
|
||||
/// game's clouds are not drawn.
|
||||
hides_clouds: bool,
|
||||
/// The pack has a sky (`gbuffers_skybasic`) or sun and moon (`gbuffers_skytextured`)
|
||||
/// program: vanilla's stays hidden even when the program writes nothing.
|
||||
@@ -573,6 +574,7 @@ pub struct PackPost {
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
lightmap: wgpu::TextureView,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
@@ -617,7 +619,9 @@ pub struct PackPost {
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
nearest_repeat: wgpu::Sampler,
|
||||
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
|
||||
/// nor buffer to clear.
|
||||
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
|
||||
blit_depth: bool,
|
||||
blit_clears: Vec<usize>,
|
||||
@@ -939,6 +943,14 @@ fn blend_state(b: horizon_shaders::Blend) -> wgpu::BlendState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A buffer whose texels are floats (normalized or not), not integers.
|
||||
fn holds_floats(format: wgpu::TextureFormat) -> bool {
|
||||
matches!(
|
||||
format.sample_type(None, None),
|
||||
Some(wgpu::TextureSampleType::Float { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -1060,6 +1072,8 @@ pub(super) struct Geometry<'a> {
|
||||
pub depth_write: bool,
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
pub atlas: Option<wgpu::TextureView>,
|
||||
/// This draw uses the block atlas's surface identities.
|
||||
pub material_maps: bool,
|
||||
/// `mc_Entity`'s table at set 3 (flat terrain).
|
||||
pub ids: Option<&'a wgpu::BindGroupLayout>,
|
||||
/// Back faces are culled, as for the game's own terrain.
|
||||
@@ -1089,6 +1103,9 @@ struct Resources<'a> {
|
||||
released: std::cell::Cell<(&'static str, u16)>,
|
||||
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
|
||||
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
|
||||
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
|
||||
/// unset, and the device filters `R32Float`).
|
||||
shadow_linear: u8,
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1213,6 +1230,7 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::ShadowDepth(n, false) if pack.shadow_linear & 1 << n != 0 => float,
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
@@ -1531,11 +1549,12 @@ fn build_pass(
|
||||
.iter()
|
||||
.filter(|f| !f.1)
|
||||
.fold(0u16, |m, f| m | 1 << f.0);
|
||||
// Gbuffers programs write in place (6.1.6).
|
||||
// Gbuffers programs write in place (6.1.6). A forced flip of a buffer the program writes
|
||||
// cancels the write's own flip, as in Iris 1.11.7 (probed with packs of our own).
|
||||
let flip = if final_pass || geometry.is_some() {
|
||||
0
|
||||
} else {
|
||||
(written & !kept) | mask(&p.flips)
|
||||
(written & !kept) ^ mask(&p.flips)
|
||||
};
|
||||
let created = checked(device, || {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1624,7 +1643,11 @@ fn build_pass(
|
||||
.map(|u| u.name.as_str())
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
|
||||
notes.push(format!(
|
||||
"{}: shader defaults or zero: {}",
|
||||
p.name,
|
||||
missing.join(" ")
|
||||
));
|
||||
}
|
||||
let image_copies = image_copies(&samplers, &images);
|
||||
Some(Pass {
|
||||
@@ -1634,7 +1657,7 @@ fn build_pass(
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -1652,6 +1675,7 @@ fn build_pass(
|
||||
mipmaps: p.mipmaps,
|
||||
draw,
|
||||
atlas: geometry.and_then(|g| g.atlas.clone()),
|
||||
material_maps: geometry.is_some_and(|g| g.material_maps),
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -1762,7 +1786,7 @@ fn build_compute(
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -1787,6 +1811,7 @@ fn build_compute(
|
||||
mipmaps: 0,
|
||||
draw: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: false,
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -1940,6 +1965,13 @@ impl PackPost {
|
||||
rgb,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
|
||||
(0..2)
|
||||
.filter(|&n| !shadow_config.nearest[n])
|
||||
.fold(0, |m, n| m | 1 << n)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
let in_shadow = p.group.shadow();
|
||||
@@ -2032,6 +2064,7 @@ impl PackPost {
|
||||
table: g.table,
|
||||
depth_write: g.depth_write,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: block_ids.as_ref().map(|i| &i.0),
|
||||
cull: g.cull,
|
||||
});
|
||||
@@ -2059,6 +2092,7 @@ impl PackPost {
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2068,6 +2102,7 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: false,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2106,6 +2141,7 @@ impl PackPost {
|
||||
table: Some(&e.table),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
@@ -2117,6 +2153,7 @@ impl PackPost {
|
||||
table: g.table,
|
||||
depth_write: false,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2144,6 +2181,7 @@ impl PackPost {
|
||||
table: Some(layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
@@ -2153,16 +2191,20 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
let hand_geometry = world.hand.then(|| super::pack_hand::PackHand::new(device));
|
||||
let hand_geometry = world
|
||||
.hand
|
||||
.then(|| super::pack_hand::PackHand::new(device, &draws.layout));
|
||||
let hand = post.hand.zip(hand_geometry).and_then(|(p, geometry)| {
|
||||
let g = Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2187,6 +2229,7 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2313,8 +2356,21 @@ impl PackPost {
|
||||
let depth1 = every_pass()
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
|
||||
let t =
|
||||
super::pack_shadow::ShadowTargets::new(device, &shadow_config, f, colors, depth1);
|
||||
let linear = every_pass()
|
||||
.flat_map(|p| &p.samplers)
|
||||
.filter(|s| s.filtering)
|
||||
.fold(0u8, |m, s| match s.source {
|
||||
Source::ShadowDepth(n, false) => m | 1 << n,
|
||||
_ => m,
|
||||
});
|
||||
let t = super::pack_shadow::ShadowTargets::new(
|
||||
device,
|
||||
&shadow_config,
|
||||
f,
|
||||
colors,
|
||||
depth1,
|
||||
linear,
|
||||
);
|
||||
notes.push(format!(
|
||||
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
|
||||
t.size,
|
||||
@@ -2385,7 +2441,13 @@ impl PackPost {
|
||||
}
|
||||
});
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut world_target = target(formats[0]);
|
||||
// The world's colour is floats: an integer colortex0 (`RGBA32UI`...) gets only what the
|
||||
// pack writes, and is cleared in a pass of its own.
|
||||
let world_color = holds_floats(formats[0]);
|
||||
if !world_color {
|
||||
other_clears.push(0);
|
||||
}
|
||||
let mut world_target = world_color.then(|| target(formats[0])).flatten();
|
||||
if rgb & 1 != 0
|
||||
&& let Some(t) = &mut world_target
|
||||
{
|
||||
@@ -2402,28 +2464,30 @@ impl PackPost {
|
||||
})
|
||||
}))
|
||||
.collect();
|
||||
let blit = helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
// GL's window depth, written for depthtex0 and the terrain's depth test.
|
||||
blit_depth.then_some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
);
|
||||
let blit = (world_color || blit_depth || !cleared.is_empty()).then(|| {
|
||||
helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
// GL's window depth, written for depthtex0 and the terrain's depth test.
|
||||
blit_depth.then_some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
)
|
||||
});
|
||||
let copy = helper(
|
||||
device,
|
||||
"vs_uv",
|
||||
@@ -2513,6 +2577,7 @@ impl PackPost {
|
||||
center_depth: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
material_maps: world.material_maps,
|
||||
lightmap: world.lightmap,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
@@ -2724,6 +2789,12 @@ impl PackPost {
|
||||
shadow_color(i, usize::from(key & 1 << (32 + i) != 0))
|
||||
.unwrap_or(&self.dummy)
|
||||
}
|
||||
Source::ShadowDepth(i, false) if s.filtering => {
|
||||
match shadow.and_then(|t| t.linear[i].as_ref()) {
|
||||
Some(v) => v,
|
||||
None => &self.dummy,
|
||||
}
|
||||
}
|
||||
Source::ShadowDepth(i, _) => match shadow {
|
||||
Some(t) => match (i, &t.depth1) {
|
||||
(1, Some(d)) => &d.1,
|
||||
@@ -2792,8 +2863,16 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals => &self.dummy_normal,
|
||||
Source::Specular => &self.dummy_black,
|
||||
Source::Normals => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
.filter(|_| pass.material_maps)
|
||||
.map_or(&self.dummy_normal, |maps| &maps[0]),
|
||||
Source::Specular => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
.filter(|_| pass.material_maps)
|
||||
.map_or(&self.dummy_black, |maps| &maps[1]),
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
@@ -3104,42 +3183,17 @@ impl PackPost {
|
||||
continue;
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs
|
||||
// that wrap it with their own coordinates sample the portal sprite only.
|
||||
let portal_group = if geometry
|
||||
.draws
|
||||
.iter()
|
||||
.any(|d| d.pack && d.program == program && d.portal)
|
||||
&& let Some(atlas) = pass.atlas.clone().or_else(|| self.atlas.clone())
|
||||
&& self.portal_rect[2] > 0.0
|
||||
{
|
||||
if self
|
||||
.portal_texture
|
||||
.as_ref()
|
||||
.is_none_or(|(rect, _)| *rect != self.portal_rect)
|
||||
{
|
||||
pass.portal_groups.clear();
|
||||
let view = super::pack_weather::copy_sprite(
|
||||
device,
|
||||
encoder,
|
||||
atlas.texture(),
|
||||
self.portal_rect,
|
||||
);
|
||||
self.portal_texture = Some((self.portal_rect, view));
|
||||
}
|
||||
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
|
||||
// `bind` caches groups by a key without the atlas: the portal groups are kept
|
||||
// in a cache of their own, swapped in with the texture.
|
||||
let saved = std::mem::replace(&mut pass.atlas, view);
|
||||
let groups =
|
||||
std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
|
||||
let g = self.bind(device, queue, encoder, pass, size);
|
||||
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
|
||||
pass.atlas = saved;
|
||||
Some(g)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let portal_group = self.portal_group(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
geometry
|
||||
.draws
|
||||
.iter()
|
||||
.any(|d| d.pack && d.program == program && d.portal),
|
||||
);
|
||||
{
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
@@ -3171,6 +3225,47 @@ impl PackPost {
|
||||
self.entities = Some(entities);
|
||||
}
|
||||
|
||||
/// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs that
|
||||
/// wrap it with their own coordinates sample the portal sprite only. `None` when no portal
|
||||
/// face draws in this pass.
|
||||
fn portal_group(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pass: &mut Pass,
|
||||
size: [u32; 2],
|
||||
wanted: bool,
|
||||
) -> Option<wgpu::BindGroup> {
|
||||
let atlas = pass.atlas.clone().or_else(|| self.atlas.clone())?;
|
||||
if !wanted || self.portal_rect[2] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.portal_texture
|
||||
.as_ref()
|
||||
.is_none_or(|(rect, _)| *rect != self.portal_rect)
|
||||
{
|
||||
let view = super::pack_weather::copy_sprite(
|
||||
device,
|
||||
encoder,
|
||||
atlas.texture(),
|
||||
self.portal_rect,
|
||||
);
|
||||
self.portal_texture = Some((self.portal_rect, view));
|
||||
pass.portal_groups.clear();
|
||||
}
|
||||
// `bind` caches groups by a key without the atlas: the portal groups are kept in a cache
|
||||
// of their own, swapped in with the texture.
|
||||
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
|
||||
let saved = std::mem::replace(&mut pass.atlas, view);
|
||||
let groups = std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
|
||||
pass.atlas = saved;
|
||||
Some(group)
|
||||
}
|
||||
|
||||
/// Entities and block entities into the shadow map, each run with the per-draw uniforms
|
||||
/// of its gbuffers draw (2.4): opaque, so before the `shadowtex1` copy.
|
||||
fn draw_shadow_entities(
|
||||
@@ -3195,6 +3290,15 @@ impl PackPost {
|
||||
continue;
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
let portal_group = self.portal_group(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
|
||||
);
|
||||
let mut on_portal = false;
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
@@ -3207,6 +3311,12 @@ impl PackPost {
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for d in draws {
|
||||
if let Some(portal) = &portal_group
|
||||
&& d.portal != on_portal
|
||||
{
|
||||
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
|
||||
on_portal = d.portal;
|
||||
}
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
|
||||
}
|
||||
@@ -3359,7 +3469,7 @@ impl PackPost {
|
||||
size[0] as f32 / size[1].max(1) as f32,
|
||||
frame.depth_far,
|
||||
) * unspin;
|
||||
geometry.prepare(device, queue, frame.hand, projection);
|
||||
geometry.prepare(device, queue, frame.hand, frame.hand_items, projection);
|
||||
if geometry.quads > 0 {
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
@@ -3372,12 +3482,15 @@ impl PackPost {
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
if pass.draw {
|
||||
self.draws.bind(&mut rp, "HAND_SOLID");
|
||||
}
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
rp.draw_indexed(0..geometry.quads * 6, 0, 0..1);
|
||||
// The arm and each held item: their own `currentRenderedItemId`.
|
||||
for &(first, quads, offset) in &geometry.runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
@@ -3743,6 +3856,10 @@ impl PackPost {
|
||||
if self.copied & 1 << i != 0 {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
// A buffer that isn't cleared starts each frame with B copied back to A.
|
||||
if i != 0 && self.clears[i].is_none() {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
let sides = [0, 1]
|
||||
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
|
||||
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
|
||||
@@ -3907,6 +4024,9 @@ impl PackPost {
|
||||
TerrainList::ShadowTranslucent,
|
||||
terrain,
|
||||
);
|
||||
if let Some(t) = &self.shadow_targets {
|
||||
t.copy_linear(encoder);
|
||||
}
|
||||
let mut comp = std::mem::take(&mut self.shadow_comp);
|
||||
for pass in &mut comp {
|
||||
let mut compute = std::mem::take(&mut pass.compute);
|
||||
@@ -4128,6 +4248,20 @@ impl PackPost {
|
||||
&mut self.zeros,
|
||||
);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
// Every frame starts on A. A buffer that isn't cleared and ended the last frame on B has B
|
||||
// copied back to A, and B keeps what was last written into it: a model that fits Iris 1.11.7's
|
||||
// values in probe packs of our own (#9009), not a documented Iris rule. colortex0's A is
|
||||
// overwritten by the world below.
|
||||
for b in (1..BUFFERS).filter(|&b| self.main & 1 << b != 0 && self.clears[b].is_none()) {
|
||||
if let Some([a, b_side]) = &targets.color[b] {
|
||||
encoder.copy_texture_to_texture(
|
||||
b_side.texture().as_image_copy(),
|
||||
a.texture().as_image_copy(),
|
||||
a.texture().size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.main = 0;
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
@@ -4229,14 +4363,15 @@ impl PackPost {
|
||||
}
|
||||
|
||||
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
|
||||
if self
|
||||
.blit_group
|
||||
.as_ref()
|
||||
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
|
||||
if let Some(blit) = &self.blit
|
||||
&& self
|
||||
.blit_group
|
||||
.as_ref()
|
||||
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
layout: &self.blit.1,
|
||||
layout: &blit.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -4250,7 +4385,7 @@ impl PackPost {
|
||||
});
|
||||
self.blit_group = Some(([world.clone(), world_depth.clone()], group));
|
||||
}
|
||||
{
|
||||
if let Some(blit) = &self.blit {
|
||||
// The blit leaves a three-channel colortex0's alpha at the 1 it starts with.
|
||||
let world_ops = if self.rgb & 1 != 0 {
|
||||
wgpu::Operations {
|
||||
@@ -4260,10 +4395,11 @@ impl PackPost {
|
||||
} else {
|
||||
overwrite
|
||||
};
|
||||
let mut attachments = vec![attach(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
world_ops,
|
||||
)];
|
||||
let mut attachments = vec![if holds_floats(self.formats[0]) {
|
||||
attach(view(self.main, 0, false).expect("colortex0"), world_ops)
|
||||
} else {
|
||||
None
|
||||
}];
|
||||
for &b in &self.blit_clears {
|
||||
attachments.push(attach(
|
||||
view(self.main, b, false).expect("used buffer"),
|
||||
@@ -4286,7 +4422,7 @@ impl PackPost {
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.blit.0);
|
||||
pass.set_pipeline(&blit.0);
|
||||
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
@@ -4435,7 +4571,14 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
if !shown {
|
||||
// An integer colortex0 has no colour to show: the screen is only cleared.
|
||||
if !shown && !holds_floats(self.formats[0]) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
} else if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
@@ -4587,6 +4730,7 @@ mod tests {
|
||||
}];
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
initial_values: vec![0; 48],
|
||||
uniforms: Vec::new(),
|
||||
samplers: vec![horizon_shaders::Sampler {
|
||||
name: "colortex0".into(),
|
||||
@@ -4616,6 +4760,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let reads = |group: Group| {
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -4665,6 +4810,7 @@ mod tests {
|
||||
};
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
initial_values: vec![0; 48],
|
||||
uniforms: Vec::new(),
|
||||
samplers: Vec::new(),
|
||||
outputs: vec![0],
|
||||
@@ -4694,6 +4840,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
@@ -4834,6 +4981,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let draws = DrawUniforms::new(&device);
|
||||
let mut notes = Vec::new();
|
||||
@@ -4996,5 +5144,18 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod destination_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_material_tests.rs"]
|
||||
mod material_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trinary_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod const_offset_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integer_colortex0_tests;
|
||||
@@ -0,0 +1,111 @@
|
||||
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
|
||||
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
|
||||
use super::*;
|
||||
|
||||
/// colortex6 is written three times a frame, the first write leaving the right half alone:
|
||||
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
|
||||
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
|
||||
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
|
||||
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
|
||||
#[test]
|
||||
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
|
||||
let programs = [
|
||||
(
|
||||
"composite",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
|
||||
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
|
||||
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
|
||||
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
|
||||
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.25)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite3",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite4",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite5",
|
||||
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
|
||||
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
for (program, fragment) in &programs {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"clear-false-history",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..4u8 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let sum = 32 * (frame + 1);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(n, p)| {
|
||||
let expected = match (n % 16 < 8, frame) {
|
||||
(true, _) => [128, 128, sum, 255],
|
||||
(false, 0) => [0, 0, sum, 255],
|
||||
(false, _) => [32, 0, sum, 255],
|
||||
};
|
||||
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
|
||||
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// Each texel of colortex1 holds its own column and row, in eighths.
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1 */\n\
|
||||
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Red and green: what the texel two columns right and one row down holds; blue: this row.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
|
||||
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
|
||||
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-const-offset",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_const_local_offset_reads_the_offset_texel() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
|
||||
let mut checked = 0;
|
||||
for (n, p) in pixels.iter().enumerate() {
|
||||
let x = (n % 8) as u8;
|
||||
// The bottom row (GL's row 0) and the two right columns read past the edge.
|
||||
if x >= 6 || p[2] == 0 {
|
||||
continue;
|
||||
}
|
||||
let row = (0..8)
|
||||
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
|
||||
.expect("row in blue");
|
||||
let want = [eighth(x + 2), eighth(row - 1)];
|
||||
assert!(
|
||||
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
|
||||
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 6 * 7);
|
||||
}
|
||||
@@ -315,19 +315,21 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
shaders(sampler, stage, scene),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: terrain.then_some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
@@ -617,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -635,3 +638,143 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
|
||||
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
|
||||
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
|
||||
/// both stage files.
|
||||
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let gpu = Gpu::new()?;
|
||||
if !gpu
|
||||
.device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_FILTERABLE)
|
||||
{
|
||||
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
|
||||
return None;
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
|
||||
let files = vec![
|
||||
("final.vsh".to_string(), VERTEX.to_string()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 330 compatibility\n{settings}\
|
||||
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
|
||||
float moved(sampler2D s, ivec2 t) {{\n\
|
||||
float a = texelFetch(s, t, 0).r;\n\
|
||||
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
|
||||
}}\n\
|
||||
void main() {{\n\
|
||||
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
|
||||
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
|
||||
}}\n"
|
||||
),
|
||||
),
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
(
|
||||
"shadow.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
"shadow.fsh".into(),
|
||||
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
|
||||
),
|
||||
];
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: false,
|
||||
material_maps: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow depth ramp"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
Some(
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
|
||||
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
|
||||
if let Some(pixels) = shadow_depth_filtering("") {
|
||||
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowtex_nearest_settings_read_the_nearest_texel() {
|
||||
for (line, expected) in [
|
||||
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
|
||||
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
|
||||
] {
|
||||
if let Some(pixels) = shadow_depth_filtering(line) {
|
||||
all_pixels(&pixels, expected, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ impl Fixture {
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
@@ -346,8 +347,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for clear in [true, false] {
|
||||
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
|
||||
// Every frame changes which physical image is main. The other image is fully
|
||||
// seeded red before the partial write, independent of its initial clearing.
|
||||
// The other image is fully seeded red before the partial write, independent of its
|
||||
// initial clearing.
|
||||
for frame in 0..3 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
assert!(
|
||||
@@ -433,6 +434,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -521,3 +523,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "clear_false_tests.rs"]
|
||||
mod clear_false_tests;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
|
||||
//! are Iris 1.11.7's, measured with probe packs of our own.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
|
||||
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
|
||||
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
|
||||
#[test]
|
||||
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
for (program, fragment) in [
|
||||
(
|
||||
"composite",
|
||||
"#version 120\n/* RENDERTARGETS:2,4 */\n\
|
||||
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
|
||||
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
|
||||
),
|
||||
] {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add(
|
||||
"shaders.properties",
|
||||
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"forced-flip-of-a-written-buffer",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..2 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(_, p)| {
|
||||
p.iter()
|
||||
.zip([191, 0, 0, 255])
|
||||
.any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
|
||||
fn pack(depth: bool, last: bool) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
|
||||
let value = if depth {
|
||||
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
|
||||
} else {
|
||||
"uvec4(0u, 200u, 0u, 255u)"
|
||||
};
|
||||
add(
|
||||
"composite.fsh",
|
||||
&format!(
|
||||
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if last {
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
|
||||
);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(
|
||||
post.programs.len(),
|
||||
1 + usize::from(last),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for depth in [false, true] {
|
||||
let mut post = gpu.build(pack(depth, true));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
|
||||
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
|
||||
/// then was not measured. The frame must still run.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_final_still_runs() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack(false, false));
|
||||
for _ in 0..2 {
|
||||
gpu.render(&mut post, [8, 8]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
|
||||
//! and median of three values, for float, int and uint vectors (the extension's additions to
|
||||
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows case x % 8 in red, green and blue.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
|
||||
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
|
||||
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
|
||||
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
|
||||
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
|
||||
vec3 o = vec3(0.0);\n\
|
||||
switch (x) {\n\
|
||||
case 0: o = min3(a, b, c); break;\n\
|
||||
case 1: o = max3(a, b, c); break;\n\
|
||||
case 2: o = mid3(a, b, c); break;\n\
|
||||
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
|
||||
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
|
||||
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
|
||||
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
|
||||
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
|
||||
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-trinary",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trinary_min_max_and_median_per_component() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
|
||||
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
|
||||
let tenths: [[u8; 3]; 8] = [
|
||||
[1, 2, 0],
|
||||
[6, 9, 8],
|
||||
[3, 5, 4],
|
||||
[0, 4, 2],
|
||||
[6, 8, 7],
|
||||
[3, 5, 0],
|
||||
[4, 5, 3],
|
||||
[6, 2, 0],
|
||||
];
|
||||
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
|
||||
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
|
||||
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
|
||||
assert!(
|
||||
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
|
||||
"case {case}: {got:?}, expected {want:?}; row {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,7 @@ fn pack(
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(super) struct ShadowTargets {
|
||||
pub depth: (wgpu::Texture, wgpu::TextureView),
|
||||
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
|
||||
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
|
||||
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
|
||||
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
|
||||
pub linear: [Option<wgpu::TextureView>; 2],
|
||||
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
|
||||
/// and their stride in bytes.
|
||||
staging: Option<(wgpu::Buffer, u32)>,
|
||||
/// Both sides of each shadowcolor buffer used.
|
||||
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
|
||||
/// The clear colour of each buffer cleared every frame.
|
||||
@@ -77,13 +84,15 @@ fn target(
|
||||
|
||||
impl ShadowTargets {
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
|
||||
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
|
||||
/// sampler). The resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
depth1: bool,
|
||||
linear: u8,
|
||||
) -> Self {
|
||||
let size = shadow
|
||||
.resolution
|
||||
@@ -105,10 +114,43 @@ impl ShadowTargets {
|
||||
})
|
||||
})
|
||||
});
|
||||
let linear = [0, 1].map(|n| {
|
||||
(linear & 1 << n != 0).then(|| {
|
||||
let label = format!("Shader pack shadowtex{n} (filtered)");
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
});
|
||||
let staging = linear.iter().any(Option::is_some).then(|| {
|
||||
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack shadow depth rows"),
|
||||
size: u64::from(stride) * u64::from(size),
|
||||
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
(buffer, stride)
|
||||
});
|
||||
Self {
|
||||
size,
|
||||
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
|
||||
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
|
||||
linear,
|
||||
staging,
|
||||
color,
|
||||
clears: std::array::from_fn(|n| {
|
||||
let b = &shadow.colors[n];
|
||||
@@ -214,4 +256,38 @@ impl ShadowTargets {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
|
||||
/// copy before translucent geometry).
|
||||
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((buffer, stride)) = &self.staging else {
|
||||
return;
|
||||
};
|
||||
for (n, linear) in self.linear.iter().enumerate() {
|
||||
let Some(linear) = linear else {
|
||||
continue;
|
||||
};
|
||||
let depth = match (n, &self.depth1) {
|
||||
(1, Some(d)) => &d.0,
|
||||
_ => &self.depth.0,
|
||||
};
|
||||
let rows = wgpu::TexelCopyBufferInfo {
|
||||
buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(*stride),
|
||||
rows_per_image: None,
|
||||
},
|
||||
};
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: wgpu::TextureAspect::DepthOnly,
|
||||
..depth.as_image_copy()
|
||||
},
|
||||
rows,
|
||||
depth.size(),
|
||||
);
|
||||
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
],
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1388,6 +1388,7 @@ mod initial_history_tests {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1405,7 +1405,8 @@ fn pack_coords(coords: u32) -> u16 {
|
||||
pub struct Mesher<'a> {
|
||||
/// CardinalLighting.NETHER: brighter up/down faces.
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
|
||||
/// only the dimension's up-face brightness (`lighting::pack_flat`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
@@ -1660,8 +1661,10 @@ impl Mesher<'_> {
|
||||
None => ao,
|
||||
};
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = if self.shader_pack_lighting || !q.shade {
|
||||
let directional = if !q.shade {
|
||||
1.0
|
||||
} else if self.shader_pack_lighting {
|
||||
lighting::pack_flat(self.nether_shading)
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
|
||||
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
|
||||
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
|
||||
/// alpha for separate AO.
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
|
||||
use horizon_assets::model::Direction as D;
|
||||
let cardinal = |d: D| {
|
||||
if shader_pack_lighting {
|
||||
1.0
|
||||
lighting::pack_flat(nether)
|
||||
} else {
|
||||
lighting::cardinal(nether, d)
|
||||
}
|
||||
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
|
||||
Some(layer) if face_state.overlay => (layer, true),
|
||||
_ => (flow, false),
|
||||
};
|
||||
let side = if matches!(d, Direction::North | Direction::South) {
|
||||
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
|
||||
let side = if shader_pack_lighting {
|
||||
1.0
|
||||
} else if matches!(d, Direction::North | Direction::South) {
|
||||
cardinal(D::North)
|
||||
} else {
|
||||
cardinal(D::West)
|
||||
|
||||
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
|
||||
D::West | D::East => 0.6,
|
||||
}
|
||||
}
|
||||
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
|
||||
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
|
||||
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
|
||||
pub(super) fn pack_flat(nether: bool) -> f32 {
|
||||
cardinal(nether, D::Up)
|
||||
}
|
||||
fn normal(face: D) -> [i32; 3] {
|
||||
match face {
|
||||
D::Down => [0, -1, 0],
|
||||
|
||||
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
|
||||
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
|
||||
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
|
||||
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let colours = |nether_shading| {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
|
||||
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
|
||||
assert!(all.len() >= 48, "{} vertices", all.len());
|
||||
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
|
||||
colours.sort_unstable();
|
||||
colours.dedup();
|
||||
colours
|
||||
};
|
||||
assert_eq!(colours(true), [0xE5_E5E5]);
|
||||
assert_eq!(colours(false), [0xFF_FFFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
|
||||
@@ -8039,7 +8039,7 @@ mod tests {
|
||||
|
||||
// Also verify Act::Extra on Page::Extras
|
||||
ui.show(Page::Extras);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let (widgets, top, _, _) = ui.layout(&s, size.0, size.1);
|
||||
if let Some(extra_btn) = widgets.iter().find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
@@ -8050,6 +8050,22 @@ mod tests {
|
||||
}
|
||||
)
|
||||
}) {
|
||||
// Extras has enough rows to scroll: click the cycle button inside
|
||||
// the list's viewport, as a player would after scrolling to it.
|
||||
ui.scroll = (extra_btn.y - top).max(0.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let extra_btn = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Shown);
|
||||
|
||||
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
|
||||
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
|
||||
/// non-negative ints whose second is the minor. None: it does not parse.
|
||||
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
|
||||
let non_negative =
|
||||
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
|
||||
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
|
||||
// counts as its whole part (97), as it does for the game.
|
||||
let non_negative = |v: &serde_json::Value| {
|
||||
v.as_i64()
|
||||
.or_else(|| {
|
||||
v.as_f64()
|
||||
.filter(|f| f.is_finite())
|
||||
.map(|f| f.trunc() as i64)
|
||||
})
|
||||
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
|
||||
};
|
||||
match v {
|
||||
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
|
||||
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
|
||||
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
let mut hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A companion from below the winning colour would describe another surface.
|
||||
// A map-only pack above it is intentional and remains valid.
|
||||
for (name, (owner, _)) in &files {
|
||||
let Some(stem) = name.strip_suffix(".png") else {
|
||||
continue;
|
||||
};
|
||||
if stem.ends_with("_n") || stem.ends_with("_s") {
|
||||
continue;
|
||||
}
|
||||
for suffix in ["_n.png", "_s.png"] {
|
||||
let map = format!("{stem}{suffix}");
|
||||
if files
|
||||
.get(&map)
|
||||
.is_none_or(|(map_owner, _)| map_owner < owner)
|
||||
{
|
||||
hidden.insert(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
@@ -1257,6 +1285,16 @@ mod tests {
|
||||
.unwrap_err()
|
||||
.contains("deprecated")
|
||||
);
|
||||
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
|
||||
// that meant `[97, 1]` still loads and counts as compatible).
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
|
||||
Ok(((97, 0), (97, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97.9,"max_format":98.5}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97,"max_format":98}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
@@ -1469,6 +1507,61 @@ mod tests {
|
||||
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
|
||||
}
|
||||
#[test]
|
||||
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
|
||||
let vanilla = root.join("vanilla");
|
||||
let relative = "assets/minecraft/textures/block";
|
||||
fs::create_dir_all(vanilla.join(relative)).unwrap();
|
||||
let names = ["stone.png", "stone_n.png", "stone_s.png"];
|
||||
for name in names {
|
||||
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
|
||||
}
|
||||
for (pack, files) in [
|
||||
("low", vec!["stone_n.png", "stone_s.png"]),
|
||||
("colour", vec!["stone.png"]),
|
||||
("maps", vec!["stone_n.png"]),
|
||||
] {
|
||||
let dir = root.join("packs").join(pack);
|
||||
fs::create_dir_all(dir.join(relative)).unwrap();
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for name in files {
|
||||
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
let all = discover(&vanilla, &root.join("packs"));
|
||||
let order = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
all.iter()
|
||||
.find(|p| p.id == format!("file/{name}"))
|
||||
.unwrap()
|
||||
.clone()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let out = root.join("out");
|
||||
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone.png")).unwrap(),
|
||||
b"colour"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_n.png").exists());
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
|
||||
b"maps"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
const VERSION: u32 = 8;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -8,6 +8,7 @@ pub struct SpriteAnimation {
|
||||
pub layer: u32,
|
||||
frames: Arc<Vec<Vec<u8>>>,
|
||||
clip: Arc<AnimationClip>,
|
||||
materials: [Option<Arc<Vec<Vec<u8>>>>; 2],
|
||||
}
|
||||
/// What an animated sprite shows: `frame`, mixed `millis` thousandths of the
|
||||
/// way towards `next` (0 for a single frame).
|
||||
@@ -18,6 +19,14 @@ pub struct FrameBlend {
|
||||
pub millis: u32,
|
||||
}
|
||||
impl SpriteAnimation {
|
||||
pub(crate) fn has_materials(&self) -> bool {
|
||||
self.materials.iter().any(Option::is_some)
|
||||
}
|
||||
/// Material categories follow the selected colour frame, without colour interpolation.
|
||||
pub(crate) fn material_pixels(&self, kind: usize, tick: u64) -> Option<&[u8]> {
|
||||
let frames = self.materials[kind].as_ref()?;
|
||||
Some(&frames[self.frame(tick) % frames.len()])
|
||||
}
|
||||
pub fn frame(&self, tick: u64) -> usize {
|
||||
self.clip.sample(tick).frame as usize
|
||||
}
|
||||
@@ -88,9 +97,9 @@ 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)>,
|
||||
/// 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]>,
|
||||
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their
|
||||
/// frames at their own resolution (square).
|
||||
pbr: Vec<[Option<(u32, Vec<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>,
|
||||
@@ -126,7 +135,7 @@ pub fn set_texture_budget(mib: u32) {
|
||||
/// 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
|
||||
/// textures. Native material stages and terrain shader packs read them; without a pack they cost
|
||||
/// neither loading time nor video memory.
|
||||
pub fn set_labpbr_maps(on: bool) {
|
||||
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
|
||||
@@ -135,7 +144,7 @@ pub fn set_labpbr_maps(on: bool) {
|
||||
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.
|
||||
/// Budget for either complete normal or specular image set.
|
||||
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.
|
||||
@@ -153,30 +162,12 @@ fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
|
||||
}
|
||||
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.
|
||||
/// Reduce data maps by selecting whole texels: material IDs and sentinel bytes cannot be averaged.
|
||||
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)
|
||||
(max, resample(&frame, side, max))
|
||||
}
|
||||
/// Nearest-resample a square RGBA frame to another square size.
|
||||
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
|
||||
@@ -289,14 +280,28 @@ impl Sprites {
|
||||
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 animation in &mut self.animations {
|
||||
animation.materials = self.pbr[animation.layer as usize].each_ref().map(|map| {
|
||||
map.as_ref().map(|(from, frames)| {
|
||||
Arc::new(
|
||||
frames
|
||||
.iter()
|
||||
.map(|frame| resample(frame, *from, pbr_size))
|
||||
.collect(),
|
||||
)
|
||||
})
|
||||
});
|
||||
}
|
||||
for (layer, maps) in std::mem::take(&mut self.pbr).into_iter().enumerate() {
|
||||
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);
|
||||
Some((from, frames)) => {
|
||||
let initial = self.native[layer].2 % frames.len();
|
||||
let (from, pixels) =
|
||||
shrink_map(frames[initial].clone(), from, pbr_size);
|
||||
out.extend(resample(&pixels, from, pbr_size));
|
||||
}
|
||||
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
|
||||
@@ -638,8 +643,10 @@ impl Sprites {
|
||||
let pbr = match name.split_once(':') {
|
||||
Some(_) if labpbr_maps() => {
|
||||
let path = crate::atlas_sources::texture_path(root, name, "textures");
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
|
||||
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count, animation.as_ref()))
|
||||
}
|
||||
None if labpbr_maps() && !name.starts_with("fusion/") => ["_n", "_s"]
|
||||
.map(|suffix| pbr_map(&root.join(name), suffix, count, animation.as_ref())),
|
||||
_ => [None, None],
|
||||
};
|
||||
let settings = crate::mipmap::Settings {
|
||||
@@ -691,6 +698,7 @@ impl Sprites {
|
||||
layer,
|
||||
frames: frames.clone(),
|
||||
clip: Arc::new(clip),
|
||||
materials: [None, None],
|
||||
});
|
||||
}
|
||||
self.mips.push(settings);
|
||||
@@ -734,32 +742,64 @@ fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
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>)> {
|
||||
/// Companion frames use the colour image's grid; a single-frame map stays static.
|
||||
fn pbr_map(
|
||||
path: &Path,
|
||||
suffix: &str,
|
||||
count: u32,
|
||||
animation: Option<&AnimationMetadata>,
|
||||
) -> Option<(u32, Vec<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));
|
||||
let (width, height, _) = read_rgba(path).ok()?;
|
||||
let [frame_width, frame_height] = animation.map_or([width as i32, height as i32], |a| {
|
||||
a.frame_size(width as i32, height as i32)
|
||||
});
|
||||
let (frame_width, frame_height) = (
|
||||
u32::try_from(frame_width).ok()?,
|
||||
u32::try_from(frame_height).ok()?,
|
||||
);
|
||||
if frame_width == 0 || frame_height == 0 {
|
||||
return None;
|
||||
}
|
||||
let (columns, rows) = if count <= 1 || (map_width == frame_width && map_height == frame_height)
|
||||
{
|
||||
(1, 1)
|
||||
} else if u64::from(map_width) * u64::from(height) == u64::from(map_height) * u64::from(width) {
|
||||
(width / frame_width, height / frame_height)
|
||||
} else if u64::from(map_width) * u64::from(frame_height)
|
||||
== u64::from(map_height) * u64::from(frame_width)
|
||||
{
|
||||
(1, 1)
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
if columns == 0 || rows == 0 || map_width % columns != 0 || map_height % rows != 0 {
|
||||
return None;
|
||||
}
|
||||
let (w, h) = (map_width / columns, map_height / rows);
|
||||
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),
|
||||
))
|
||||
let output_side = side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES).min(side);
|
||||
let frames = (0..columns * rows)
|
||||
.map(|index| {
|
||||
let (x, y) = (index % columns * w, index / columns * h);
|
||||
let mut frame = Vec::with_capacity((side * side * 4) as usize);
|
||||
for row in 0..side {
|
||||
for column in 0..side {
|
||||
let offset =
|
||||
(((y + row * h / side) * map_width + x + column * w / side) * 4) as usize;
|
||||
frame.extend_from_slice(&pixels[offset..offset + 4]);
|
||||
}
|
||||
}
|
||||
resample(&frame, side, output_side)
|
||||
})
|
||||
.collect();
|
||||
Some((output_side, frames))
|
||||
}
|
||||
|
||||
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
|
||||
/// mipmap strategy and alpha cutoff bias).
|
||||
fn texture_section(
|
||||
@@ -790,6 +830,7 @@ pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> S
|
||||
layer,
|
||||
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
|
||||
frames: Arc::new(frames),
|
||||
materials: [None, None],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -815,8 +856,8 @@ mod tests {
|
||||
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.
|
||||
fn a_labpbr_map_selects_whole_texels_and_missing_layers_get_defaults() {
|
||||
// Selecting a texel preserves encoded channels, including categorical values.
|
||||
let frame = [
|
||||
[200, 100, 0, 255],
|
||||
[0, 0, 0, 0],
|
||||
@@ -824,14 +865,14 @@ mod tests {
|
||||
[0, 0, 0, 0],
|
||||
]
|
||||
.concat();
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
|
||||
assert_eq!(shrink_map(frame, 2, 1), (1, vec![200, 100, 0, 255]));
|
||||
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],
|
||||
[Some((16, vec![[9, 8, 7, 6].repeat(16 * 16)])), None],
|
||||
[None, None],
|
||||
],
|
||||
..Default::default()
|
||||
@@ -844,6 +885,50 @@ mod tests {
|
||||
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
|
||||
}
|
||||
#[test]
|
||||
fn companion_grid_and_static_map_follow_colour_frame_selection() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-companion-grid-{}", std::process::id()));
|
||||
fs::create_dir_all(&root).unwrap();
|
||||
let write = |name: &str, width, height, pixels: &[u8]| {
|
||||
let mut encoder =
|
||||
png::Encoder::new(File::create(root.join(name)).unwrap(), width, height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder
|
||||
.write_header()
|
||||
.unwrap()
|
||||
.write_image_data(pixels)
|
||||
.unwrap();
|
||||
};
|
||||
write("grid.png", 4, 4, &[200, 200, 200, 255].repeat(16));
|
||||
let maps: Vec<u8> = (0..16)
|
||||
.flat_map(|i| {
|
||||
let cell = (i / 8) * 2 + (i % 4) / 2;
|
||||
[10 + cell as u8, 230, 65, 255]
|
||||
})
|
||||
.collect();
|
||||
write("grid_s.png", 4, 4, &maps);
|
||||
write("grid_n.png", 2, 2, &[128, 128, 255, 255].repeat(4));
|
||||
let metadata =
|
||||
r#"{"width":2,"height":2,"frametime":2,"interpolate":true,"frames":[3,1,2,0]}"#;
|
||||
let animation = AnimationMetadata::from_json(metadata).unwrap();
|
||||
let (side, frames) = pbr_map(&root.join("grid.png"), "_s", 4, Some(&animation)).unwrap();
|
||||
assert_eq!(side, 2);
|
||||
assert_eq!(
|
||||
frames.iter().map(|f| f[0]).collect::<Vec<_>>(),
|
||||
[10, 11, 12, 13]
|
||||
);
|
||||
let (_, normals) = pbr_map(&root.join("grid.png"), "_n", 4, Some(&animation)).unwrap();
|
||||
assert_eq!(normals.len(), 1);
|
||||
let mut sprite = test_frames(0, vec![vec![0; 16]; 4], metadata);
|
||||
sprite.materials = [Some(Arc::new(normals)), Some(Arc::new(frames))];
|
||||
for (tick, expected) in [(0, 13), (1, 13), (2, 11), (4, 12), (6, 10)] {
|
||||
assert_eq!(sprite.material_pixels(1, tick).unwrap()[0], expected);
|
||||
assert_eq!(&sprite.material_pixels(0, tick).unwrap()[..4], &FLAT_NORMAL);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[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)],
|
||||
@@ -1284,6 +1369,7 @@ mod tests {
|
||||
fn animation_boundaries_and_repeated_frames_do_not_depend_on_render_rate() {
|
||||
let animation = SpriteAnimation {
|
||||
layer: 0,
|
||||
materials: [None, None],
|
||||
frames: Arc::new(vec![vec![], vec![], vec![]]),
|
||||
clip: Arc::new(AnimationMetadata::from_json(r#"{"frames":[{"index":2,"time":8},{"index":1,"time":3},{"index":1,"time":2},0]}"#).unwrap().clip(3).unwrap().unwrap()),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Original synthetic rendering controls for ordinary uniform initial values.
|
||||
use horizon_client_render::gfx::pack_uniforms::Value;
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["original-render.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((430, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn authored_defaults_render_and_runtime_values_override_including_zero() {
|
||||
let program = translate_composite(
|
||||
&source("void main() { gl_Position = ftransform(); }"),
|
||||
&source("uniform float gain = 0.375; uniform mat3 basis = mat3(2.0); uniform mat4 transform = mat4(1.0); uniform vec3 tint = vec3(0.125,0.25,0.5); uniform float points[2] = float[](0.0625,0.125); uniform int mode = 3; uniform bool enabled = true; out vec4 color; void main() { color = vec4(gain * basis[0][0] * transform[2][2], tint.g + points[1], float(mode), enabled ? 1.0 : 0.0); }"),
|
||||
Target::Buffers,
|
||||
).unwrap();
|
||||
let instance =
|
||||
wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle().with_env());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default()))
|
||||
.expect("GPU adapter required; missing adapter is not a pass");
|
||||
eprintln!("Uniform defaults GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let vertex = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.vertex)),
|
||||
});
|
||||
let fragment = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(program.fragment)),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vertex,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: wgpu::PrimitiveState {
|
||||
topology: wgpu::PrimitiveTopology::TriangleStrip,
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fragment,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let target = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba32Float,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
for (name, zero_buffer, overrides, expected) in [
|
||||
("authored defaults", false, vec![], [0.75, 0.375, 3.0, 1.0]),
|
||||
(
|
||||
"runtime precedence",
|
||||
false,
|
||||
vec![
|
||||
("gain", Value::F([0.125; 16])),
|
||||
("transform", Value::F([0.5; 16])),
|
||||
("mode", Value::I([7, 0, 0])),
|
||||
("enabled", Value::I([0; 3])),
|
||||
],
|
||||
[0.125, 0.375, 7.0, 0.0],
|
||||
),
|
||||
(
|
||||
"runtime explicit zero",
|
||||
false,
|
||||
vec![("gain", Value::F([0.; 16])), ("mode", Value::I([0; 3]))],
|
||||
[0.0, 0.375, 0.0, 1.0],
|
||||
),
|
||||
("zero upload negative control", true, vec![], [0.; 4]),
|
||||
] {
|
||||
let mut bytes = if zero_buffer {
|
||||
vec![0; program.interface.uniform_size as usize]
|
||||
} else {
|
||||
program.interface.initial_values.clone()
|
||||
};
|
||||
for (name, value) in overrides {
|
||||
let uniform = program
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == name)
|
||||
.unwrap();
|
||||
value.write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(name),
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: buffer.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let view = target.create_view(&Default::default());
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..4, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
target.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &readback,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: Some(1),
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
readback
|
||||
.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
{
|
||||
let mapped = readback.slice(..).get_mapped_range().unwrap();
|
||||
let actual: &[f32] = bytemuck::cast_slice(&mapped[..16]);
|
||||
eprintln!("{name}: {actual:?}");
|
||||
assert_eq!(actual, expected, "{name}");
|
||||
}
|
||||
readback.unmap();
|
||||
}
|
||||
}
|
||||
@@ -68,8 +68,9 @@ impl FeatureWorld for ChunkWorld<'_> {
|
||||
impl FlatTerrain {
|
||||
/// The overworld generator of `world_gen_settings` (`data.dimensions`), if it
|
||||
/// is flat. Its layers, as FlatLevelSource places them, and of the features
|
||||
/// its settings decorate the world with, `void_start_platform`; the other
|
||||
/// features and the lakes are not placed yet.
|
||||
/// its settings decorate the world with, `void_start_platform`. A world that
|
||||
/// places other features or lakes runs on the pipeline instead
|
||||
/// ([`Self::pipeline_settings`]).
|
||||
pub fn from_settings(
|
||||
settings: &Tag,
|
||||
registry: &BlockRegistry,
|
||||
@@ -150,12 +151,13 @@ impl FlatTerrain {
|
||||
void_platform,
|
||||
})
|
||||
}
|
||||
/// The settings of a superflat overworld that lets structure sets generate
|
||||
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
|
||||
/// (no key means every set; `[]` none). `None` for anything else, a world with no
|
||||
/// structure set to generate, the Void preset's biome (its platform is a feature
|
||||
/// this generator places) or one that is not superflat.
|
||||
pub fn structure_settings(
|
||||
/// The settings of a superflat overworld that runs on the structure and feature
|
||||
/// pipeline (`FlatLevelSource`): one that lets structure sets generate
|
||||
/// (`createState`: no `structure_overrides` key means every set, `[]` none) or
|
||||
/// that decorates (`features` or `lakes`, `adjustGenerationSettings`). `None` for a
|
||||
/// world with neither, the Void preset's biome (its platform is a feature this
|
||||
/// generator places) or one that is not superflat.
|
||||
pub fn pipeline_settings(
|
||||
settings: &Tag,
|
||||
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
|
||||
let generator = settings
|
||||
@@ -187,13 +189,17 @@ impl FlatTerrain {
|
||||
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
|
||||
_ => None,
|
||||
};
|
||||
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
|
||||
let flag = |key| crate::save::number(s.get(key)).is_some_and(|v| v != 0);
|
||||
let decorates = flag("features") || flag("lakes");
|
||||
if structure_overrides.as_ref().is_some_and(Vec::is_empty) && !decorates {
|
||||
return None;
|
||||
}
|
||||
Some(horizon_world::worldgen::generator::FlatSettings {
|
||||
layers,
|
||||
biome,
|
||||
structure_overrides,
|
||||
features: flag("features"),
|
||||
lakes: flag("lakes"),
|
||||
})
|
||||
}
|
||||
/// FlatLevelSource.getSpawnHeight: the top of the layers.
|
||||
@@ -556,10 +562,10 @@ mod tests {
|
||||
]))
|
||||
};
|
||||
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
|
||||
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
|
||||
let all = FlatTerrain::pipeline_settings(&flat(vec![], "minecraft:plains")).unwrap();
|
||||
assert_eq!(all.structure_overrides, None);
|
||||
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
|
||||
let some = FlatTerrain::structure_settings(&flat(
|
||||
let some = FlatTerrain::pipeline_settings(&flat(
|
||||
vec![(
|
||||
"structure_overrides",
|
||||
Tag::List(vec![Tag::String("minecraft:villages".into())]),
|
||||
@@ -573,15 +579,31 @@ mod tests {
|
||||
);
|
||||
assert_eq!(some.biome, "minecraft:desert");
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&flat(
|
||||
FlatTerrain::pipeline_settings(&flat(
|
||||
vec![("structure_overrides", Tag::List(vec![]))],
|
||||
"minecraft:plains"
|
||||
))
|
||||
.is_none()
|
||||
);
|
||||
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
|
||||
// No structure set, but features or lakes to place: the pipeline, with no set.
|
||||
for key in ["features", "lakes"] {
|
||||
let decorated = FlatTerrain::pipeline_settings(&flat(
|
||||
vec![
|
||||
("structure_overrides", Tag::List(vec![])),
|
||||
(key, Tag::Byte(1)),
|
||||
],
|
||||
"minecraft:plains",
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(decorated.structure_overrides, Some(vec![]), "{key}");
|
||||
assert_eq!(
|
||||
(decorated.features, decorated.lakes),
|
||||
(key == "features", key == "lakes")
|
||||
);
|
||||
}
|
||||
assert!(FlatTerrain::pipeline_settings(&flat(vec![], "minecraft:the_void")).is_none());
|
||||
assert!(
|
||||
FlatTerrain::structure_settings(&world(Tag::compound([(
|
||||
FlatTerrain::pipeline_settings(&world(Tag::compound([(
|
||||
"type",
|
||||
Tag::String("minecraft:noise".into())
|
||||
)])))
|
||||
|
||||
@@ -12870,11 +12870,14 @@ impl Game<'_> {
|
||||
});
|
||||
}
|
||||
} else if !first && !self.res.facts().is_air(state) && f.fluid == Fluid::None {
|
||||
return Some(Hit {
|
||||
pos: b,
|
||||
face,
|
||||
point: p,
|
||||
});
|
||||
// `ClipContext.Block.OUTLINE`: the ray stops at the block's
|
||||
// outline, not its cell; it passes through the empty part
|
||||
// of a torch, a fence or a flower to the water behind.
|
||||
if let Some(hit) =
|
||||
outline_stop(origin, dir, self.reach(), b, face, || self.outline_boxes(b))
|
||||
{
|
||||
return Some(hit);
|
||||
}
|
||||
}
|
||||
last = b;
|
||||
first = false;
|
||||
@@ -13993,9 +13996,12 @@ impl Game<'_> {
|
||||
horizon_client::mobs::render_model(arm, m, &pose, [r.u0, r.v0, r.u1, r.v1], light, 0, out);
|
||||
}
|
||||
/// FirstPersonHandsAndItemsRenderer: the right arm or the held items of
|
||||
/// both hands in view space, converted to camera-relative world space.
|
||||
fn hand_geometry(&mut self, alpha: f64) -> Vec<Vertex> {
|
||||
/// both hands in view space, converted to camera-relative world space,
|
||||
/// and where the arm and each item start, with the `currentRenderedItemId`
|
||||
/// a shader pack reads for them.
|
||||
fn hand_geometry(&mut self, alpha: f64) -> (Vec<Vertex>, Vec<(u32, i32)>) {
|
||||
let mut out = Vec::new();
|
||||
let mut runs = Vec::new();
|
||||
// GameRenderer.renderItemInHand: F1 hides the hand with the HUD.
|
||||
if self.spectator()
|
||||
|| self.ui.hide_gui
|
||||
@@ -14007,7 +14013,7 @@ impl Game<'_> {
|
||||
.is_none_or(|p| p.health.dead_or_dying())
|
||||
|| self.screen == Screen::Dead
|
||||
{
|
||||
return out;
|
||||
return (out, runs);
|
||||
}
|
||||
let a = alpha as f32;
|
||||
// FirstPersonHandsAndItemsRenderer: the swing goes to the hand that
|
||||
@@ -14099,20 +14105,24 @@ impl Game<'_> {
|
||||
// renderPlayerArm
|
||||
let m = to_world
|
||||
* horizon_client::first_person::player_arm_pose(left, inverse_height, attack);
|
||||
runs.push((out.len() as u32, 0));
|
||||
self.first_person_arm(left, m, packed, &mut out);
|
||||
continue;
|
||||
};
|
||||
let item_id = self.rendered_item_id(&stack.item);
|
||||
if let Some(id) = maps::map_id(&stack) {
|
||||
use horizon_client::first_person::{MapHold, map_arm_poses, one_handed_map_pose};
|
||||
let hold = MapHold::of(hand, off_hand_empty);
|
||||
// renderOneHandedMap and renderTwoHandedMap: the arms under
|
||||
// the map, unless the player is invisible.
|
||||
if !self.own_invisible() {
|
||||
runs.push((out.len() as u32, 0));
|
||||
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
|
||||
{
|
||||
self.first_person_arm(arm, to_world * pose, packed, &mut out);
|
||||
}
|
||||
}
|
||||
runs.push((out.len() as u32, item_id));
|
||||
match hold {
|
||||
MapHold::TwoHanded => self.two_handed_map_geometry(
|
||||
to_world,
|
||||
@@ -14223,6 +14233,7 @@ impl Game<'_> {
|
||||
horizon_client::cit::Hand::Main
|
||||
};
|
||||
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
|
||||
runs.push((out.len() as u32, item_id));
|
||||
let light = self.world.light(
|
||||
eye[0].floor() as i32,
|
||||
eye[1].floor() as i32,
|
||||
@@ -14241,7 +14252,7 @@ impl Game<'_> {
|
||||
for vertex in &mut out {
|
||||
vertex.layer &= !horizon_client::gfx::OIT_ENTITY;
|
||||
}
|
||||
out
|
||||
(out, runs)
|
||||
}
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
@@ -15965,8 +15976,8 @@ impl Game<'_> {
|
||||
} else {
|
||||
(Vec::new(), None)
|
||||
};
|
||||
let mut hand = if title || self.scoping() {
|
||||
Vec::new()
|
||||
let (mut hand, hand_items) = if title || self.scoping() {
|
||||
Default::default()
|
||||
} else {
|
||||
self.hand_geometry(camera_alpha)
|
||||
};
|
||||
@@ -16123,6 +16134,7 @@ impl Game<'_> {
|
||||
},
|
||||
stars: stars.filter(|_| self.settings.horizon.sky || title),
|
||||
hand: &hand,
|
||||
hand_items: &hand_items,
|
||||
hand_fov_degrees: self.hud_fov_degrees(camera_alpha as f32, camera.to_array()),
|
||||
screen: &screen,
|
||||
pack: pack_inputs,
|
||||
@@ -20084,7 +20096,7 @@ fn load_resources(
|
||||
// server thread's (horizon_threads::jobs), sized before any world opens.
|
||||
horizon_threads::jobs::configure(threads);
|
||||
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
|
||||
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_material_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`.
|
||||
@@ -20733,6 +20745,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -21691,6 +21704,23 @@ fn container_key_clones(button: MouseButton, action: KeyAction) -> bool {
|
||||
button == MouseButton::Middle && action != KeyAction::Release
|
||||
}
|
||||
|
||||
/// Where a ray entering `pos` first meets the block's outline boxes, if it
|
||||
/// does: a hollow block (torch, fence, flower) lets the ray through.
|
||||
fn outline_stop(
|
||||
origin: [f64; 3],
|
||||
dir: [f64; 3],
|
||||
reach: f64,
|
||||
pos: [i32; 3],
|
||||
face: [i32; 3],
|
||||
boxes: impl FnOnce() -> Vec<[f64; 6]>,
|
||||
) -> Option<Hit> {
|
||||
let (t, face) = interaction_shapes::entry(origin, dir, &boxes(), reach, face)?;
|
||||
Some(Hit {
|
||||
pos,
|
||||
face,
|
||||
point: std::array::from_fn(|i| origin[i] + dir[i] * t),
|
||||
})
|
||||
}
|
||||
/// `VoxelShape.clip` of a fluid source's shape for a ray that starts in the
|
||||
/// source's own block `cell`: the face and point it hits, in `pick_fluid`'s
|
||||
/// `Hit` terms. `FlowingFluid.getShape` is the whole block under the same
|
||||
@@ -21928,6 +21958,24 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::container_screen::{Button, Clicks, Release, Target};
|
||||
|
||||
/// `ClipContext.Block.OUTLINE` for the SOURCE_ONLY ray (#8180): a ray
|
||||
/// through the empty part of a torch's cell is not stopped by it.
|
||||
#[test]
|
||||
fn the_fluid_ray_passes_through_the_hollow_of_a_torch() {
|
||||
// A torch: a 2x10x2 pillar in the middle of its cell, at (0, 0, 0).
|
||||
let torch = || vec![[0.4375, 0.0, 0.4375, 0.5625, 0.625, 0.5625]];
|
||||
let dir = [0.0, 0.0, 1.0];
|
||||
// Beside the pillar: no hit, the ray goes on to the water behind.
|
||||
assert!(outline_stop([0.1, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Above the pillar (a torch is 10/16 tall): no hit either.
|
||||
assert!(outline_stop([0.5, 0.8, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch).is_none());
|
||||
// Through the pillar: stopped on its front face, not at the cell's edge.
|
||||
let hit = outline_stop([0.5, 0.3, -1.0], dir, 5.0, [0, 0, 0], [0, 0, -1], torch)
|
||||
.expect("the pillar stops the ray");
|
||||
assert_eq!(hit.face, [0, 0, -1]);
|
||||
assert!((hit.point[2] - 0.4375).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_death_drops_the_off_hand_before_the_armour() {
|
||||
let stack = |item: &str| Some(ItemStack::new(item, 1));
|
||||
|
||||
@@ -82,6 +82,9 @@ pub(super) struct Boat {
|
||||
/// left, right, up, down
|
||||
pub input: [bool; 4],
|
||||
pub on_ground: bool,
|
||||
/// `Entity.stuckSpeedMultiplier`: set where the boat sits in powder snow
|
||||
/// (`PowderSnowBlock.entityInside`), spent by the next move.
|
||||
stuck: Vec3,
|
||||
/// `ChestBoat`: a chest with 27 slots in the back seat.
|
||||
pub chest: bool,
|
||||
pub items: Vec<Option<ItemStack>>,
|
||||
@@ -105,6 +108,39 @@ const BUBBLE_TIME: i32 = 60;
|
||||
/// for a down and an up column.
|
||||
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
|
||||
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
|
||||
/// What `PowderSnowBlock.entityInside` passes to `makeStuckInBlock`.
|
||||
fn powder_snow_stuck() -> Vec3 {
|
||||
Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32))
|
||||
}
|
||||
/// The powder snow cells a box reaches into (deflated by 1e-5, as `Entity.checkInsideBlocks`
|
||||
/// does), in x, y, z order.
|
||||
fn powder_snow_cells(bounds: Aabb, is_snow: impl Fn([i32; 3]) -> bool) -> Vec<[i32; 3]> {
|
||||
let e = f64::from(1.0e-5_f32);
|
||||
let bounds = bounds.deflate(Vec3::new(e, e, e));
|
||||
let (lo, hi) = (bounds.min_position(), bounds.max_position());
|
||||
let mut cells = Vec::new();
|
||||
for x in lo.x.floor() as i32..=hi.x.floor() as i32 {
|
||||
for y in lo.y.floor() as i32..=hi.y.floor() as i32 {
|
||||
for z in lo.z.floor() as i32..=hi.z.floor() as i32 {
|
||||
if is_snow([x, y, z]) {
|
||||
cells.push([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
/// The snowflake `PowderSnowBlock.entityInside` sends for an entity whose known movement is
|
||||
/// `known`: none unless it moved horizontally and a coin falls so, then `Mth.randomBetween(-1, 1)`
|
||||
/// over 12 sideways and 0.05 up.
|
||||
fn snowflake_velocity(known: Vec3, random: &mut impl RandomSource) -> Option<[f64; 3]> {
|
||||
if (known.x == 0.0 && known.z == 0.0) || !random.next_boolean() {
|
||||
return None;
|
||||
}
|
||||
let xa = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32;
|
||||
Some([f64::from(xa), f64::from(0.05_f32), f64::from(za)])
|
||||
}
|
||||
pub(super) const WIDTH: f64 = 1.375;
|
||||
pub(super) const HEIGHT: f64 = 0.5625;
|
||||
impl Boat {
|
||||
@@ -152,6 +188,7 @@ impl Boat {
|
||||
paddling: [false; 2],
|
||||
input: [false; 4],
|
||||
on_ground: false,
|
||||
stuck: Vec3::ZERO,
|
||||
chest: false,
|
||||
items: Vec::new(),
|
||||
leash: None,
|
||||
@@ -168,12 +205,27 @@ impl Boat {
|
||||
self
|
||||
}
|
||||
pub fn bounds(&self) -> Aabb {
|
||||
let (w, p) = (WIDTH / 2.0, self.position);
|
||||
Self::bounds_at(self.position)
|
||||
}
|
||||
/// The box of a boat whose position is `p`.
|
||||
fn bounds_at(p: Vec3) -> Aabb {
|
||||
let w = WIDTH / 2.0;
|
||||
Aabb::new(
|
||||
Vec3::new(p.x - w, p.y, p.z - w),
|
||||
Vec3::new(p.x + w, p.y + HEIGHT, p.z + w),
|
||||
)
|
||||
}
|
||||
/// The start of `Entity.move`: a boat made stuck since its last move goes its
|
||||
/// velocity times the multiplier, and its velocity and the multiplier are spent.
|
||||
fn take_movement(&mut self) -> Vec3 {
|
||||
if self.stuck.length_squared() <= 1.0e-7 {
|
||||
return self.velocity;
|
||||
}
|
||||
let movement = self.velocity.multiply(self.stuck);
|
||||
self.stuck = Vec3::ZERO;
|
||||
self.velocity = Vec3::ZERO;
|
||||
movement
|
||||
}
|
||||
/// `isUnderWater`; it stands in for `canAddPassenger`'s
|
||||
/// `isEyeInFluid(WATER)` too.
|
||||
pub fn under_water(&self) -> bool {
|
||||
@@ -687,23 +739,29 @@ impl Game<'_> {
|
||||
entities: &obstacles,
|
||||
border: Some((self.border.geometry(), [b.position.x, b.position.z])),
|
||||
};
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), b.velocity, 0.0, b.on_ground) {
|
||||
let movement = b.take_movement();
|
||||
if let Ok(moved) = scene.resolve(b.bounds(), movement, 0.0, b.on_ground) {
|
||||
b.position = Vec3::new(
|
||||
b.position.x + moved.x,
|
||||
b.position.y + moved.y,
|
||||
b.position.z + moved.z,
|
||||
);
|
||||
b.on_ground = b.velocity.y < 0.0 && moved.y > b.velocity.y;
|
||||
if (moved.x - b.velocity.x).abs() > 1.0e-7 {
|
||||
b.on_ground = movement.y < 0.0 && moved.y > movement.y;
|
||||
if (moved.x - movement.x).abs() > 1.0e-7 {
|
||||
b.velocity.x = 0.0;
|
||||
}
|
||||
if (moved.z - b.velocity.z).abs() > 1.0e-7 {
|
||||
if (moved.z - movement.z).abs() > 1.0e-7 {
|
||||
b.velocity.z = 0.0;
|
||||
}
|
||||
if (moved.y - b.velocity.y).abs() > 1.0e-7 {
|
||||
if (moved.y - movement.y).abs() > 1.0e-7 {
|
||||
b.velocity.y = 0.0;
|
||||
}
|
||||
}
|
||||
// AbstractBoat.tick's applyEffectsFromBlocks: powder snow makes the boat stuck,
|
||||
// which slows its next move (`PowderSnowBlock.entityInside`).
|
||||
if !self.boat_powder_snow_cells(&b).is_empty() {
|
||||
b.stuck = powder_snow_stuck();
|
||||
}
|
||||
// Entity.move's last step: soul sand and honey slow the boat too.
|
||||
let factor = crate::motion::block_speed_factor_at(
|
||||
&self.world,
|
||||
@@ -825,6 +883,9 @@ impl Game<'_> {
|
||||
if Some(id) != self.riding && self.boat_lava_tick(i) {
|
||||
return;
|
||||
}
|
||||
if self.remote.is_none() {
|
||||
self.boat_powder_snow_snowflakes(i);
|
||||
}
|
||||
// `AbstractBoat.tick` counts submersion on the server even when
|
||||
// its controlling client owns the boat's movement.
|
||||
if driven.contains(&id) {
|
||||
@@ -1019,6 +1080,38 @@ impl Game<'_> {
|
||||
self.boats[i].tick_bubble_wobble();
|
||||
}
|
||||
}
|
||||
/// `PowderSnowBlock.entityInside` for a boat: the powder snow cells its box, and the box it
|
||||
/// had before its move, reach into (`Entity.checkInsideBlocks` along the move).
|
||||
fn boat_powder_snow_cells(&self, b: &Boat) -> Vec<[i32; 3]> {
|
||||
let mut cells = powder_snow_cells(b.bounds(), |p| self.is_powder_snow(p));
|
||||
if b.previous != b.position {
|
||||
for p in powder_snow_cells(Boat::bounds_at(b.previous), |p| self.is_powder_snow(p)) {
|
||||
if !cells.contains(&p) {
|
||||
cells.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles`: a boat that moved
|
||||
/// horizontally in powder snow throws snowflakes the players around see. `AbstractBoat.tick`
|
||||
/// calls `applyEffectsFromBlocks` twice.
|
||||
fn boat_powder_snow_snowflakes(&mut self, i: usize) {
|
||||
let b = &self.boats[i];
|
||||
let cells = self.boat_powder_snow_cells(b);
|
||||
if cells.is_empty() {
|
||||
return;
|
||||
}
|
||||
let known = b.position.subtract(b.previous);
|
||||
let (x, z) = (b.position.x, b.position.z);
|
||||
for _ in 0..2 {
|
||||
for cell in &cells {
|
||||
if let Some(velocity) = snowflake_velocity(known, &mut self.world.random) {
|
||||
self.lan_snowflake([x, f64::from(cell[1] + 1), z], velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
|
||||
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
|
||||
/// damage, so lava breaks a boat on its first tick inside, and the fire
|
||||
@@ -1642,5 +1735,82 @@ mod tests {
|
||||
assert_eq!(b.bubble_angle, 0.0);
|
||||
assert_eq!(b.bubble_angle(0.5), 0.0);
|
||||
}
|
||||
|
||||
/// #8156: the powder snow cells a boat's box reaches into, its box deflated by 1e-5.
|
||||
#[test]
|
||||
fn a_boat_reaches_the_powder_snow_cells_its_box_touches() {
|
||||
let b = Boat::new(1, "oak".into(), Vec3::new(10.5, 64.0, 10.5), 0.0);
|
||||
let snow = |cells: &'static [[i32; 3]]| move |p: [i32; 3]| cells.contains(&p);
|
||||
// Inside, a corner cell, and a cell the box only ends against.
|
||||
assert_eq!(
|
||||
powder_snow_cells(b.bounds(), snow(&[[10, 64, 10]])),
|
||||
vec![[10, 64, 10]]
|
||||
);
|
||||
assert_eq!(
|
||||
powder_snow_cells(b.bounds(), snow(&[[9, 64, 11], [12, 64, 10], [10, 65, 10]])),
|
||||
vec![[9, 64, 11]],
|
||||
"the cell beyond the edge and the one above the box are not reached"
|
||||
);
|
||||
let edge = Boat::new(1, "oak".into(), Vec3::new(11.3125, 64.0, 10.5), 0.0);
|
||||
assert!(powder_snow_cells(edge.bounds(), snow(&[[12, 64, 10]])).is_empty());
|
||||
// x, y, z order.
|
||||
let all = powder_snow_cells(b.bounds(), |_| true);
|
||||
assert_eq!(all.len(), 9);
|
||||
assert_eq!((all[0], all[8]), ([9, 64, 9], [11, 64, 11]));
|
||||
}
|
||||
|
||||
/// `Entity.move`: a boat made stuck goes its velocity times the multiplier once, and
|
||||
/// loses its velocity; one that is not goes as it did.
|
||||
#[test]
|
||||
fn a_stuck_boat_moves_a_part_of_its_velocity_once() {
|
||||
let mut b = Boat::new(1, "oak".into(), Vec3::ZERO, 0.0);
|
||||
b.velocity = Vec3::new(1.0, 0.5, 2.0);
|
||||
assert_eq!(b.take_movement(), Vec3::new(1.0, 0.5, 2.0));
|
||||
assert_eq!(b.velocity, Vec3::new(1.0, 0.5, 2.0));
|
||||
b.stuck = powder_snow_stuck();
|
||||
let moved = b.take_movement();
|
||||
let slow = f64::from(0.9_f32);
|
||||
assert_eq!(moved, Vec3::new(slow, 0.75, 2.0 * slow));
|
||||
assert_eq!((b.velocity, b.stuck), (Vec3::ZERO, Vec3::ZERO));
|
||||
b.velocity = Vec3::new(0.0, 0.1, 1.0);
|
||||
assert_eq!(b.take_movement(), Vec3::new(0.0, 0.1, 1.0), "spent");
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`'s snowflake: only for an entity that moved sideways, and
|
||||
/// then only when a coin falls so.
|
||||
#[test]
|
||||
fn a_boat_moving_sideways_in_powder_snow_throws_snowflakes_now_and_then() {
|
||||
use horizon_core::random::LegacyRandom;
|
||||
// Still or only rising: no coin is drawn.
|
||||
let mut random = LegacyRandom::new(7);
|
||||
let mut untouched = LegacyRandom::new(7);
|
||||
assert_eq!(
|
||||
snowflake_velocity(Vec3::new(0.0, 0.4, 0.0), &mut random),
|
||||
None
|
||||
);
|
||||
assert_eq!(random.next_int(), untouched.next_int());
|
||||
let known = Vec3::new(0.1, 0.0, 0.0);
|
||||
// One stream, as the server's: about half the calls draw a snowflake. (Seeds a step
|
||||
// apart would not do, their first coin is nearly always the same.)
|
||||
let mut stream = LegacyRandom::new(42);
|
||||
let flakes: Vec<[f64; 3]> = (0..200)
|
||||
.filter_map(|_| snowflake_velocity(known, &mut stream))
|
||||
.collect();
|
||||
assert!(
|
||||
(60..=140).contains(&flakes.len()),
|
||||
"about half the seeds: {}",
|
||||
flakes.len()
|
||||
);
|
||||
let limit = f64::from(0.083_333_336_f32);
|
||||
for [x, y, z] in flakes {
|
||||
assert!(x.abs() <= limit && z.abs() <= limit);
|
||||
assert_eq!(y, f64::from(0.05_f32));
|
||||
}
|
||||
// Either horizontal axis is enough.
|
||||
assert!(
|
||||
(0..64)
|
||||
.any(|_| { snowflake_velocity(Vec3::new(0.0, 0.0, -0.1), &mut stream).is_some() })
|
||||
);
|
||||
}
|
||||
}
|
||||
horizon_client::impl_joined!(Boat);
|
||||
@@ -810,7 +810,7 @@ impl Game<'_> {
|
||||
/// so a killed entity and a later summon of the same UUID can both be
|
||||
/// in it; the one the level still holds is the entity.
|
||||
pub(in crate::game) fn target_with_uuid(&self, uuid: [i32; 4]) -> Option<Target> {
|
||||
if Some(uuid) == self.level.player_uuid {
|
||||
if is_player_in_place(uuid, self.lan_acting_uuid, self.level.player_uuid) {
|
||||
return Some(Target::Player);
|
||||
}
|
||||
self.persist.uuids.iter().find_map(|(&id, found)| {
|
||||
@@ -1148,3 +1148,43 @@ impl Game<'_> {
|
||||
|| (entries.is_empty() && self.res.mob_data().biome_has(tag, biome))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `uuid` is the player in place (`Target::Player`): during a guest's turn that is the guest, not the
|
||||
/// level's own player (the world owner), whom `Level.getEntity(UUID)` finds only while they are in the level.
|
||||
fn is_player_in_place(
|
||||
uuid: [i32; 4],
|
||||
acting: Option<u128>,
|
||||
level_player: Option<[i32; 4]>,
|
||||
) -> bool {
|
||||
match acting {
|
||||
Some(guest) => horizon_client::lan::playerdata::ints_uuid(uuid) == guest,
|
||||
None => Some(uuid) == level_player,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::is_player_in_place;
|
||||
use horizon_client::lan::playerdata::ints_uuid;
|
||||
|
||||
const OWNER: [i32; 4] = [0, 0, 0, 12];
|
||||
const GUEST: [i32; 4] = [0, 0, 0, 13];
|
||||
|
||||
/// A owns the world, B is a guest: the sequence of one command each way.
|
||||
#[test]
|
||||
fn the_owners_uuid_is_not_the_guest_in_place() {
|
||||
// A's own command, or the single-player one: no guest in place, the level's player is.
|
||||
assert!(is_player_in_place(OWNER, None, Some(OWNER)));
|
||||
assert!(!is_player_in_place(GUEST, None, Some(OWNER)));
|
||||
// B's turn: only B is in place, the owner's UUID names someone else.
|
||||
let b = Some(ints_uuid(GUEST));
|
||||
assert!(is_player_in_place(GUEST, b, Some(OWNER)));
|
||||
assert!(!is_player_in_place(OWNER, b, Some(OWNER)));
|
||||
// A's own turn as a guest of an integrated server.
|
||||
let a = Some(ints_uuid(OWNER));
|
||||
assert!(is_player_in_place(OWNER, a, Some(OWNER)));
|
||||
assert!(!is_player_in_place(GUEST, a, Some(OWNER)));
|
||||
// No level player at all (a dedicated server): nobody is in place without a guest.
|
||||
assert!(!is_player_in_place(OWNER, None, None));
|
||||
}
|
||||
}
|
||||
@@ -17,13 +17,28 @@ use std::time::{Duration, Instant};
|
||||
/// An item as the renderer takes it.
|
||||
struct Baked {
|
||||
item: Arc<Item>,
|
||||
model: Arc<Part>,
|
||||
/// A model per bone it rides (a shirt's sleeves are on the arms).
|
||||
models: Vec<(horizon_cosmetics::Bone, Arc<Part>)>,
|
||||
/// Its texture's sprite (a 64×64 skin slot of the atlas).
|
||||
location: String,
|
||||
/// The frame it was last marked drawn in (its slot is kept).
|
||||
marked: Cell<u32>,
|
||||
}
|
||||
|
||||
impl Baked {
|
||||
fn new(item: &Arc<Item>, frame: u32) -> Self {
|
||||
Self {
|
||||
item: item.clone(),
|
||||
models: item
|
||||
.roots()
|
||||
.map(|(bone, root)| (bone, Arc::new(part(root))))
|
||||
.collect(),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Cosmetics {
|
||||
client: Option<horizon_cosmetics::Cosmetics>,
|
||||
@@ -127,6 +142,12 @@ fn covered(m: &Mob, slot: Slot) -> bool {
|
||||
Slot::Head => m.extra.head.is_some(),
|
||||
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
|
||||
Slot::Shoulder => false,
|
||||
// A chestplate, not an elytra.
|
||||
Slot::Torso => m
|
||||
.extra
|
||||
.chest
|
||||
.as_deref()
|
||||
.is_some_and(|c| c != "minecraft:elytra"),
|
||||
Slot::NameTag => true,
|
||||
}
|
||||
}
|
||||
@@ -198,12 +219,7 @@ impl Game<'_> {
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let entry = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked {
|
||||
item: item.clone(),
|
||||
model: Arc::new(part(&item.root)),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
});
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(slot) = self.gui.atlas.sprites.get(&entry.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
return None;
|
||||
@@ -274,8 +290,9 @@ impl Game<'_> {
|
||||
let ticks = m.age as f32 + alpha;
|
||||
let frame = self.cosmetics.frame.get();
|
||||
let mut bounds = None;
|
||||
// Items on one bone (a back item and a shoulder pet both ride the body) share its matrix.
|
||||
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 3] = [None; 3];
|
||||
// Items on one bone (a back item, a shoulder pet and a shirt ride the body) share its
|
||||
// matrix: the head, the body and the two arms at most.
|
||||
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 4] = [None; 4];
|
||||
// "Name tags only": other players' styles beside their names, nothing on their bodies.
|
||||
let bodies = own || self.settings.extras.cosmetics_others == Others::Shown;
|
||||
for item in worn.iter() {
|
||||
@@ -285,12 +302,7 @@ impl Game<'_> {
|
||||
let mut baked = self.cosmetics.baked.borrow_mut();
|
||||
let b = baked
|
||||
.entry(Arc::as_ptr(item) as usize)
|
||||
.or_insert_with(|| Baked {
|
||||
item: item.clone(),
|
||||
model: Arc::new(part(&item.root)),
|
||||
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
|
||||
marked: Cell::new(frame.wrapping_sub(1)),
|
||||
});
|
||||
.or_insert_with(|| Baked::new(item, frame));
|
||||
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
|
||||
self.cosmetics.uploads.borrow_mut().push(item.clone());
|
||||
continue;
|
||||
@@ -298,21 +310,6 @@ impl Game<'_> {
|
||||
if b.marked.replace(frame) != frame {
|
||||
self.skin_downloads.borrow_mut().drawn(&b.location);
|
||||
}
|
||||
let known = bones.iter().flatten().find(|(b, _)| *b == item.bone);
|
||||
let bone = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) =
|
||||
render::part_matrix(body, base, body_poses, item.bone.part(), 0.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((item.bone, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
// The texture fills the top-left of its 64×64 slot.
|
||||
let (w, h) = (
|
||||
item.texture.width as f32 / 64.0,
|
||||
@@ -324,34 +321,51 @@ impl Game<'_> {
|
||||
rect.u0 + (rect.u1 - rect.u0) * w,
|
||||
rect.v0 + (rect.v1 - rect.v0) * h,
|
||||
];
|
||||
poses.reset(&b.model);
|
||||
item.animate(ticks, |name, pose| *poses.get(name) = pose);
|
||||
if gpu {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
for (bone_of, model) in &b.models {
|
||||
let known = bones.iter().flatten().find(|(b, _)| b == bone_of);
|
||||
let bone = match known {
|
||||
Some(&(_, matrix)) => matrix,
|
||||
None => {
|
||||
let Some(matrix) =
|
||||
render::part_matrix(body, base, body_poses, bone_of.part(), 0.0)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
|
||||
*free = Some((*bone_of, matrix));
|
||||
}
|
||||
matrix
|
||||
}
|
||||
};
|
||||
if skin.draw(&b.model, bone, poses, paint) {
|
||||
bounds = skin.bounds;
|
||||
continue;
|
||||
poses.reset(model);
|
||||
item.animate(ticks, |name, pose| *poses.get(name) = pose);
|
||||
if gpu {
|
||||
let mut models = self.skin_models.borrow_mut();
|
||||
let mut frame = self.skin_frame.borrow_mut();
|
||||
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
|
||||
skin.bounds = bounds;
|
||||
let paint = horizon_client::mobs::skin::Paint {
|
||||
uv,
|
||||
scroll: 0.0,
|
||||
color: 0xFFFF_FFFF,
|
||||
light,
|
||||
overlay: 0,
|
||||
};
|
||||
if skin.draw(model, bone, poses, paint) {
|
||||
bounds = skin.bounds;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
horizon_client::mobs::render_model(
|
||||
model,
|
||||
bone,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
horizon_client::mobs::render_model(
|
||||
&b.model,
|
||||
bone,
|
||||
&|p: &Part| poses.pose(p),
|
||||
uv,
|
||||
light,
|
||||
0,
|
||||
out,
|
||||
);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
@@ -76,6 +76,48 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Each level's loaded chunks and entities (players and block entities apart), the level in place
|
||||
/// first: what the Prometheus text reports by dimension.
|
||||
fn status_levels(&self) -> Value {
|
||||
let mut levels = vec![json!({
|
||||
"dimension": self.dim.id(),
|
||||
"chunks": self.world.chunks.len(),
|
||||
"entities": self.entity_count(),
|
||||
})];
|
||||
for dim in self.levels.parked_dims() {
|
||||
if let (Some(world), Some(entities)) = (
|
||||
self.levels.parked_world(dim),
|
||||
self.levels.parked_entity_count(dim),
|
||||
) {
|
||||
levels.push(json!({
|
||||
"dimension": dim.id(),
|
||||
"chunks": world.chunks.len(),
|
||||
"entities": entities,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Value::Array(levels)
|
||||
}
|
||||
|
||||
/// The entities of the level in place: mobs, items, orbs, projectiles, vehicles, falling blocks,
|
||||
/// hangings, armor stands, cushions, tridents, rockets and primed TNT.
|
||||
fn entity_count(&self) -> usize {
|
||||
self.mobs.iter().filter(|m| !m.removed).count()
|
||||
+ self.dropped.len()
|
||||
+ self.orbs.len()
|
||||
+ self.creatures.projectiles.len()
|
||||
+ self.carts.len()
|
||||
+ self.boats.len()
|
||||
+ self.falling.len()
|
||||
+ self.paintings.len()
|
||||
+ self.item_frames.len()
|
||||
+ self.armor_stands.len()
|
||||
+ self.cushions.len()
|
||||
+ self.tridents.len()
|
||||
+ self.rockets.len()
|
||||
+ self.tnts.len()
|
||||
}
|
||||
|
||||
fn server_status(&self, state: &ServerState) -> Value {
|
||||
let millis = |nanos: u64| nanos as f64 / 1e6;
|
||||
let samples = self.clock.sorted_samples();
|
||||
@@ -113,6 +155,7 @@ impl Game<'_> {
|
||||
"players": players,
|
||||
"max_players": max_players,
|
||||
"chunks": self.world.chunks.len(),
|
||||
"levels": self.status_levels(),
|
||||
"paused": state.paused,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -482,13 +482,16 @@ impl Game<'_> {
|
||||
/// ItemFrameRenderer: the frame model, then the item half size on its face.
|
||||
pub(super) fn frame_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
|
||||
let sprites = &self.gui.atlas.sprites;
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
|
||||
// Each with its frame's type: a pack tells a glow frame and what it holds apart by its
|
||||
// `entityId` (`glow_item_frame` in entity.properties), the item drawn within its frame.
|
||||
let mut maps: Vec<(i32, glam::Mat4, u32, &'static str)> = Vec::new();
|
||||
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8, &'static str)> = Vec::new();
|
||||
// ItemFrame.shouldRenderAtSqrDistance: 1024 blocks at a view scale
|
||||
// of 1.
|
||||
let range = 16.0 * 64.0 * self.view_scale();
|
||||
for f in &self.item_frames {
|
||||
let entity_start = out.len();
|
||||
self.mark_entity(entity_start, f.kind());
|
||||
let map = f.item.as_ref().and_then(super::maps::map_id);
|
||||
// EntityRenderer.shouldRender: its box (a block wide with a map),
|
||||
// measured from the box's centre.
|
||||
@@ -614,7 +617,7 @@ impl Game<'_> {
|
||||
* glam::DMat4::from_rotation_z(std::f64::consts::PI)
|
||||
* glam::DMat4::from_scale(glam::DVec3::splat(0.0078125))
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(-64.0, -64.0, -1.0));
|
||||
maps.push((id, m.as_mat4(), item_light));
|
||||
maps.push((id, m.as_mat4(), item_light, f.kind()));
|
||||
continue;
|
||||
}
|
||||
let Some(stack) = f.item.clone() else {
|
||||
@@ -627,14 +630,15 @@ impl Game<'_> {
|
||||
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
|
||||
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
|
||||
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
|
||||
items.push((stack, f.id, m.as_mat4(), item_light));
|
||||
items.push((stack, f.id, m.as_mat4(), item_light, f.kind()));
|
||||
}
|
||||
// `extractRenderState`: FIXED, seeded by the frame entity.
|
||||
let skinning = self.skinning();
|
||||
let lights =
|
||||
horizon_client::block_entity_render::diffuse_lights(self.dim == super::Dim::Nether);
|
||||
for (stack, id, m, light) in items {
|
||||
for (stack, id, m, light, kind) in items {
|
||||
let entity_start = out.len();
|
||||
self.mark_entity(entity_start, kind);
|
||||
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
|
||||
let state = self.resolve_item(
|
||||
&stack,
|
||||
@@ -652,8 +656,9 @@ impl Game<'_> {
|
||||
}
|
||||
self.cull_skinned(out, entity_start, skinned);
|
||||
}
|
||||
for (id, m, light) in maps {
|
||||
for (id, m, light, kind) in maps {
|
||||
let entity_start = out.len();
|
||||
self.mark_entity(entity_start, kind);
|
||||
self.map_vertices(id, true, m, light, out);
|
||||
self.cull_entity_vertices(out, entity_start);
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ pub(super) fn reload_sync_data(
|
||||
// a `/reload` that dropped them must read its own registries again.
|
||||
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
|
||||
let sync = SyncData::load(root, overlay).map(|mut sync| {
|
||||
sync.keep_registries(root, kept);
|
||||
sync.keep_registries(root, kept, overlay);
|
||||
sync.features.clone_from(&kept.features);
|
||||
Arc::new(sync)
|
||||
});
|
||||
|
||||
@@ -135,6 +135,15 @@ pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], veloc
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A snowflake from a vehicle (`PowderSnowBlock.entityInside`), to every player around.
|
||||
pub(super) fn lan_snowflake(&self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
send_snowflake_particle(host, at, velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles` of one
|
||||
/// snowflake (`count` 1, no spread, `RandomizationType.ALTERNATIVE`).
|
||||
pub(super) fn send_snowflake_particle(
|
||||
|
||||
@@ -149,6 +149,27 @@ impl Levels {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The entities of a parked level, counted as `Game::entity_count` counts the one in place.
|
||||
pub(super) fn parked_entity_count(&self, dim: Dim) -> Option<usize> {
|
||||
let l = self.parked.get(&dim)?;
|
||||
Some(
|
||||
l.mobs.iter().filter(|m| !m.removed).count()
|
||||
+ l.dropped.len()
|
||||
+ l.orbs.len()
|
||||
+ l.projectiles.len()
|
||||
+ l.carts.len()
|
||||
+ l.boats.len()
|
||||
+ l.falling.len()
|
||||
+ l.paintings.len()
|
||||
+ l.item_frames.len()
|
||||
+ l.armor_stands.len()
|
||||
+ l.cushions.len()
|
||||
+ l.tridents.len()
|
||||
+ l.rockets.len()
|
||||
+ l.tnts.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The mobs of a parked level.
|
||||
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
|
||||
self.parked.get(&dim).map(|level| level.mobs.as_slice())
|
||||
|
||||
@@ -6038,15 +6038,19 @@ impl Game<'_> {
|
||||
// at 10, the hurt tilt restarted every frame and shook the camera.
|
||||
player.health.advance_clock();
|
||||
if riding && !dead {
|
||||
MotionWorld {
|
||||
player.client_side = true;
|
||||
let mut passenger = MotionWorld {
|
||||
entities: &entity_collisions,
|
||||
player: &mut *player,
|
||||
scene: &mut self.world,
|
||||
assets: &self.pack.assets,
|
||||
collision_binding: self.collision,
|
||||
resources: self.motion,
|
||||
}
|
||||
.passenger_pose_tick()?;
|
||||
};
|
||||
passenger.passenger_pose_tick()?;
|
||||
// LivingEntity.aiStep: a passenger freezes in powder snow too, and the
|
||||
// frostbite overlay shows it (#8156).
|
||||
passenger.passenger_block_effects_tick()?;
|
||||
}
|
||||
}
|
||||
// The server decides fire the window cannot predict (blaze fireballs,
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(super) fn read_shader_options(
|
||||
let chosen = chosen_shader_options(name);
|
||||
let pack = pack.with_chosen(chosen.clone());
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, &horizon_shaders::with_options(&pack, macros));
|
||||
let properties = Properties::parse(&text, &horizon_shaders::properties_macros(&pack, macros));
|
||||
let screens = horizon_shaders::Screens::parse(&properties, &options);
|
||||
Some(ShaderOptions::new(
|
||||
name.to_owned(),
|
||||
@@ -108,16 +108,14 @@ 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 {
|
||||
/// A selected pack may read resource-pack material maps, whether native or GLSL.
|
||||
pub(super) fn picks_material_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
|
||||
@@ -134,6 +132,11 @@ pub(super) struct PackIds {
|
||||
/// −1, or 0 without the file (5.3).
|
||||
items: HashMap<String, i32>,
|
||||
missing_item: i32,
|
||||
/// `currentRenderedItemId` of each block item (`StateFacts::item` of its block's default
|
||||
/// state): that state's `block.properties` id, or 0.
|
||||
block_items: HashMap<String, i32>,
|
||||
/// Whether the pack has `item.properties` (`currentRenderedItemId` of an item it lacks).
|
||||
item_file: bool,
|
||||
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
|
||||
old_hand_light: bool,
|
||||
/// `vignette`: `false` hides the game's vignette over the pack's image.
|
||||
@@ -178,6 +181,11 @@ impl Game<'_> {
|
||||
if picked.is_empty() {
|
||||
return PackInputs::default();
|
||||
}
|
||||
if !horizon_client::sprites::labpbr_maps() {
|
||||
horizon_client::sprites::set_labpbr_maps(true);
|
||||
self.reload_packs();
|
||||
return PackInputs::default();
|
||||
}
|
||||
let name = picked;
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
@@ -248,7 +256,11 @@ impl Game<'_> {
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"item",
|
||||
);
|
||||
let block_items =
|
||||
block_item_ids(self.res.facts(), self.pack.assets.registry, &ids);
|
||||
self.pack_ids = Some(PackIds {
|
||||
block_items,
|
||||
item_file: text.is_some(),
|
||||
items: items.plain(),
|
||||
missing_item: if text.is_some() { -1 } else { 0 },
|
||||
// OQ-21: on unless the pack turns it off.
|
||||
@@ -457,6 +469,17 @@ impl Game<'_> {
|
||||
self.settings.vignette && self.pack_ids.as_ref().is_none_or(|ids| ids.vignette)
|
||||
}
|
||||
|
||||
/// `currentRenderedItemId` of a held `item`, [`rendered_item_id`]'s; 0 without a pack.
|
||||
pub(super) fn rendered_item_id(&self, item: &str) -> i32 {
|
||||
self.pack_ids.as_ref().map_or(0, |ids| {
|
||||
rendered_item_id(
|
||||
ids.block_items.get(item).copied(),
|
||||
ids.items.get(item).copied(),
|
||||
ids.item_file,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
|
||||
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
|
||||
if self.pack_ids.is_none() {
|
||||
@@ -606,6 +629,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `currentRenderedItemId` of an item (Iris 1.11.7, observed with probe packs): a block item
|
||||
/// (`block`: its block's `block.properties` id, 0 when the file lacks it) reads that, whatever
|
||||
/// `item.properties` says of it; any other item its `item.properties` id (`item`), and one the
|
||||
/// file lacks 65535, or 0 when the pack has no `item.properties` (`item_file`).
|
||||
fn rendered_item_id(block: Option<i32>, item: Option<i32>, item_file: bool) -> i32 {
|
||||
block.or(item).unwrap_or(if item_file { 65535 } else { 0 })
|
||||
}
|
||||
|
||||
/// The block items' `currentRenderedItemId`: for each block's default state, the item it gives
|
||||
/// (`StateFacts::item`) takes the state's id in `ids`, or 0. A block named as its item wins over
|
||||
/// another block giving that item.
|
||||
fn block_item_ids(
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
ids: &BlockIds,
|
||||
) -> HashMap<String, i32> {
|
||||
let mut out = HashMap::new();
|
||||
for (i, state) in facts.states.iter().enumerate() {
|
||||
let id = horizon_world::registry::BlockStateId(i as u32);
|
||||
if &*state.item == "minecraft:air" || registry.default_state(&state.name) != Some(id) {
|
||||
continue;
|
||||
}
|
||||
let value = registry
|
||||
.state(id)
|
||||
.and_then(|s| ids.id(&s.name, |k| s.properties.get(k).map(String::as_str)))
|
||||
.unwrap_or(0);
|
||||
if state.item == state.name {
|
||||
out.insert(state.item.to_string(), value);
|
||||
} else {
|
||||
out.entry(state.item.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Spans for a native mod (`native_render::RenderNeeds::entity_spans`), without a pack.
|
||||
fn native_spans() -> bool {
|
||||
horizon_client::gfx::native_render::render_needs().entity_spans
|
||||
@@ -826,6 +884,27 @@ mod emission_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rendered_item_tests {
|
||||
use super::rendered_item_id;
|
||||
|
||||
/// Iris 1.11.7's values (probe packs of ours): a block item reads its block's id, and 0 when
|
||||
/// `block.properties` lacks it even if `item.properties` names it (oak planks as item 15);
|
||||
/// other items read their `item.properties` id, or 65535 when the file lacks them, and 0
|
||||
/// when the pack has no such file.
|
||||
#[test]
|
||||
fn a_held_item_reads_its_block_id_or_item_id() {
|
||||
// Stone: block 21 and item 12.
|
||||
assert_eq!(rendered_item_id(Some(21), Some(12), true), 21);
|
||||
assert_eq!(rendered_item_id(Some(0), Some(15), true), 0);
|
||||
assert_eq!(rendered_item_id(Some(0), None, true), 0);
|
||||
assert_eq!(rendered_item_id(Some(0), None, false), 0);
|
||||
assert_eq!(rendered_item_id(None, Some(11), true), 11);
|
||||
assert_eq!(rendered_item_id(None, None, true), 65535);
|
||||
assert_eq!(rendered_item_id(None, None, false), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod native_pack_tests {
|
||||
use super::{active_pack, pack_draws_flat};
|
||||
|
||||
@@ -712,13 +712,18 @@ pub(super) fn make_streaming(
|
||||
// even when automatic structure starts are disabled.
|
||||
let automatic_structures =
|
||||
horizon_client::save::WorldGen::read(world_root).is_none_or(|w| w.structures);
|
||||
let flat_structures = (dim == Dim::Overworld)
|
||||
let flat_settings = (dim == Dim::Overworld)
|
||||
.then(|| horizon_client::save::world_gen_settings(world_root))
|
||||
.flatten()
|
||||
.and_then(|s| horizon_client::flatgen::FlatTerrain::structure_settings(&s));
|
||||
.and_then(|s| horizon_client::flatgen::FlatTerrain::pipeline_settings(&s));
|
||||
// `adjustGenerationSettings`: a superflat world with features or lakes is decorated
|
||||
// whether or not its structures generate.
|
||||
let flat_decorates = flat_settings
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.features || s.lakes);
|
||||
// A generator that cannot be built (a layer block the registry lacks, a level shorter
|
||||
// than the noise) leaves the world on its flat ground, as it was before.
|
||||
let flat_vanilla = flat_structures.and_then(|settings| {
|
||||
let flat_vanilla = flat_settings.and_then(|settings| {
|
||||
let overrides =
|
||||
world_worldgen_overrides(world_root, &options.resources, &biomes, enabled_features);
|
||||
match VanillaTerrain::for_flat(
|
||||
@@ -735,7 +740,7 @@ pub(super) fn make_streaming(
|
||||
Some(Arc::new(terrain))
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: the superflat structures are unavailable: {e}");
|
||||
log::warn!("Horizon: the superflat structures and features are unavailable: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -753,10 +758,12 @@ pub(super) fn make_streaming(
|
||||
)
|
||||
} else if let Some(inner) = flat_vanilla
|
||||
.as_ref()
|
||||
.filter(|_| automatic_structures)
|
||||
.filter(|_| automatic_structures || flat_decorates)
|
||||
.cloned()
|
||||
{
|
||||
inner.search_rings_behind();
|
||||
if automatic_structures {
|
||||
inner.search_rings_behind();
|
||||
}
|
||||
inner.set_reference_file(world_root.join("data").join(format!(
|
||||
"horizon_structure_references_{}.json",
|
||||
dim.id().replace(':', "_")
|
||||
@@ -779,8 +786,8 @@ pub(super) fn make_streaming(
|
||||
res.threads(),
|
||||
)
|
||||
} else if let Some(inner) = flat {
|
||||
// Keep the existing flat terrain/decorations path when automatic
|
||||
// structures are off; only manual commands use the locator.
|
||||
// A superflat world that neither decorates nor generates structures: its
|
||||
// layers alone. Only manual commands use the locator.
|
||||
locator = flat_vanilla;
|
||||
Streaming::new(
|
||||
Arc::new(Persistent {
|
||||
|
||||
@@ -293,12 +293,13 @@ impl SyncData {
|
||||
registries.push(((*key).to_owned(), e));
|
||||
}
|
||||
// The world's packs' dialogs joined the registry above
|
||||
// (`pack_entries`); their `minecraft:dialog` tags join the root's here,
|
||||
// for the Quick Actions key and `show_dialog`.
|
||||
// (`pack_entries`); their tags join the root's here, the dialogs'
|
||||
// (the Quick Actions key and `show_dialog`) and every other
|
||||
// registry's a pack has tag files for (a block made climbable).
|
||||
drop(registries_step);
|
||||
let tags_step = startup::step("sync.tags");
|
||||
let mut tags = Self::load_tags(&root, &builtin, &dynamic);
|
||||
Self::merge_dialog_tags(&mut tags, &root, overlay, &builtin, &dynamic);
|
||||
Self::merge_pack_tags(&mut tags, &root, overlay, &builtin, &dynamic);
|
||||
drop(tags_step);
|
||||
let content_identity = if resources.join(crate::content_identity::MARKER).exists()
|
||||
|| horizon_protocol::item::custom::entries().next().is_some()
|
||||
@@ -343,15 +344,19 @@ impl SyncData {
|
||||
/// MinecraftServer.reloadResources: `/reload` keeps the synchronized
|
||||
/// registries the server started with (`kept`'s, its
|
||||
/// LayeredRegistryAccess) and reads the tags against them
|
||||
/// (TagLoader.loadTagsForExistingRegistries); the rest stays reloaded.
|
||||
pub fn keep_registries(&mut self, resources: &Path, kept: &SyncData) {
|
||||
/// (TagLoader.loadTagsForExistingRegistries), the world's packs' as well
|
||||
/// (`overlay`); the rest stays reloaded.
|
||||
pub fn keep_registries(
|
||||
&mut self,
|
||||
resources: &Path,
|
||||
kept: &SyncData,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
) {
|
||||
self.dynamic = kept.dynamic.clone();
|
||||
self.registries = kept.registries.clone();
|
||||
self.tags = Self::load_tags(
|
||||
&resources.join("data/minecraft"),
|
||||
&self.builtin,
|
||||
&self.dynamic,
|
||||
);
|
||||
let root = resources.join("data/minecraft");
|
||||
self.tags = Self::load_tags(&root, &self.builtin, &self.dynamic);
|
||||
Self::merge_pack_tags(&mut self.tags, &root, overlay, &self.builtin, &self.dynamic);
|
||||
}
|
||||
|
||||
/// Whether a synchronized registry holds an entry from a world's pack.
|
||||
@@ -414,27 +419,63 @@ impl SyncData {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The `minecraft:dialog` tags of the pack stack: the resource root's
|
||||
/// files, then each world pack's in order (`TagLoader`: a file adds its
|
||||
/// values, or replaces the earlier ones with `"replace": true`), nested
|
||||
/// tags expanded to the registry's ids.
|
||||
fn merge_dialog_tags(
|
||||
/// The tags the server sends (`TagNetworkSerialization.serializeTagsToNetwork`
|
||||
/// reads the registries' tags as `TagLoader` loaded them from the whole
|
||||
/// pack stack): every synchronized registry the world's packs have tag
|
||||
/// files for is merged again over the resource root's, and
|
||||
/// `minecraft:dialog` always (its tags name the packs' dialogs too).
|
||||
/// Without packs, the root's tags stand.
|
||||
fn merge_pack_tags(
|
||||
tags: &mut UpdateTags,
|
||||
root: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
builtin: &Builtin,
|
||||
dynamic: &Dynamic,
|
||||
) {
|
||||
let Some(names) = Self::registry_names(builtin, dynamic, "minecraft:dialog") else {
|
||||
let mut keys: Vec<&String> = builtin
|
||||
.registries
|
||||
.keys()
|
||||
.chain(dynamic.registries.keys())
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
for key in keys {
|
||||
// Registries.tagsDirPath: `tags/<path>`, a mod's registry under
|
||||
// `tags/<namespace>/<path>`.
|
||||
let kind = match key.split_once(':') {
|
||||
Some(("minecraft", path)) => path.to_owned(),
|
||||
Some((ns, path)) => format!("{ns}/{path}"),
|
||||
None => key.clone(),
|
||||
};
|
||||
if key != "minecraft:dialog" && overlay.tag_kind(&kind).next().is_none() {
|
||||
continue;
|
||||
}
|
||||
Self::merge_registry_tags(tags, root, overlay, builtin, dynamic, key, &kind);
|
||||
}
|
||||
}
|
||||
/// One registry's tags of the pack stack: the resource root's files, then
|
||||
/// each world pack's in order (`TagLoader`: a file adds its values, or
|
||||
/// replaces the earlier ones with `"replace": true`), nested tags expanded
|
||||
/// to the registry's ids.
|
||||
fn merge_registry_tags(
|
||||
tags: &mut UpdateTags,
|
||||
root: &Path,
|
||||
overlay: &horizon_world::loot::PackOverlay,
|
||||
builtin: &Builtin,
|
||||
dynamic: &Dynamic,
|
||||
key: &str,
|
||||
kind: &str,
|
||||
) {
|
||||
let Some(names) = Self::registry_names(builtin, dynamic, key) else {
|
||||
return;
|
||||
};
|
||||
let mut files: BTreeMap<String, Vec<Value>> = BTreeMap::new();
|
||||
let mut own = BTreeMap::new();
|
||||
walk(&root.join("tags/dialog"), &mut own);
|
||||
walk(&root.join("tags").join(kind), &mut own);
|
||||
for (name, value) in own {
|
||||
Self::add_tag_values(files.entry(qualified(&name)).or_default(), &value);
|
||||
}
|
||||
for (id, packs) in overlay.tag_kind("dialog") {
|
||||
for (id, packs) in overlay.tag_kind(kind) {
|
||||
for bytes in packs {
|
||||
if let Ok(value) = serde_json::from_slice::<Value>(bytes) {
|
||||
Self::add_tag_values(files.entry(id.clone()).or_default(), &value);
|
||||
@@ -445,16 +486,16 @@ impl SyncData {
|
||||
for name in files.keys() {
|
||||
let mut ids = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
Self::expand_dialog_tag(&files, name, &names, &mut ids, &mut seen);
|
||||
Self::expand_pack_tag(&files, name, &names, &mut ids, &mut seen);
|
||||
entries.push(TagEntry {
|
||||
name: name.clone(),
|
||||
ids,
|
||||
});
|
||||
}
|
||||
tags.registries.retain(|r| r.registry != "minecraft:dialog");
|
||||
tags.registries.retain(|r| r.registry != key);
|
||||
if !entries.is_empty() {
|
||||
tags.registries.push(RegistryTags {
|
||||
registry: "minecraft:dialog".into(),
|
||||
registry: key.into(),
|
||||
tags: entries,
|
||||
});
|
||||
}
|
||||
@@ -470,8 +511,8 @@ impl SyncData {
|
||||
}
|
||||
}
|
||||
|
||||
/// A dialog tag's entries as registry ids, nested tags expanded once.
|
||||
fn expand_dialog_tag(
|
||||
/// A tag's entries as registry ids, nested tags expanded once.
|
||||
fn expand_pack_tag(
|
||||
files: &BTreeMap<String, Vec<Value>>,
|
||||
tag: &str,
|
||||
names: &HashMap<&str, i32>,
|
||||
@@ -490,7 +531,7 @@ impl SyncData {
|
||||
.or_else(|| value["id"].as_str())
|
||||
.unwrap_or_default();
|
||||
if let Some(reference) = id.strip_prefix('#') {
|
||||
Self::expand_dialog_tag(files, &qualified(reference), names, out, seen);
|
||||
Self::expand_pack_tag(files, &qualified(reference), names, out, seen);
|
||||
} else if let Some(&n) = names.get(qualified(id).as_str())
|
||||
&& !out.contains(&n)
|
||||
{
|
||||
@@ -675,7 +716,7 @@ mod tests {
|
||||
let mut tags = UpdateTags {
|
||||
registries: Vec::new(),
|
||||
};
|
||||
SyncData::merge_dialog_tags(
|
||||
SyncData::merge_pack_tags(
|
||||
&mut tags,
|
||||
Path::new("a-resource-root-without-tags"),
|
||||
&overlay,
|
||||
@@ -699,6 +740,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #7843: the server sends its tags as `TagLoader` merged them from the
|
||||
/// whole pack stack. A world pack that adds dirt to `climbable` and
|
||||
/// replaces `mineable/pickaxe` reaches the block tags sent, over the
|
||||
/// resource root's; a nested tag expands; a registry no pack has tag files
|
||||
/// for keeps what `load_tags` read.
|
||||
#[test]
|
||||
fn world_packs_tags_reach_every_synchronized_registry() {
|
||||
let root = std::env::temp_dir().join(format!("horizon-pack-tags-{}", std::process::id()));
|
||||
let block_tags = root.join("tags/block");
|
||||
std::fs::create_dir_all(block_tags.join("mineable")).unwrap();
|
||||
std::fs::write(
|
||||
block_tags.join("climbable.json"),
|
||||
r#"{"values":["minecraft:ladder"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
block_tags.join("mineable/pickaxe.json"),
|
||||
r#"{"values":["minecraft:stone"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let names = |list: &[&str]| {
|
||||
horizon_protocol::registry::Ids::from_names(
|
||||
list.iter().map(|s| (*s).to_owned()).collect(),
|
||||
)
|
||||
};
|
||||
let builtin = Builtin {
|
||||
registries: HashMap::from([
|
||||
(
|
||||
"minecraft:block".to_owned(),
|
||||
names(&["minecraft:stone", "minecraft:dirt", "minecraft:ladder"]),
|
||||
),
|
||||
("minecraft:item".to_owned(), names(&["minecraft:stick"])),
|
||||
]),
|
||||
};
|
||||
let dynamic = Dynamic::default();
|
||||
let mut tags = SyncData::load_tags(&root, &builtin, &dynamic);
|
||||
let item_tags = RegistryTags {
|
||||
registry: "minecraft:item".into(),
|
||||
tags: vec![TagEntry {
|
||||
name: "minecraft:sticks".into(),
|
||||
ids: vec![0],
|
||||
}],
|
||||
};
|
||||
tags.registries.push(item_tags.clone());
|
||||
let files = vec![
|
||||
(
|
||||
"tags/minecraft:block/climbable".to_owned(),
|
||||
vec![br##"{"values":["minecraft:dirt","#test:more"]}"##.to_vec()],
|
||||
),
|
||||
(
|
||||
"tags/test:block/more".to_owned(),
|
||||
vec![br#"{"values":["minecraft:stone"]}"#.to_vec()],
|
||||
),
|
||||
(
|
||||
"tags/minecraft:block/mineable/pickaxe".to_owned(),
|
||||
vec![br#"{"replace":true,"values":["minecraft:dirt"]}"#.to_vec()],
|
||||
),
|
||||
];
|
||||
let overlay = horizon_world::loot::PackOverlay { files: &files };
|
||||
SyncData::merge_pack_tags(&mut tags, &root, &overlay, &builtin, &dynamic);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let sent = |registry: &str, tag: &str| -> Vec<i32> {
|
||||
tags.registries
|
||||
.iter()
|
||||
.find(|r| r.registry == registry)
|
||||
.and_then(|r| r.tags.iter().find(|t| t.name == tag))
|
||||
.map(|t| t.ids.clone())
|
||||
.unwrap_or_else(|| panic!("{registry} {tag} is sent"))
|
||||
};
|
||||
// The root's ladder, then the pack's dirt and its nested tag's stone.
|
||||
assert_eq!(sent("minecraft:block", "minecraft:climbable"), [2, 1, 0]);
|
||||
// `"replace": true` drops the root's stone.
|
||||
assert_eq!(sent("minecraft:block", "minecraft:mineable/pickaxe"), [1]);
|
||||
assert_eq!(sent("minecraft:block", "test:more"), [0]);
|
||||
// No pack tags for items: the registry stays as it was read.
|
||||
let items = tags
|
||||
.registries
|
||||
.iter()
|
||||
.find(|r| r.registry == "minecraft:item")
|
||||
.unwrap();
|
||||
assert_eq!(items.tags.len(), 1);
|
||||
assert_eq!(items.tags[0].ids, item_tags.tags[0].ids);
|
||||
assert_eq!(
|
||||
tags.registries
|
||||
.iter()
|
||||
.filter(|r| r.registry == "minecraft:block")
|
||||
.count(),
|
||||
1,
|
||||
"one entry per registry"
|
||||
);
|
||||
}
|
||||
|
||||
/// RegistrySynchronization.packRegistry: a client that knows the core
|
||||
/// pack gets the vanilla dialogs without data; a pack's dialog has it.
|
||||
#[test]
|
||||
|
||||
@@ -1187,6 +1187,8 @@ fn a_superflat_world_runs_its_structure_sets_on_flat_ground() {
|
||||
"minecraft:strongholds".into(),
|
||||
"minecraft:villages".into(),
|
||||
]),
|
||||
features: false,
|
||||
lakes: false,
|
||||
};
|
||||
let terrain = crate::vanillagen::VanillaTerrain::for_flat(
|
||||
&base.join("worldgen"),
|
||||
@@ -1232,6 +1234,70 @@ fn a_superflat_world_runs_its_structure_sets_on_flat_ground() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `adjustGenerationSettings`: the Overworld preset decorates its plains with grass,
|
||||
/// flowers and trees even with no structure set (`structure_overrides: []`, or
|
||||
/// `generate_structures` off), and without `features` the same layers stay bare.
|
||||
#[test]
|
||||
#[ignore = "full content root: CI and pr_ready run it"]
|
||||
fn a_decorated_superflat_world_without_structures_grows_its_features() {
|
||||
use crate::streaming::ChunkGenerator;
|
||||
let Some(base) = prepared() else {
|
||||
return;
|
||||
};
|
||||
let assets = Assets::load(&base).unwrap();
|
||||
let biomes = crate::biome::Biomes::load(&base).unwrap();
|
||||
// Blocks above the grass (the top layer is at the level's minimum Y + 63) in the
|
||||
// chunk at 4, 4 once its 3×3 region is decorated.
|
||||
let above_ground = |features| {
|
||||
let flat = horizon_world::worldgen::generator::FlatSettings {
|
||||
layers: vec![
|
||||
("minecraft:bedrock".into(), 1),
|
||||
("minecraft:stone".into(), 59),
|
||||
("minecraft:dirt".into(), 3),
|
||||
("minecraft:grass_block".into(), 1),
|
||||
],
|
||||
biome: "minecraft:plains".into(),
|
||||
structure_overrides: Some(vec![]),
|
||||
features,
|
||||
lakes: false,
|
||||
};
|
||||
let terrain = crate::vanillagen::VanillaTerrain::for_flat(
|
||||
&base.join("worldgen"),
|
||||
&Default::default(),
|
||||
assets.registry,
|
||||
&biomes,
|
||||
1234,
|
||||
&flat,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let mut worker = terrain.worker();
|
||||
let mut region: [_; 9] =
|
||||
std::array::from_fn(|i| terrain.proto(&mut worker, 3 + i as i32 % 3, 3 + i as i32 / 3));
|
||||
terrain.decorate(&mut worker, &mut region);
|
||||
let [_, _, _, _, center, ..] = region;
|
||||
let chunk = terrain.finish(center);
|
||||
let mut found = Vec::new();
|
||||
for y in 0..16 {
|
||||
for z in 64..80 {
|
||||
for x in 64..80 {
|
||||
let name = &assets.registry.state(chunk.block(x, y, z)).unwrap().name;
|
||||
if name != "minecraft:air" {
|
||||
found.push(name.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
found
|
||||
};
|
||||
let decorated = above_ground(true);
|
||||
assert!(
|
||||
decorated.iter().any(|n| n == "minecraft:short_grass"),
|
||||
"{decorated:?}"
|
||||
);
|
||||
assert_eq!(above_ground(false), Vec::<String>::new());
|
||||
}
|
||||
|
||||
/// `createForFlat` searches the stronghold rings with seed 0, so a superflat world's
|
||||
/// strongholds are in the same place on every seed; a layer that is not opaque to
|
||||
/// `MOTION_BLOCKING` is `null`, left out of `buildTerrain` for the fill after the structures.
|
||||
@@ -1252,6 +1318,8 @@ fn a_superflat_world_keeps_its_strongholds_on_every_seed_and_leaves_null_layers_
|
||||
],
|
||||
biome: "minecraft:plains".into(),
|
||||
structure_overrides: Some(vec!["minecraft:strongholds".into()]),
|
||||
features: false,
|
||||
lakes: false,
|
||||
};
|
||||
let make = |seed| {
|
||||
crate::vanillagen::VanillaTerrain::for_flat(
|
||||
@@ -1324,6 +1392,8 @@ fn a_superflat_structure_override_naming_no_set_lets_every_set_generate() {
|
||||
],
|
||||
biome: "minecraft:plains".into(),
|
||||
structure_overrides: Some(vec!["minecraft:village".into()]),
|
||||
features: false,
|
||||
lakes: false,
|
||||
};
|
||||
let terrain = crate::vanillagen::VanillaTerrain::for_flat(
|
||||
&base.join("worldgen"),
|
||||
|
||||
@@ -1652,6 +1652,26 @@ impl MotionWorld<'_, '_, '_> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// The block effects and the frost of a window's own player while a vehicle carries it. Its
|
||||
/// client does not travel (`passenger_pose_tick` is all it runs), but `LivingEntity.aiStep`
|
||||
/// still applies the effects of the blocks a passenger is in (`applyEffectsFromBlocks`: powder
|
||||
/// snow's FREEZE adds a tick of frost) and thaws it when it is out of powder snow. The
|
||||
/// frostbite overlay reads these ticks. The server's copy does this in `server_block_damage`.
|
||||
pub fn passenger_block_effects_tick(&mut self) -> Result<()> {
|
||||
// Entity.baseTick
|
||||
self.player.was_in_powder_snow = self.player.in_powder_snow;
|
||||
self.player.in_powder_snow = false;
|
||||
if self.player.block_effects {
|
||||
self.apply_effects_from_blocks()?;
|
||||
}
|
||||
// LivingEntity.aiStep "freezing" (server), as the client predicts it on foot.
|
||||
if !self.player.in_powder_snow || !self.player.can_freeze() {
|
||||
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
|
||||
}
|
||||
// The vehicle moves the passenger between ticks: the next sweep starts here.
|
||||
self.player.old_position = self.player.position;
|
||||
Ok(())
|
||||
}
|
||||
/// `Entity.checkInsideBlocks(from, to, ...)`: the blocks the box at `to`
|
||||
/// sweeps through from `from`; returns the iterations used plus one.
|
||||
fn check_inside_blocks(
|
||||
@@ -3516,6 +3536,82 @@ mod tests {
|
||||
assert_eq!(roses(&away), 0);
|
||||
}
|
||||
|
||||
/// #8156: a window's own player carried by a boat does not travel, but it still freezes
|
||||
/// in powder snow and thaws out of it (`LivingEntity.aiStep`), or the frostbite overlay,
|
||||
/// which reads `ticks_frozen`, never shows.
|
||||
#[test]
|
||||
fn a_passenger_in_powder_snow_freezes_and_thaws() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION)
|
||||
});
|
||||
if !root.join("movement-properties.nbt").exists() {
|
||||
return;
|
||||
}
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let binding = crate::collisions::load(&root, assets.registry).unwrap();
|
||||
let resources = MotionResources::load(&root, &assets).unwrap();
|
||||
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
|
||||
let snow = assets
|
||||
.registry
|
||||
.default_state("minecraft:powder_snow")
|
||||
.unwrap();
|
||||
scene.set(20, 4, 20, snow).unwrap();
|
||||
let in_snow = Vec3::new(20.5, 4.0, 20.5);
|
||||
let clear = Vec3::new(30.5, 4.0, 30.5);
|
||||
let mut player = PlayerMotion::new(&resources, in_snow).unwrap();
|
||||
player.client_side = true;
|
||||
let tick = |player: &mut PlayerMotion, scene: &mut World| {
|
||||
MotionWorld {
|
||||
entities: &[],
|
||||
player,
|
||||
scene,
|
||||
assets: &assets,
|
||||
collision_binding: &binding,
|
||||
resources: &resources,
|
||||
}
|
||||
.passenger_block_effects_tick()
|
||||
.unwrap();
|
||||
};
|
||||
// One tick of frost for each tick in powder snow, up to the 140 that freeze fully.
|
||||
for expected in 1..=5 {
|
||||
tick(&mut player, &mut scene);
|
||||
assert!(player.in_powder_snow);
|
||||
assert_eq!(player.ticks_frozen, expected);
|
||||
}
|
||||
for _ in 0..200 {
|
||||
tick(&mut player, &mut scene);
|
||||
}
|
||||
assert_eq!(player.ticks_frozen, PlayerMotion::TICKS_REQUIRED_TO_FREEZE);
|
||||
assert!(player.is_fully_frozen());
|
||||
// Carried out of it: two ticks of frost thaw each tick.
|
||||
player.position = clear;
|
||||
player.old_position = clear;
|
||||
player.ticks_frozen = 5;
|
||||
for expected in [3, 1, 0, 0] {
|
||||
tick(&mut player, &mut scene);
|
||||
assert!(!player.in_powder_snow);
|
||||
assert_eq!(player.ticks_frozen, expected);
|
||||
}
|
||||
// What cannot freeze (a freeze-immune player) takes no frost in it.
|
||||
player.position = in_snow;
|
||||
player.old_position = in_snow;
|
||||
player.freeze_immune = true;
|
||||
tick(&mut player, &mut scene);
|
||||
assert_eq!(player.ticks_frozen, 0);
|
||||
// Block effects off (a mod's switch): nothing is sampled, the frost thaws.
|
||||
player.freeze_immune = false;
|
||||
player.block_effects = false;
|
||||
player.ticks_frozen = 10;
|
||||
tick(&mut player, &mut scene);
|
||||
assert!(!player.in_powder_snow);
|
||||
assert_eq!(player.ticks_frozen, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walking_through_powder_snow_asks_for_a_snowflake() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
|
||||
@@ -27,6 +27,9 @@ pub enum Slot {
|
||||
Head,
|
||||
Back,
|
||||
Shoulder,
|
||||
/// Over the body: a shirt, its sleeves on the arms (top-level parts may ride another bone,
|
||||
/// `Part`'s `bone` in the file).
|
||||
Torso,
|
||||
/// A name tag style: no model, a square mark drawn before the player's
|
||||
/// name and a line under it (`Item::tag`).
|
||||
NameTag,
|
||||
@@ -137,6 +140,9 @@ pub struct Item {
|
||||
pub texture: Texture,
|
||||
pub animations: Vec<Animation>,
|
||||
pub vertices: usize,
|
||||
/// Top-level parts that ride another bone than `bone` (a shirt's sleeves on the arms), one
|
||||
/// root per bone, named `cosmetic_<bone>`, each with the item's attachment as its pose.
|
||||
pub limbs: Vec<(Bone, Part)>,
|
||||
/// A name tag's line (`Slot::NameTag` only; it has no parts).
|
||||
pub tag: Option<Tag>,
|
||||
}
|
||||
@@ -237,6 +243,9 @@ struct TextureFile {
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PartFile {
|
||||
name: String,
|
||||
/// A top-level part only: the bone it rides, if not the item's.
|
||||
#[serde(default)]
|
||||
bone: Option<Bone>,
|
||||
#[serde(default)]
|
||||
pivot: [f32; 3],
|
||||
/// Degrees.
|
||||
@@ -327,11 +336,26 @@ impl Item {
|
||||
];
|
||||
let size = [texture.width as f32, texture.height as f32];
|
||||
let mut names = Vec::new();
|
||||
let children = file
|
||||
.parts
|
||||
.iter()
|
||||
.map(|p| bake(p, size, &mut names))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let mut children = Vec::new();
|
||||
let mut limbs: Vec<(Bone, Part)> = Vec::new();
|
||||
for p in &file.parts {
|
||||
let part = bake(p, size, &mut names, true)?;
|
||||
match p.bone.filter(|b| *b != file.bone) {
|
||||
None => children.push(part),
|
||||
Some(bone) => match limbs.iter_mut().find(|(b, _)| *b == bone) {
|
||||
Some((_, limb)) => limb.children.push(part),
|
||||
None => limbs.push((
|
||||
bone,
|
||||
Part {
|
||||
name: format!("cosmetic_{}", bone.part()),
|
||||
pose,
|
||||
faces: Vec::new(),
|
||||
children: vec![part],
|
||||
},
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
if names.len() > MAX_PARTS {
|
||||
return Err(Error::TooManyParts(names.len()));
|
||||
}
|
||||
@@ -341,7 +365,7 @@ impl Item {
|
||||
faces: Vec::new(),
|
||||
children,
|
||||
};
|
||||
let vertices = count(&root);
|
||||
let vertices = count(&root) + limbs.iter().map(|(_, l)| count(l)).sum::<usize>();
|
||||
if vertices > MAX_VERTICES {
|
||||
return Err(Error::TooManyVertices(vertices));
|
||||
}
|
||||
@@ -373,6 +397,7 @@ impl Item {
|
||||
texture,
|
||||
animations: file.animations,
|
||||
vertices,
|
||||
limbs,
|
||||
tag,
|
||||
};
|
||||
let reach = item.reach();
|
||||
@@ -382,6 +407,11 @@ impl Item {
|
||||
Ok(item)
|
||||
}
|
||||
|
||||
/// The item's roots and the bone each rides: `root` on `bone`, then the `limbs`.
|
||||
pub fn roots(&self) -> impl Iterator<Item = (Bone, &Part)> {
|
||||
std::iter::once((self.bone, &self.root)).chain(self.limbs.iter().map(|(b, p)| (*b, p)))
|
||||
}
|
||||
|
||||
/// The farthest any vertex gets from the bone's pivot (pixels): at rest,
|
||||
/// and at 32 points of each animation's period.
|
||||
fn reach(&self) -> f32 {
|
||||
@@ -396,9 +426,11 @@ impl Item {
|
||||
self.animate(ticks, |name, pose| {
|
||||
poses.insert(name.to_owned(), pose);
|
||||
});
|
||||
walk(&self.root, glam::Mat4::IDENTITY, &poses, &mut |p| {
|
||||
far = far.max(p.length() * 16.0);
|
||||
});
|
||||
for (_, root) in self.roots() {
|
||||
walk(root, glam::Mat4::IDENTITY, &poses, &mut |p| {
|
||||
far = far.max(p.length() * 16.0);
|
||||
});
|
||||
}
|
||||
}
|
||||
far
|
||||
}
|
||||
@@ -412,7 +444,7 @@ impl Item {
|
||||
continue;
|
||||
}
|
||||
done.push(&anim.part);
|
||||
let Some(part) = find(&self.root, &anim.part) else {
|
||||
let Some(part) = self.roots().find_map(|(_, r)| find(r, &anim.part)) else {
|
||||
continue;
|
||||
};
|
||||
let mut pose = part.pose;
|
||||
@@ -467,10 +499,21 @@ fn count(part: &Part) -> usize {
|
||||
part.faces.len() * 4 + part.children.iter().map(count).sum::<usize>()
|
||||
}
|
||||
|
||||
fn bake(file: &PartFile, size: [f32; 2], names: &mut Vec<String>) -> Result<Part, Error> {
|
||||
if file.name.is_empty() || file.name == "cosmetic" || names.contains(&file.name) {
|
||||
fn bake(
|
||||
file: &PartFile,
|
||||
size: [f32; 2],
|
||||
names: &mut Vec<String>,
|
||||
top: bool,
|
||||
) -> Result<Part, Error> {
|
||||
if file.name.is_empty() || file.name.starts_with("cosmetic") || names.contains(&file.name) {
|
||||
return Err(invalid(format!("part names are unique: {:?}", file.name)));
|
||||
}
|
||||
if !top && file.bone.is_some() {
|
||||
return Err(invalid(format!(
|
||||
"only a top-level part rides its own bone: {:?}",
|
||||
file.name
|
||||
)));
|
||||
}
|
||||
names.push(file.name.clone());
|
||||
finite(&file.pivot, "a pivot")?;
|
||||
finite(&file.rotation, "a rotation")?;
|
||||
@@ -498,7 +541,7 @@ fn bake(file: &PartFile, size: [f32; 2], names: &mut Vec<String>) -> Result<Part
|
||||
let children = file
|
||||
.children
|
||||
.iter()
|
||||
.map(|c| bake(c, size, names))
|
||||
.map(|c| bake(c, size, names, false))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(Part {
|
||||
name: file.name.clone(),
|
||||
|
||||
@@ -22,14 +22,16 @@ pub use item::{
|
||||
};
|
||||
pub use pose::{Pose, pose_matrix};
|
||||
|
||||
/// Cosmetics stay off until this is true: no request, no drawing, no settings.
|
||||
pub const ON_BY_DEFAULT: bool = false;
|
||||
/// Cosmetics run by default (Ludovic, 2026-11-04: on when the shop opens): the game asks what
|
||||
/// players in view wear and draws it, and its settings show and hide them (both shown by
|
||||
/// default). `HORIZON_COSMETICS=0` turns them off.
|
||||
pub const ON_BY_DEFAULT: bool = true;
|
||||
|
||||
/// The cosmetics service (`HORIZON_COSMETICS_API` overrides it).
|
||||
pub const API: &str = "https://cosmetics.playhorizon.dev";
|
||||
|
||||
/// Whether cosmetics run: `ON_BY_DEFAULT`, or `HORIZON_COSMETICS=1` for one run (development,
|
||||
/// the bench). `HORIZON_COSMETICS=0` keeps them off.
|
||||
/// Whether cosmetics run: `ON_BY_DEFAULT`, or `HORIZON_COSMETICS=1` / `0` for one run
|
||||
/// (development, the bench).
|
||||
pub fn enabled() -> bool {
|
||||
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
|
||||
*ON.get_or_init(|| match std::env::var("HORIZON_COSMETICS").as_deref() {
|
||||
@@ -38,3 +40,9 @@ pub fn enabled() -> bool {
|
||||
_ => ON_BY_DEFAULT,
|
||||
})
|
||||
}
|
||||
|
||||
/// `HORIZON_COSMETICS=1` asked for this run: the launcher shows its Wardrobe even while the
|
||||
/// cosmetics service has no Shop to show (development, screenshots).
|
||||
pub fn forced() -> bool {
|
||||
std::env::var("HORIZON_COSMETICS").as_deref() == Ok("1")
|
||||
}
|
||||
@@ -252,4 +252,55 @@ pub(crate) mod tests {
|
||||
let hat_with_tag = hat(r##", "tag": {"line": "#ffffff"}"##);
|
||||
assert!(Item::parse(hat_with_tag.as_bytes()).is_err());
|
||||
}
|
||||
|
||||
/// A shirt: the body's box, and a sleeve that rides the right arm.
|
||||
fn shirt(sleeve: &str) -> String {
|
||||
format!(
|
||||
r#"{{"format": 1, "id": "shirt", "slot": "torso", "bone": "body",
|
||||
"texture": {{"png": "{}"}},
|
||||
"parts": [{{"name": "chest", "cubes": [{{"origin": [-4, 0, -2], "size": [8, 12, 4], "uv": [0, 0]}}]}},
|
||||
{{"name": "sleeve", {sleeve}
|
||||
"cubes": [{{"origin": [-3, -2, -2], "size": [4, 4, 4], "uv": [0, 16]}}]}}]}}"#,
|
||||
png(32, 32)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sleeve_rides_its_own_bone() {
|
||||
let item = Item::parse(shirt(r#""bone": "right_arm","#).as_bytes()).unwrap();
|
||||
assert_eq!((item.slot, item.bone), (Slot::Torso, Bone::Body));
|
||||
assert_eq!(item.vertices, 2 * 6 * 4);
|
||||
let roots: Vec<_> = item.roots().map(|(b, r)| (b, r.children.len())).collect();
|
||||
assert_eq!(roots, [(Bone::Body, 1), (Bone::RightArm, 1)]);
|
||||
// The sleeve's root sits at the arm's pivot with the item's attachment (none here): its
|
||||
// down face's vertex 2 is the cube's (-3, -2, -2) corner, under whatever matrix the arm
|
||||
// has.
|
||||
let (_, limb) = &item.limbs[0];
|
||||
let arm = Mat4::from_translation(Vec3::new(-5.0, 2.0, 0.0) / 16.0)
|
||||
* Mat4::from_rotation_x(40f32.to_radians());
|
||||
let v = limb.children[0].faces[0].vertices[2];
|
||||
let at = arm * pose_matrix(limb.pose) * pose_matrix(limb.children[0].pose);
|
||||
close(
|
||||
at.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0),
|
||||
arm.transform_point3(Vec3::new(-3.0, -2.0, -2.0) / 16.0),
|
||||
);
|
||||
// On the item's own bone it stays with the chest.
|
||||
let same = Item::parse(shirt(r#""bone": "body","#).as_bytes()).unwrap();
|
||||
assert!(same.limbs.is_empty());
|
||||
assert_eq!(same.root.children.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_top_level_part_rides_another_bone() {
|
||||
let nested = hat("").replace(
|
||||
r#""name": "crown","#,
|
||||
r#""name": "crown", "bone": "left_arm","#,
|
||||
);
|
||||
assert!(matches!(
|
||||
Item::parse(nested.as_bytes()),
|
||||
Err(Error::Invalid(_))
|
||||
));
|
||||
let named = shirt("").replace(r#""name": "sleeve""#, r#""name": "cosmetic_right_arm""#);
|
||||
assert!(Item::parse(named.as_bytes()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -111,17 +111,21 @@ pub struct Draw<'a> {
|
||||
}
|
||||
|
||||
impl<'a> Draw<'a> {
|
||||
/// `item` on the player model `body`, at `ticks` of its animations.
|
||||
pub fn item(item: &'a Item, body: &Part, ticks: f32) -> Option<Self> {
|
||||
let bone = rest_matrix(body, Mat4::IDENTITY, item.bone.part())?;
|
||||
/// `item` on the player model `body`, at `ticks` of its animations: one draw per bone it
|
||||
/// rides (a shirt's sleeves are on the arms).
|
||||
pub fn item(item: &'a Item, body: &Part, ticks: f32) -> Vec<Self> {
|
||||
let mut poses = Vec::new();
|
||||
item.animate(ticks, |name, pose| poses.push((name.to_owned(), pose)));
|
||||
Some(Self {
|
||||
root: &item.root,
|
||||
base: bone,
|
||||
texture: &item.texture,
|
||||
poses,
|
||||
})
|
||||
item.roots()
|
||||
.filter_map(|(bone, root)| {
|
||||
Some(Self {
|
||||
root,
|
||||
base: rest_matrix(body, Mat4::IDENTITY, bone.part())?,
|
||||
texture: &item.texture,
|
||||
poses: poses.clone(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +327,7 @@ mod tests {
|
||||
poses: Vec::new(),
|
||||
}];
|
||||
let bare = render(&draws, 100, 100, 0.0, 0.0);
|
||||
draws.push(Draw::item(&item, &body, 0.0).unwrap());
|
||||
draws.extend(Draw::item(&item, &body, 0.0));
|
||||
let worn = render(&draws, 100, 100, 0.0, 0.0);
|
||||
// The cap spans 8 to 12 model pixels above the head's pivot: rows 0 to 10, over the
|
||||
// head's top at row 10.
|
||||
|
||||
@@ -402,6 +402,32 @@ pub fn prometheus(shared: &Shared) -> Response {
|
||||
"Chunks loaded",
|
||||
&[("", f(&game["chunks"]))],
|
||||
);
|
||||
// By dimension, from the game's `levels`; a level the game does not count is left out, not
|
||||
// reported as 0.
|
||||
let by_dimension = |key: &str| -> Vec<(String, Option<f64>)> {
|
||||
game["levels"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|level| {
|
||||
let dimension = level["dimension"].as_str()?;
|
||||
Some((format!("{{dimension=\"{dimension}\"}}"), f(&level[key])))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
for (name, help, key) in [
|
||||
("loaded_chunks", "Chunks loaded, by dimension", "chunks"),
|
||||
(
|
||||
"entities",
|
||||
"Entities (players and block entities apart), by dimension",
|
||||
"entities",
|
||||
),
|
||||
] {
|
||||
let values = by_dimension(key);
|
||||
let values: Vec<(&str, Option<f64>)> =
|
||||
values.iter().map(|(l, v)| (l.as_str(), *v)).collect();
|
||||
gauge(name, help, &values);
|
||||
}
|
||||
gauge(
|
||||
"memory_rss_bytes",
|
||||
"The game process's resident memory",
|
||||
@@ -577,6 +603,45 @@ mod tests {
|
||||
] {
|
||||
assert!(text.contains(line), "{line} in {text}");
|
||||
}
|
||||
assert!(
|
||||
!text.contains("horizon_entities"),
|
||||
"no levels, no counts: {text}"
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chunks_and_entities_are_reported_by_dimension() {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-server-levels-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let shared = Shared::with_install(&dir, None);
|
||||
shared.state.lock().unwrap().sample = json!({
|
||||
"game": { "tps": 20.0, "chunks": 441, "levels": [
|
||||
{ "dimension": "minecraft:overworld", "chunks": 441, "entities": 37 },
|
||||
{ "dimension": "minecraft:the_nether", "chunks": 25, "entities": 0 },
|
||||
{ "dimension": "minecraft:the_end" },
|
||||
] },
|
||||
});
|
||||
let text = prometheus(&shared).body;
|
||||
for line in [
|
||||
"# TYPE horizon_loaded_chunks gauge
|
||||
",
|
||||
"horizon_loaded_chunks{dimension=\"minecraft:overworld\"} 441
|
||||
",
|
||||
"horizon_loaded_chunks{dimension=\"minecraft:the_nether\"} 25
|
||||
",
|
||||
"# TYPE horizon_entities gauge
|
||||
",
|
||||
"horizon_entities{dimension=\"minecraft:overworld\"} 37
|
||||
",
|
||||
"horizon_entities{dimension=\"minecraft:the_nether\"} 0
|
||||
",
|
||||
] {
|
||||
assert!(text.contains(line), "{line} in {text}");
|
||||
}
|
||||
// A level the game gave no count for has no line, rather than a 0.
|
||||
assert!(!text.contains("the_end"), "{text}");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ pub const ROUTES: [Route; 30] = [
|
||||
method: "GET",
|
||||
path: "/api/v1/metrics/prometheus",
|
||||
takes: "",
|
||||
answers: "the latest sample as Prometheus text: `horizon_up`, `horizon_tps`, `horizon_mspt{stat}`, `horizon_players_online`, `horizon_memory_rss_bytes`...",
|
||||
answers: "the latest sample as Prometheus text: `horizon_up`, `horizon_tps`, `horizon_mspt{stat}`, `horizon_players_online`, `horizon_memory_rss_bytes`, `horizon_loaded_chunks{dimension}` and `horizon_entities{dimension}` (players and block entities apart)...",
|
||||
what: "Metrics for a monitoring tool to scrape",
|
||||
},
|
||||
Route {
|
||||
@@ -948,6 +948,71 @@ impl Client {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const LIFECYCLE_GOLDENS: &str = include_str!("../testdata/api-v1-lifecycle.json");
|
||||
|
||||
fn golden_phase(name: &str) -> Phase {
|
||||
match name {
|
||||
"starting" => Phase::Starting,
|
||||
"running" => Phase::Running,
|
||||
"stopped" => Phase::Stopped,
|
||||
"stopping" => Phase::Stopping,
|
||||
"restarting" => Phase::Restarting,
|
||||
_ => panic!("unknown golden phase {name:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_lifecycle_reply(path: &str, body: &mut Value) {
|
||||
if path == "/api/v1/status" {
|
||||
assert!(body["version"].is_string());
|
||||
assert!(body["pid"].is_u64());
|
||||
assert!(body["uptime_seconds"].is_u64());
|
||||
assert!(body["engine_uptime_seconds"].is_u64());
|
||||
body["version"] = json!("$VERSION");
|
||||
body["pid"] = json!("$PID");
|
||||
body["uptime_seconds"] = json!("$TIME");
|
||||
body["engine_uptime_seconds"] = json!("$TIME");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_replies_match_api_v1_goldens() {
|
||||
let cases: Value = serde_json::from_str(LIFECYCLE_GOLDENS).unwrap();
|
||||
for case in cases.as_array().unwrap() {
|
||||
let shared = Shared::with_install(Path::new("unused"), None);
|
||||
let sample = json!({ "t": 200, "tps": 20, "players": 2 });
|
||||
{
|
||||
let mut state = shared.state.lock().unwrap();
|
||||
state.phase = golden_phase(case["phase"].as_str().unwrap());
|
||||
state.engine_pid = Some(4242);
|
||||
state.engine_started = Some(Instant::now());
|
||||
state.crashes = 2;
|
||||
state.last_crash = Some("exit status: 1".into());
|
||||
state.sample = sample.clone();
|
||||
}
|
||||
shared.record(json!({ "t": 100, "tps": 19, "players": 1 }));
|
||||
shared.record(sample);
|
||||
shared.log.lock().unwrap().push("first".into());
|
||||
shared.log.lock().unwrap().push("second".into());
|
||||
|
||||
let raw_path = case["path"].as_str().unwrap();
|
||||
let (path, query) = raw_path.split_once('?').unwrap_or((raw_path, ""));
|
||||
let response = route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: case["method"].as_str().unwrap().into(),
|
||||
path: path.into(),
|
||||
query: query.into(),
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
},
|
||||
);
|
||||
let mut body: Value = serde_json::from_str(&response.body).unwrap();
|
||||
normalize_lifecycle_reply(path, &mut body);
|
||||
assert_eq!(response.status, case["status"], "{}", case["name"]);
|
||||
assert_eq!(body, case["body"], "{}", case["name"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_keeps_the_last_lines_numbered() {
|
||||
let mut log = Log::default();
|
||||
@@ -1179,6 +1244,131 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
fn backup_reply(mut response: Response) -> Value {
|
||||
let body = if let Some(write) = response.stream.take() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let mut client = std::net::TcpStream::connect(listener.local_addr().unwrap()).unwrap();
|
||||
let (server, _) = listener.accept().unwrap();
|
||||
write(&server);
|
||||
server.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
std::io::Read::read_to_end(&mut client, &mut bytes).unwrap();
|
||||
Value::String(
|
||||
bytes
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(""),
|
||||
)
|
||||
} else {
|
||||
serde_json::from_str(&response.body).unwrap()
|
||||
};
|
||||
json!({
|
||||
"status": response.status,
|
||||
"content_type": response.content_type,
|
||||
"headers": response.headers,
|
||||
"body": body,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_replies_match_api_v1_goldens() {
|
||||
let golden: Value =
|
||||
serde_json::from_str(include_str!("../testdata/api-v1-backups.json")).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-server-api-backup-goldens-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(dir.join("backups")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("world/dimensions/minecraft/the_end")).unwrap();
|
||||
std::fs::write(dir.join(crate::config::FILE), crate::config::TEMPLATE).unwrap();
|
||||
std::fs::write(dir.join("world/level.dat"), b"level").unwrap();
|
||||
std::fs::write(
|
||||
dir.join("world/dimensions/minecraft/the_end/region.mca"),
|
||||
b"end",
|
||||
)
|
||||
.unwrap();
|
||||
let name = "2026-11-04_00-00-00-golden.zip";
|
||||
std::fs::write(dir.join("backups").join(name), b"PK\x05\x06zip").unwrap();
|
||||
let shared = Shared::with_install(&dir, None);
|
||||
shared.set_phase(Phase::Stopped);
|
||||
let call = |method: &str, target: &str, body: Value| {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
route(
|
||||
&shared,
|
||||
&Request {
|
||||
method: method.into(),
|
||||
path: path.into(),
|
||||
query: query.into(),
|
||||
headers: Vec::new(),
|
||||
body: body.to_string().into_bytes(),
|
||||
},
|
||||
)
|
||||
};
|
||||
let check = |case: &str, response: Response| {
|
||||
assert_eq!(backup_reply(response), golden[case], "{case}");
|
||||
};
|
||||
check("list", call("GET", "/api/v1/backups", Value::Null));
|
||||
check(
|
||||
"download",
|
||||
call(
|
||||
"GET",
|
||||
&format!("/api/v1/backups/download?name={name}"),
|
||||
Value::Null,
|
||||
),
|
||||
);
|
||||
check(
|
||||
"restore",
|
||||
call("POST", "/api/v1/restore", json!({ "name": name })),
|
||||
);
|
||||
check(
|
||||
"restore_dimension",
|
||||
call(
|
||||
"POST",
|
||||
"/api/v1/restore",
|
||||
json!({ "name": name, "dimension": "the_end" }),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"bad_dimension",
|
||||
call(
|
||||
"POST",
|
||||
"/api/v1/restore",
|
||||
json!({ "name": name, "dimension": "../end" }),
|
||||
),
|
||||
);
|
||||
check(
|
||||
"restore_missing",
|
||||
call("POST", "/api/v1/restore", json!({ "name": "missing" })),
|
||||
);
|
||||
check(
|
||||
"delete_missing",
|
||||
call("DELETE", "/api/v1/backups?name=../server.toml", Value::Null),
|
||||
);
|
||||
check(
|
||||
"delete",
|
||||
call(
|
||||
"DELETE",
|
||||
&format!("/api/v1/backups?name={name}"),
|
||||
Value::Null,
|
||||
),
|
||||
);
|
||||
check("list_empty", call("GET", "/api/v1/backups", Value::Null));
|
||||
|
||||
let mut created = backup_reply(call(
|
||||
"POST",
|
||||
"/api/v1/backups",
|
||||
json!({ "name": "created" }),
|
||||
));
|
||||
let created_name = created["body"]["name"].as_str().unwrap();
|
||||
assert!(created_name.ends_with("-created.zip"));
|
||||
assert!(dir.join("backups").join(created_name).is_file());
|
||||
created["body"]["name"] = json!("$BACKUP");
|
||||
assert_eq!(created, golden["create"]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_supervisor_can_edit_lists_and_start_again() {
|
||||
let dir =
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"list": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"backups": [{"name": "2026-11-04_00-00-00-golden.zip", "bytes": 7}]}
|
||||
},
|
||||
"download": {
|
||||
"status": 200,
|
||||
"content_type": "application/octet-stream",
|
||||
"headers": [
|
||||
["Content-Length", "7"],
|
||||
["Content-Disposition", "attachment; filename=\"2026-11-04_00-00-00-golden.zip\""]
|
||||
],
|
||||
"body": "504b05067a6970"
|
||||
},
|
||||
"restore": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"name": "2026-11-04_00-00-00-golden.zip", "dimension": null}
|
||||
},
|
||||
"restore_dimension": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"name": "2026-11-04_00-00-00-golden.zip", "dimension": "minecraft:the_end"}
|
||||
},
|
||||
"bad_dimension": {
|
||||
"status": 400,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "\"../end\" is not a dimension id (like minecraft:the_nether)"}
|
||||
},
|
||||
"restore_missing": {
|
||||
"status": 404,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "no backup \"missing\" in backups/ (`horizon-server backups` lists them)"}
|
||||
},
|
||||
"delete_missing": {
|
||||
"status": 404,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"error": "no such backup (`GET /api/v1/backups` lists them)"}
|
||||
},
|
||||
"delete": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"name": "2026-11-04_00-00-00-golden.zip"}
|
||||
},
|
||||
"list_empty": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"backups": []}
|
||||
},
|
||||
"create": {
|
||||
"status": 200,
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"body": {"name": "$BACKUP"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
[
|
||||
{
|
||||
"name": "status",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/status",
|
||||
"phase": "running",
|
||||
"status": 200,
|
||||
"body": {
|
||||
"phase": "running",
|
||||
"version": "$VERSION",
|
||||
"pid": "$PID",
|
||||
"uptime_seconds": "$TIME",
|
||||
"engine_pid": 4242,
|
||||
"engine_uptime_seconds": "$TIME",
|
||||
"crashes": 2,
|
||||
"last_crash": "exit status: 1",
|
||||
"sample": {"t": 200, "tps": 20, "players": 2},
|
||||
"update": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "metrics after a timestamp",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/metrics?since=100",
|
||||
"phase": "running",
|
||||
"status": 200,
|
||||
"body": {"samples": [{"t": 200, "tps": 20, "players": 2}]}
|
||||
},
|
||||
{
|
||||
"name": "logs after a line",
|
||||
"method": "GET",
|
||||
"path": "/api/v1/logs?since=1",
|
||||
"phase": "running",
|
||||
"status": 200,
|
||||
"body": {"next": 2, "lines": ["second"]}
|
||||
},
|
||||
{
|
||||
"name": "start a stopped game",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/start",
|
||||
"phase": "stopped",
|
||||
"status": 200,
|
||||
"body": {"ok": true}
|
||||
},
|
||||
{
|
||||
"name": "start while running",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/start",
|
||||
"phase": "running",
|
||||
"status": 409,
|
||||
"body": {"error": "the game isn't stopped right now"}
|
||||
},
|
||||
{
|
||||
"name": "stop",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/stop",
|
||||
"phase": "running",
|
||||
"status": 200,
|
||||
"body": {"ok": true}
|
||||
},
|
||||
{
|
||||
"name": "restart",
|
||||
"method": "POST",
|
||||
"path": "/api/v1/restart",
|
||||
"phase": "running",
|
||||
"status": 200,
|
||||
"body": {"ok": true}
|
||||
}
|
||||
]
|
||||
@@ -5,8 +5,10 @@
|
||||
//!
|
||||
//! Everything is read from live code only: the preprocessor has already dropped the false
|
||||
//! branches, so a format chosen by an option is the one the option selects. Every stage file of
|
||||
//! the programs that run is read (OQ-17); a later file overrides an earlier one, with a note when
|
||||
//! the values differ.
|
||||
//! the programs that run is read (OQ-17), and the shadow programs' when the shadow pass doesn't
|
||||
//! run; a later file overrides an earlier one, with a note when the values differ. A colortex
|
||||
//! format counts only in a fragment stage, and in a block comment only where `const` starts its
|
||||
//! line, as Iris 1.11.7 read our probes ([`Config::read_vertex`], [`Config::read`]).
|
||||
|
||||
use crate::Preprocessed;
|
||||
|
||||
@@ -147,6 +149,10 @@ pub struct Shadow {
|
||||
pub far: f32,
|
||||
/// `shadowHardwareFiltering(0|1)`: shadowtex0/1 sampled with depth comparison.
|
||||
pub hardware_filtering: [bool; 2],
|
||||
/// `shadowtex0Nearest` (or `shadowtexNearest`, `shadow0MinMagNearest`) and
|
||||
/// `shadowtex1Nearest` (or `shadow1MinMagNearest`): shadowtex0/1 read through a plain sampler
|
||||
/// take the nearest texel. Otherwise Iris 1.11.7 filters them linearly (measured).
|
||||
pub nearest: [bool; 2],
|
||||
/// `shadowcolor0` to `shadowcolor7`: format, cleared every frame, clear colour (default
|
||||
/// white).
|
||||
pub colors: [Buffer; SHADOW_COLORS],
|
||||
@@ -170,6 +176,7 @@ impl Default for Shadow {
|
||||
near: 0.05,
|
||||
far: 256.0,
|
||||
hardware_filtering: [false; 2],
|
||||
nearest: [false; 2],
|
||||
colors: std::array::from_fn(|_| white.clone()),
|
||||
}
|
||||
}
|
||||
@@ -181,10 +188,8 @@ pub struct Config {
|
||||
pub shadow: Shadow,
|
||||
/// Conflicting or unreadable settings, as `file:line: message`.
|
||||
pub notes: Vec<String>,
|
||||
format_set: [bool; BUFFERS],
|
||||
/// Each setting read so far and its value, to report a later file changing it.
|
||||
seen: Vec<(String, String)>,
|
||||
gdepth: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -193,9 +198,7 @@ impl Default for Config {
|
||||
buffers: std::array::from_fn(|_| Buffer::default()),
|
||||
shadow: Shadow::default(),
|
||||
notes: Vec::new(),
|
||||
format_set: [false; BUFFERS],
|
||||
seen: Vec::new(),
|
||||
gdepth: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,42 +233,66 @@ pub fn buffer_index(name: &str) -> Option<usize> {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Reads one preprocessed stage file.
|
||||
/// Reads one preprocessed fragment stage file.
|
||||
pub fn read(&mut self, pp: &Preprocessed) {
|
||||
self.read_stage(pp, true, true);
|
||||
}
|
||||
|
||||
/// Reads only the colortex formats of one preprocessed fragment stage file, as [`Self::read`]
|
||||
/// reads them.
|
||||
pub fn read_formats(&mut self, pp: &Preprocessed) {
|
||||
self.read_stage(pp, true, false);
|
||||
}
|
||||
|
||||
/// Reads one preprocessed vertex stage file. Its colortex formats don't count: Iris 1.11.7
|
||||
/// ignored a `colortexNFormat` declared only in `composite.vsh`, `final.vsh`, `deferred.vsh`,
|
||||
/// `gbuffers_terrain.vsh` or `shadow.vsh` of probe packs p9063a, p9063c and p9063s (ours),
|
||||
/// while it honoured the same line in each program's fragment shader.
|
||||
pub fn read_vertex(&mut self, pp: &Preprocessed) {
|
||||
self.read_stage(pp, false, true);
|
||||
}
|
||||
|
||||
/// `formats`: whether colortex formats count; `others`: whether every other setting does.
|
||||
fn read_stage(&mut self, pp: &Preprocessed, formats: bool, others: bool) {
|
||||
let line = |at: usize| pp.locate(code_line(&pp.code, at));
|
||||
for (at, ty, name, value) in constants(&pp.code) {
|
||||
let place = line(at);
|
||||
self.constant(&place, ty, name, value);
|
||||
self.constant(&place, ty, name, value, formats, others);
|
||||
}
|
||||
for (origin, comment) in &pp.comments {
|
||||
for (_, ty, name, value) in constants(comment) {
|
||||
for (at, ty, name, value) in constants(comment) {
|
||||
let file = pp
|
||||
.files
|
||||
.get(origin.file as usize)
|
||||
.map_or("?", String::as_str);
|
||||
let place = format!("{file}:{}", origin.line);
|
||||
self.constant(&place, ty, name, value);
|
||||
// Our probes' Iris 1.11.7 values fit this (a fit, not a rule Iris states): a
|
||||
// `colortexNFormat` in a block comment counts only when `const` starts its line.
|
||||
// Iris ignored one on the line of the `/*` (`/* const int colortex2Format =
|
||||
// RGBA16F; */`), after a ` * `, and after another declaration on its line; it
|
||||
// honoured one indented, one followed by text or by `*/`, and the first of two.
|
||||
let starts_line = (comment[..at].rfind('\n'))
|
||||
.is_some_and(|n| comment[n + 1..at].trim().is_empty());
|
||||
self.constant(&place, ty, name, value, formats && starts_line, others);
|
||||
}
|
||||
}
|
||||
if declares(&pp.code, "gdepth") {
|
||||
self.gdepth = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings once every file is read: buffer 1 becomes RGBA32F when a program declares
|
||||
/// `gdepth` and the pack gave it no format (6.1.3).
|
||||
pub fn finish(mut self) -> Self {
|
||||
if self.gdepth && !self.format_set[1] {
|
||||
self.buffers[1].format = Format {
|
||||
channels: 4,
|
||||
bits: 32,
|
||||
kind: Kind::Float,
|
||||
};
|
||||
/// `formats`: whether a colortex format declared here counts; `others`: whether any other
|
||||
/// setting does.
|
||||
fn constant(
|
||||
&mut self,
|
||||
place: &str,
|
||||
ty: &str,
|
||||
name: &str,
|
||||
value: &str,
|
||||
formats: bool,
|
||||
others: bool,
|
||||
) {
|
||||
let format = ty == "int" && name.strip_suffix("Format").and_then(buffer_index).is_some();
|
||||
if !others && !format {
|
||||
return;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn constant(&mut self, place: &str, ty: &str, name: &str, value: &str) {
|
||||
if self.shadow_constant(ty, name, value) {
|
||||
return;
|
||||
}
|
||||
@@ -281,12 +308,12 @@ impl Config {
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
if setting == "Format" && !formats {
|
||||
return;
|
||||
}
|
||||
let b = &mut self.buffers[buffer];
|
||||
let ok = match setting {
|
||||
"Format" => Format::parse(value).map(|f| {
|
||||
b.format = f;
|
||||
self.format_set[buffer] = true;
|
||||
}),
|
||||
"Format" => Format::parse(value).map(|f| b.format = f),
|
||||
"ClearColor" => vec4(value).map(|c| b.clear_color = Some(c)),
|
||||
"Clear" => boolean(value).map(|v| b.clear = v),
|
||||
_ => boolean(value).map(|v| b.mipmap = v),
|
||||
@@ -348,6 +375,16 @@ impl Config {
|
||||
s.hardware_filtering[usize::from(name.ends_with('1'))] = b;
|
||||
}
|
||||
}
|
||||
"shadowtexNearest" | "shadowtex0Nearest" | "shadow0MinMagNearest" => {
|
||||
if let Some(b) = boolean(value) {
|
||||
s.nearest[0] = b;
|
||||
}
|
||||
}
|
||||
"shadowtex1Nearest" | "shadow1MinMagNearest" => {
|
||||
if let Some(b) = boolean(value) {
|
||||
s.nearest[1] = b;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// `shadowcolor` is shadowcolor0.
|
||||
let color =
|
||||
@@ -437,14 +474,6 @@ fn constants(text: &str) -> Vec<(usize, &str, &str, &str)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `text` declares a uniform sampler called `name`.
|
||||
fn declares(text: &str, name: &str) -> bool {
|
||||
text.split(';').any(|decl| {
|
||||
let words: Vec<&str> = decl.split_whitespace().collect();
|
||||
words.first() == Some(&"uniform") && words.last() == Some(&name)
|
||||
})
|
||||
}
|
||||
|
||||
fn code_line(code: &str, at: usize) -> usize {
|
||||
code[..at].matches('\n').count()
|
||||
}
|
||||
@@ -531,7 +560,6 @@ mod tests {
|
||||
assert_eq!(draw_buffers(&pp), Some(vec![0, 2, 11]));
|
||||
let mut config = Config::default();
|
||||
config.read(&pp);
|
||||
let config = config.finish();
|
||||
let b = &config.buffers;
|
||||
assert_eq!(b[2].format, Format::parse("RGBA16F").unwrap());
|
||||
assert_eq!(b[7].format, Format::parse("R32F").unwrap());
|
||||
@@ -539,7 +567,9 @@ mod tests {
|
||||
assert_eq!(b[3].clear_color, Some([0.5, 0.25, 0.0, 1.0]));
|
||||
assert!(b[3].mipmap && !b[2].mipmap);
|
||||
assert_eq!(b[2].clear_color, Some([1.0; 4]));
|
||||
assert_eq!(b[1].format, Format::parse("RGBA32F").unwrap(), "gdepth");
|
||||
// Iris 1.11.7 kept RGBA8 for `gdepth` (colortex1) with no format given, in probe pack
|
||||
// p9063b of ours; OptiFine's RGBA32F rule doesn't apply.
|
||||
assert_eq!(b[1].format, Format::RGBA8, "gdepth");
|
||||
assert!(config.notes.is_empty(), "{:?}", config.notes);
|
||||
}
|
||||
|
||||
@@ -551,7 +581,7 @@ mod tests {
|
||||
const float sunPathRotation = -40.0;\nconst bool shadowHardwareFiltering = true;\n\
|
||||
/* const int shadowcolor1Format = RGBA16F; */\nconst bool shadowcolor0Clear = false;\n",
|
||||
));
|
||||
let s = c.finish().shadow;
|
||||
let s = c.shadow;
|
||||
assert_eq!(
|
||||
(s.resolution, s.distance, s.sun_path_rotation),
|
||||
(2048, 128.0, -40.0)
|
||||
@@ -563,22 +593,58 @@ mod tests {
|
||||
assert!(!s.colors[0].clear && s.colors[1].clear);
|
||||
assert_eq!(s.colors[1].format, Format::parse("RGBA16F").unwrap());
|
||||
assert_eq!(s.colors[1].clear_color, Some([1.0; 4]));
|
||||
assert_eq!(s.nearest, [false; 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_filtering_settings() {
|
||||
let nearest = |text: &str| {
|
||||
let mut c = Config::default();
|
||||
c.read(&stage(text));
|
||||
c.shadow.nearest
|
||||
};
|
||||
assert_eq!(
|
||||
nearest("const bool shadowtex0Nearest = true;\n"),
|
||||
[true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
nearest("const bool shadowtexNearest = true;\n"),
|
||||
[true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
nearest("const bool shadow0MinMagNearest = true;\n"),
|
||||
[true, false]
|
||||
);
|
||||
assert_eq!(
|
||||
nearest("const bool shadowtex1Nearest = true;\n"),
|
||||
[false, true]
|
||||
);
|
||||
assert_eq!(
|
||||
nearest("const bool shadow1MinMagNearest = true;\n"),
|
||||
[false, true]
|
||||
);
|
||||
assert_eq!(
|
||||
nearest("const bool shadowtex0Nearest = false;\n"),
|
||||
[false; 2]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_directive_and_conflicts() {
|
||||
let pp = stage("void main() {}\n");
|
||||
assert_eq!(draw_buffers(&pp), None);
|
||||
let a =
|
||||
stage("/* const int colortex1Format = RGB8; */\nconst bool colortex1Clear = maybe;\n");
|
||||
let b =
|
||||
stage("/* const int colortex1Format = RGBA16; const int colortex2Format = XYZ; */\n");
|
||||
let a = stage(
|
||||
"/*\nconst int colortex1Format = RGB8;\n*/\nconst bool colortex1Clear = maybe;\n",
|
||||
);
|
||||
let b = stage(
|
||||
"/*\nconst int colortex1Format = RGBA16;\nconst int colortex2Format = XYZ;\n*/\n",
|
||||
);
|
||||
let mut config = Config::default();
|
||||
config.read(&a);
|
||||
config.read(&b);
|
||||
let notes = config.notes.join("\n");
|
||||
assert!(
|
||||
notes.contains("composite.fsh:2: `colortex1Clear` is not a bool"),
|
||||
notes.contains("composite.fsh:4: `colortex1Clear` is not a bool"),
|
||||
"{notes}"
|
||||
);
|
||||
assert!(
|
||||
@@ -586,9 +652,34 @@ mod tests {
|
||||
"{notes}"
|
||||
);
|
||||
assert!(notes.contains("unknown format `XYZ`"), "{notes}");
|
||||
assert_eq!(config.buffers[1].format, Format::parse("RGBA16").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_format_in_a_comment_counts_where_const_starts_its_line() {
|
||||
// The placements of probe packs p9063a and p9063c (ours): Iris 1.11.7 kept RGBA8 for
|
||||
// buffers 1, 2, 5 and 6, and took RGBA16F for 3, 4 and 7 to 11.
|
||||
let pp = stage(
|
||||
"/*\n * const int colortex1Format = RGBA16F;\n */\n\
|
||||
/* const int colortex2Format = RGBA16F; */\n\
|
||||
/*\nconst int colortex3Format = RGBA16F;\n const int colortex4Format = RGBA16F;\n\
|
||||
const int colortex16Dummy = 1; const int colortex5Format = RGBA16F;\n*/\n\
|
||||
/* const int colortex6Format = RGBA16F;\n*/\n\
|
||||
/*\nconst int colortex7Format = RGBA16F; */\n\
|
||||
/*\nconst int colortex8Format = RGBA16F; trailing words\n\
|
||||
\tconst int colortex9Format = RGBA16F;\n\
|
||||
const int colortex10Format = RGBA16F ;\n\
|
||||
const int colortex11Format = RGBA16F; const int colortex16Dummy = 1;\n*/\n",
|
||||
);
|
||||
let mut config = Config::default();
|
||||
config.read(&pp);
|
||||
let half = Format::parse("RGBA16F").unwrap();
|
||||
let read: Vec<bool> = (1..=11).map(|n| config.buffers[n].format == half).collect();
|
||||
assert_eq!(
|
||||
config.finish().buffers[1].format,
|
||||
Format::parse("RGBA16").unwrap()
|
||||
read,
|
||||
[
|
||||
false, false, true, true, false, false, true, true, true, true, true
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,15 +7,26 @@
|
||||
//! comparisons and logic read the first component; a value takes its declared type at the
|
||||
//! end (`int` truncates toward zero, `bool` is "not zero"). Unknown names read 0 and are
|
||||
//! reported once.
|
||||
//! - OQ-26: `smooth` runs per frame with the frame time; its fade times are seconds (1 by
|
||||
//! default) to cover half of the remaining distance, `fadeIn` while rising, `fadeOut` while
|
||||
//! falling. Each call keeps its own state, so the optional id is not needed. As in Iris
|
||||
//! - OQ-26: `smooth` runs per frame with the frame time; its fade times (1 by default) are
|
||||
//! half-lives counted in tenths of a second (`FADE_UNIT`), `fadeIn` while rising, `fadeOut`
|
||||
//! while falling. Each call keeps its own state, so the optional id is not needed. As in Iris
|
||||
//! 1.11.7, the first argument is that id when it is written as a number and more follow.
|
||||
//! - Unknown names, as in Iris 1.11.7 (#8995): an expression naming something the custom
|
||||
//! uniforms can't see (an unknown name, or one of [`HIDDEN`]) reads 0 as a whole, even when
|
||||
//! the name is in an `if` branch not taken, and so does every expression using its uniform or
|
||||
//! variable. Only the names of [`NOT_GIVEN_YET`] read 0 alone.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::Properties;
|
||||
|
||||
/// The seconds one unit of a `smooth` fade time lasts. Fitted to Iris 1.11.7 with a probe pack of
|
||||
/// our own (`smoothprobe`: `smooth(rainStrength, f, f)` for several fades, read across the scene
|
||||
/// suite's weather changes): a half-life of a tenth of the fade time's seconds gives every
|
||||
/// measured value within 0.016. Horizon's built-in smoothed uniforms use the same 0.1 s unit
|
||||
/// (`pack_uniforms.rs` `SECONDS_A_TICK`).
|
||||
const FADE_UNIT: f64 = 0.1;
|
||||
|
||||
/// A value: up to 16 components (matrices only as names, for `m.column.row`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Val {
|
||||
@@ -61,6 +72,28 @@ impl Val {
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in names Iris 1.11.7's custom uniforms can't see, though the game may give them (most
|
||||
/// are frame uniforms here). Our probe packs (#8995) read 0 from `(<name>) * 0.0 + 2.0` for
|
||||
/// these, and 2 for 29 others (`skyColor.x`, `entityColor.x`, `blockEntityId`, `frameTime`...).
|
||||
const HIDDEN: [&str; 12] = [
|
||||
"alphaTestRef",
|
||||
"atlasSize",
|
||||
"blendFunc",
|
||||
"dimension",
|
||||
"entityId",
|
||||
"fogColor",
|
||||
"fogDensity",
|
||||
"fogEnd",
|
||||
"fogMode",
|
||||
"fogShape",
|
||||
"fogStart",
|
||||
"renderStage",
|
||||
];
|
||||
|
||||
/// Names Iris 1.11.7's custom uniforms see (our probe of #8995) that the game doesn't give yet:
|
||||
/// such a name alone reads 0, and the expression keeps the rest.
|
||||
const NOT_GIVEN_YET: [&str; 2] = ["currentRenderedItemId", "lightningBoltPosition"];
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
enum Expr {
|
||||
Num(f64),
|
||||
@@ -88,6 +121,9 @@ pub struct Custom {
|
||||
smooth: HashMap<usize, f64>,
|
||||
seed: u64,
|
||||
unknown: HashSet<String>,
|
||||
/// Per item, whether it reads 0 because it names something unknown (directly or through
|
||||
/// another item); worked out on the first `eval`.
|
||||
broken: Option<Vec<bool>>,
|
||||
/// `BIOME_*`, `CAT_*` and `PPT_*` (crate::biomes).
|
||||
constants: HashMap<String, f64>,
|
||||
/// Expressions that did not parse, and unknown names, as `key: message`.
|
||||
@@ -157,11 +193,19 @@ impl Custom {
|
||||
/// values of the game's uniforms and constants (`BIOME_*`...). Returns the uniforms'
|
||||
/// values, in the order of `uniforms`.
|
||||
pub fn eval(&mut self, dt: f64, builtin: &dyn Fn(&str) -> Option<Val>) -> Vec<Val> {
|
||||
if self.broken.is_none() {
|
||||
self.broken = Some(self.resolve(builtin));
|
||||
}
|
||||
let broken = self.broken.take().unwrap_or_default();
|
||||
let mut vars: HashMap<String, Val> = HashMap::new();
|
||||
let mut out = Vec::new();
|
||||
let items = std::mem::take(&mut self.items);
|
||||
for item in &items {
|
||||
let v = self.value(&item.expr, dt, &vars, builtin);
|
||||
for (item, &broken) in items.iter().zip(&broken) {
|
||||
let v = if broken {
|
||||
Val::scalar(0.0)
|
||||
} else {
|
||||
self.value(&item.expr, dt, &vars, builtin)
|
||||
};
|
||||
let v = match item.ty.as_str() {
|
||||
"int" => Val::scalar(v.x().trunc()),
|
||||
"bool" => Val::scalar(f64::from(u8::from(v.truth()))),
|
||||
@@ -183,9 +227,57 @@ impl Custom {
|
||||
}
|
||||
}
|
||||
self.items = items;
|
||||
self.broken = Some(broken);
|
||||
out
|
||||
}
|
||||
|
||||
/// Which items read 0: those naming something neither an item, the game (outside
|
||||
/// [`HIDDEN`]) nor a constant gives, and then those naming such an item. Notes each once.
|
||||
fn resolve(&mut self, builtin: &dyn Fn(&str) -> Option<Val>) -> Vec<bool> {
|
||||
let items: HashSet<&str> = self.items.iter().map(|i| i.name.as_str()).collect();
|
||||
let known = |n: &str| {
|
||||
matches!(n, "pi" | "true" | "false")
|
||||
|| n.starts_with("BIOME_")
|
||||
|| self.constants.contains_key(n)
|
||||
|| NOT_GIVEN_YET.contains(&n)
|
||||
|| (!HIDDEN.contains(&n) && builtin(n).is_some())
|
||||
};
|
||||
let mut broken = vec![false; self.items.len()];
|
||||
let mut uses: Vec<Vec<&str>> = Vec::new();
|
||||
for (i, item) in self.items.iter().enumerate() {
|
||||
let mut names = Vec::new();
|
||||
names_in(&item.expr, &mut names);
|
||||
if let Some(n) = names.iter().find(|n| !items.contains(*n) && !known(n)) {
|
||||
self.notes.push(format!(
|
||||
"`{}` reads 0: `{n}` is unknown to custom uniforms",
|
||||
item.name
|
||||
));
|
||||
broken[i] = true;
|
||||
}
|
||||
uses.push(names.into_iter().filter(|n| items.contains(n)).collect());
|
||||
}
|
||||
loop {
|
||||
let dead: HashSet<&str> = (self.items.iter().zip(&broken))
|
||||
.filter(|(_, b)| **b)
|
||||
.map(|(i, _)| i.name.as_str())
|
||||
.collect();
|
||||
let mut changed = false;
|
||||
for (i, item) in self.items.iter().enumerate() {
|
||||
if let Some(n) = uses[i].iter().find(|n| !broken[i] && dead.contains(*n)) {
|
||||
self.notes.push(format!(
|
||||
"`{}` reads 0: it uses `{n}`, which does",
|
||||
item.name
|
||||
));
|
||||
broken[i] = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return broken;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn random(&mut self) -> f64 {
|
||||
self.seed ^= self.seed << 13;
|
||||
self.seed ^= self.seed >> 7;
|
||||
@@ -330,7 +422,7 @@ impl Custom {
|
||||
let state = self.smooth.entry(*site).or_insert(val);
|
||||
let fade = if val > *state { fade_in } else { fade_out };
|
||||
if fade > 0.0 {
|
||||
*state = val + (*state - val) * 0.5f64.powf(dt / fade);
|
||||
*state = val + (*state - val) * 0.5f64.powf(dt / (fade * FADE_UNIT));
|
||||
} else {
|
||||
*state = val;
|
||||
}
|
||||
@@ -556,6 +648,20 @@ fn smooth_arguments(args: &[Expr]) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The names `e` reads, calls left out, in order.
|
||||
fn names_in<'e>(e: &'e Expr, out: &mut Vec<&'e str>) {
|
||||
match e {
|
||||
Expr::Num(_) => {}
|
||||
Expr::Name(n, _) => out.push(n),
|
||||
Expr::Call(_, args, _) => args.iter().for_each(|a| names_in(a, out)),
|
||||
Expr::Neg(a) | Expr::Not(a) => names_in(a, out),
|
||||
Expr::Bin(_, a, b) => {
|
||||
names_in(a, out);
|
||||
names_in(b, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_name(t: &str) -> bool {
|
||||
t.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
|
||||
}
|
||||
@@ -634,7 +740,7 @@ mod tests {
|
||||
12.0,
|
||||
2.0 + 2.5 + 2.0,
|
||||
9.0 + 0.875,
|
||||
1.0,
|
||||
0.0,
|
||||
0.0,
|
||||
12.0
|
||||
]
|
||||
@@ -643,7 +749,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
c.notes,
|
||||
[
|
||||
"`unknownThing` is unknown; it reads 0",
|
||||
"`h` reads 0: `unknownThing` is unknown to custom uniforms",
|
||||
"`BIOME_PALE_GARDEN` names no biome; it reads -1",
|
||||
"`BIOME_OLD` names no biome; it reads -1"
|
||||
]
|
||||
@@ -651,8 +757,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smooth_halves_the_distance_per_fade_time() {
|
||||
let mut c = custom("uniform.float.s = smooth(1, target, 2, 0.5)\n");
|
||||
fn smooth_halves_the_distance_per_tenth_of_its_fade_time() {
|
||||
let mut c = custom("uniform.float.s = smooth(1, target, 20, 5)\n");
|
||||
let mut target = 0.0;
|
||||
let mut run = |t: f64, dt: f64| {
|
||||
target = t;
|
||||
@@ -663,6 +769,42 @@ mod tests {
|
||||
assert!((run(0.0, 0.5) - 0.25).abs() < 1e-9, "falling: fadeOut");
|
||||
}
|
||||
|
||||
/// Iris 1.11.7 with a probe pack of our own, over the scene suite's weather: `weather rain`
|
||||
/// (the rain level climbs 0.01 a tick, vanilla's `ServerLevel.advanceWeatherCycle`) and a
|
||||
/// picture 9.1 s later, then `weather clear` and a picture 9.2 s after that, then one more
|
||||
/// 4.3 s on. Read there: `smooth(rainStrength, 20, 20)` 0.871, 0.118, 0.027;
|
||||
/// `smooth(rainStrength, 2, 10)` 1.0, 0.020, 0.0; `smooth(rainStrength, 5, 5)` 1.0, 0.0, 0.0.
|
||||
#[test]
|
||||
fn smooth_follows_the_rain_as_iris_does() {
|
||||
let mut c = custom(
|
||||
"uniform.float.a = smooth(rainStrength, 20, 20)\n\
|
||||
uniform.float.b = smooth(rainStrength, 2, 10)\n\
|
||||
uniform.float.c = smooth(rainStrength, 5, 5)\n",
|
||||
);
|
||||
let dt = 1.0 / 60.0;
|
||||
let (mut rain, mut read) = (0.0f64, Vec::new());
|
||||
for (seconds, target) in [(9.1, 1.0), (9.2, 0.0), (4.3, 0.0)] {
|
||||
for _ in 0..(seconds / dt) as usize {
|
||||
rain = if target > rain {
|
||||
(rain + dt / 5.0).min(1.0)
|
||||
} else {
|
||||
(rain - dt / 5.0).max(0.0)
|
||||
};
|
||||
let r = rain;
|
||||
read = c.eval(dt, &|n: &str| (n == "rainStrength").then(|| Val::scalar(r)));
|
||||
}
|
||||
let got: Vec<f64> = read.iter().map(|v| v.v[0]).collect();
|
||||
let iris = match target {
|
||||
1.0 => [0.871, 1.0, 1.0],
|
||||
_ if seconds > 9.0 => [0.118, 0.020, 0.0],
|
||||
_ => [0.027, 0.0, 0.0],
|
||||
};
|
||||
for (g, i) in got.iter().zip(iris) {
|
||||
assert!((g - i).abs() < 0.03, "{got:?} against Iris's {iris:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a restart `smooth` starts from its next value, as on the first frame, instead of
|
||||
/// fading from the old one (a 1000-second fade from a dry run's zero).
|
||||
#[test]
|
||||
@@ -718,4 +860,45 @@ mod tests {
|
||||
assert_eq!(c.uniforms().count(), 1);
|
||||
assert_eq!(c.notes.len(), 2, "{:?}", c.notes);
|
||||
}
|
||||
|
||||
/// Iris 1.11.7, with our probe pack of #8995: an expression naming something the custom
|
||||
/// uniforms can't see reads 0 as a whole, even in an `if` branch not taken, and so does
|
||||
/// anything using its uniform or variable.
|
||||
#[test]
|
||||
fn a_name_custom_uniforms_cannot_see_fails_the_whole_expression() {
|
||||
let mut c = custom(
|
||||
"variable.float.v0 = fogStart * 0.0 + 2.0\n\
|
||||
variable.float.v1 = 5.0\n\
|
||||
uniform.float.u0 = 2.0\n\
|
||||
uniform.float.u1 = dimension * 0.0 + 2.0\n\
|
||||
uniform.float.u2 = fogColor.r * 0.0 + 2.0\n\
|
||||
uniform.float.u3 = notAName * 0.0 + 2.0\n\
|
||||
uniform.float.u4 = if(1 > 0, 2.0, fogStart)\n\
|
||||
uniform.float.u5 = u0 + 1.0\n\
|
||||
uniform.float.u6 = u2 + 1.0\n\
|
||||
uniform.float.u7 = v0 + 1.0\n\
|
||||
uniform.float.u8 = v1 + 1.0\n\
|
||||
uniform.float.u9 = frameTime * 0.0 + 2.0\n\
|
||||
uniform.vec3.w = vec3(skyColor.r, entityColor.a, blockEntityId) * 0.0 + 2.0\n\
|
||||
uniform.float.r = currentRenderedItemId * 0.0 + lightningBoltPosition.x + 2.0\n",
|
||||
);
|
||||
// The game gives every name here but `notAName`, `currentRenderedItemId` and
|
||||
// `lightningBoltPosition`, as Horizon's frame uniforms do.
|
||||
let builtin = |n: &str| match n {
|
||||
"notAName" | "currentRenderedItemId" | "lightningBoltPosition" => None,
|
||||
"fogColor" | "skyColor" => Some(Val::vector(&[0.5, 0.6, 0.7])),
|
||||
"entityColor" => Some(Val::vector(&[0.0; 4])),
|
||||
_ => Some(Val::scalar(1.0)),
|
||||
};
|
||||
let x: Vec<f64> = c.eval(0.016, &builtin).iter().map(|v| v.v[0]).collect();
|
||||
assert_eq!(
|
||||
x,
|
||||
[2.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0, 0.0, 6.0, 2.0, 2.0, 2.0]
|
||||
);
|
||||
assert_eq!(c.notes.len(), 9, "{:?}", c.notes);
|
||||
// The same on later frames, with no new notes.
|
||||
let again: Vec<f64> = c.eval(0.016, &builtin).iter().map(|v| v.v[0]).collect();
|
||||
assert_eq!(again, x);
|
||||
assert_eq!(c.notes.len(), 9, "{:?}", c.notes);
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,7 @@ pub use lang::Lang;
|
||||
pub use macros::{MC_VERSION, Macros, RENDER_STAGES, render_stage};
|
||||
pub use options::{
|
||||
PackOption, apply as apply_options, chosen_file, chosen_text, options, parse_chosen,
|
||||
read_properties, switches_on, value_options, with_options,
|
||||
properties_macros, read_properties, switches_on, value_options, with_options,
|
||||
};
|
||||
pub use pack::{Pack, PackError};
|
||||
pub use post::{
|
||||
|
||||
@@ -20,6 +20,11 @@ const FEATURES: [&str; 8] = [
|
||||
"SSBO",
|
||||
];
|
||||
|
||||
/// Whether `name` is one of [`FEATURES`], in any case.
|
||||
fn is_feature(name: &str) -> bool {
|
||||
FEATURES.iter().any(|f| f.eq_ignore_ascii_case(name))
|
||||
}
|
||||
|
||||
/// `MC_VERSION` of the version Horizon plays: 26.3 → 260300.
|
||||
pub const MC_VERSION: u32 = mc_version(horizon_core::version::VERSION_ID);
|
||||
|
||||
@@ -138,8 +143,20 @@ impl Macros {
|
||||
self.define("MC_MIPMAP_LEVEL", mipmap_levels.to_string());
|
||||
}
|
||||
|
||||
/// Every feature Horizon has, as `IRIS_FEATURE_<name>`: what `shaders.properties` itself is
|
||||
/// read with. Iris 1.11.7 defines a feature it has there whether the pack lists it as
|
||||
/// optional, as required or not at all (probe pack), while its programs see only the optional
|
||||
/// ones (`define_features`).
|
||||
pub fn define_every_feature(&mut self) {
|
||||
for feature in FEATURES {
|
||||
self.define(format!("IRIS_FEATURE_{feature}"), "");
|
||||
}
|
||||
}
|
||||
|
||||
/// `iris.features.optional`'s features Horizon has, as `IRIS_FEATURE_<name>`; a note for
|
||||
/// each feature `iris.features.required` names that it lacks.
|
||||
/// each feature `iris.features.required` names that it lacks. As in Iris 1.11.7, a name
|
||||
/// matches in any case, and the macro keeps the name as the pack wrote it (`ssbo` defines
|
||||
/// `IRIS_FEATURE_ssbo`).
|
||||
pub fn define_features(&mut self, properties: &crate::Properties) -> Vec<String> {
|
||||
let words = |key: &str| {
|
||||
properties
|
||||
@@ -148,13 +165,13 @@ impl Macros {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
for feature in words("iris.features.optional") {
|
||||
if FEATURES.contains(&feature.as_str()) {
|
||||
if is_feature(&feature) {
|
||||
self.define(format!("IRIS_FEATURE_{feature}"), "");
|
||||
}
|
||||
}
|
||||
words("iris.features.required")
|
||||
.into_iter()
|
||||
.filter(|f| !FEATURES.contains(&f.as_str()))
|
||||
.filter(|f| !is_feature(f))
|
||||
.map(|f| format!("the pack requires `{f}`, which is not supported yet"))
|
||||
.collect()
|
||||
}
|
||||
@@ -164,6 +181,32 @@ impl Macros {
|
||||
self.0.insert(name.into(), Macro { params: None, body });
|
||||
}
|
||||
|
||||
/// The rest of a `#define` line: `NAME body`, or `NAME(a, b) body` (no space before the `(`)
|
||||
/// for a function-like macro.
|
||||
pub(crate) fn define_line(&mut self, rest: &str) -> Result<(), String> {
|
||||
let len = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(len);
|
||||
if name.is_empty() {
|
||||
return Err("`#define` without a name".into());
|
||||
}
|
||||
if let Some(params) = tail.strip_prefix('(') {
|
||||
let Some((params, body)) = params.split_once(')') else {
|
||||
return Err(format!("`#define {name}(` without `)`"));
|
||||
};
|
||||
let params = params
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
self.define_function(name, params, body.trim());
|
||||
} else {
|
||||
self.define(name, tail.trim());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn define_function(&mut self, name: &str, params: Vec<String>, body: &str) {
|
||||
let body = body.to_owned();
|
||||
self.0.insert(
|
||||
@@ -459,6 +502,32 @@ mod tests {
|
||||
assert!(m.is_defined("IRIS_FEATURE_HIGHER_SHADOWCOLOR") && notes.is_empty());
|
||||
}
|
||||
|
||||
/// Iris 1.11.7, with probe packs of our own (#8996): a feature name matches in any case, and
|
||||
/// an optional one defines the macro with the name as written, not in upper case.
|
||||
#[test]
|
||||
fn feature_names_match_in_any_case() {
|
||||
let mut m = Macros::standard();
|
||||
let notes = m.define_features(&crate::Properties::parse(
|
||||
"iris.features.optional = ssbo Custom_Images\n\
|
||||
iris.features.required = compute_shaders ssbo custom_images Not_A_Feature\n",
|
||||
&Macros::standard(),
|
||||
));
|
||||
assert!(m.is_defined("IRIS_FEATURE_ssbo"));
|
||||
assert!(m.is_defined("IRIS_FEATURE_Custom_Images"));
|
||||
for absent in [
|
||||
"IRIS_FEATURE_SSBO",
|
||||
"IRIS_FEATURE_CUSTOM_IMAGES",
|
||||
"IRIS_FEATURE_compute_shaders",
|
||||
"IRIS_FEATURE_COMPUTE_SHADERS",
|
||||
] {
|
||||
assert!(!m.is_defined(absent), "{absent}");
|
||||
}
|
||||
assert_eq!(
|
||||
notes,
|
||||
["the pack requires `Not_A_Feature`, which is not supported yet"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions_follow_the_documented_rule() {
|
||||
assert_eq!(mc_version("1.21"), 12100);
|
||||
|
||||
@@ -86,17 +86,26 @@ pub fn with_options(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
|
||||
out
|
||||
}
|
||||
|
||||
/// A pack's `shaders.properties` as the game reads it before the programs: parsed with the
|
||||
/// pack's options over `macros` (the standard set and the renderer's), after which `macros` gets
|
||||
/// the features the pack turns on (`iris.features.*`, `Macros::define_features`). Returns the
|
||||
/// properties and a note for each required feature Horizon lacks. Whatever reads a pack's
|
||||
/// The macros `shaders.properties` is read with: the pack's options over `macros`, and every
|
||||
/// feature Horizon has (`Macros::define_every_feature`), so a custom uniform or a screen behind
|
||||
/// `#ifdef IRIS_FEATURE_<name>` is there as in Iris 1.11.7.
|
||||
pub fn properties_macros(pack: &Pack, macros: &crate::Macros) -> crate::Macros {
|
||||
let mut out = with_options(pack, macros);
|
||||
out.define_every_feature();
|
||||
out
|
||||
}
|
||||
|
||||
/// A pack's `shaders.properties` as the game reads it before the programs: parsed with
|
||||
/// `properties_macros` over `macros` (the standard set and the renderer's), after which `macros`
|
||||
/// gets the features the pack turns on (`iris.features.*`, `Macros::define_features`). Returns
|
||||
/// the properties and a note for each required feature Horizon lacks. Whatever reads a pack's
|
||||
/// programs (the game, the `real_packs` test) reads them with these macros.
|
||||
pub fn read_properties(
|
||||
pack: &Pack,
|
||||
macros: &mut crate::Macros,
|
||||
) -> (crate::Properties, Vec<String>) {
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = crate::Properties::parse(&text, &with_options(pack, macros));
|
||||
let properties = crate::Properties::parse(&text, &properties_macros(pack, macros));
|
||||
let notes = macros.define_features(&properties);
|
||||
(properties, notes)
|
||||
}
|
||||
@@ -424,6 +433,33 @@ iris.features.required = NOT_A_FEATURE
|
||||
);
|
||||
}
|
||||
|
||||
/// `shaders.properties` sees every feature Horizon has, listed or not, as Iris 1.11.7 reads
|
||||
/// it (probe pack, measured there: a custom uniform behind each `#ifdef IRIS_FEATURE_<name>`
|
||||
/// was set for an optional, a required and an unlisted feature, the optional list written on
|
||||
/// the last line). The programs still get only the optional ones, as there.
|
||||
#[test]
|
||||
fn properties_see_every_feature_and_programs_only_the_optional_ones() {
|
||||
let bytes = crate::pack::tests::zip(&[(
|
||||
"shaders/shaders.properties",
|
||||
"iris.features.required = COMPUTE_SHADERS\n\
|
||||
#ifdef IRIS_FEATURE_CUSTOM_IMAGES\nuniform.float.optional = 1.0\n#endif\n\
|
||||
#ifdef IRIS_FEATURE_COMPUTE_SHADERS\nuniform.float.required = 1.0\n#endif\n\
|
||||
#ifdef IRIS_FEATURE_SSBO\nuniform.float.unlisted = 1.0\n#endif\n\
|
||||
#ifdef IRIS_FEATURE_NOT_A_FEATURE\nuniform.float.unknown = 1.0\n#endif\n\
|
||||
iris.features.optional = CUSTOM_IMAGES\n",
|
||||
)]);
|
||||
let pack = Pack::from_zip_bytes("p.zip", bytes).unwrap();
|
||||
let mut macros = crate::Macros::standard();
|
||||
let (properties, notes) = read_properties(&pack, &mut macros);
|
||||
let read = |name: &str| properties.get(&format!("uniform.float.{name}")).is_some();
|
||||
assert!(read("optional") && read("required") && read("unlisted"));
|
||||
assert!(!read("unknown"));
|
||||
assert!(notes.is_empty());
|
||||
assert!(macros.is_defined("IRIS_FEATURE_CUSTOM_IMAGES"));
|
||||
assert!(!macros.is_defined("IRIS_FEATURE_COMPUTE_SHADERS"));
|
||||
assert!(!macros.is_defined("IRIS_FEATURE_SSBO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declared_defaults_remain_selectable_and_apply() {
|
||||
for (source, selected) in [
|
||||
@@ -577,4 +613,44 @@ iris.features.required = NOT_A_FEATURE
|
||||
);
|
||||
assert_eq!(parse_chosen(&chosen_text(&chosen)), chosen);
|
||||
}
|
||||
|
||||
/// A custom uniform's or variable's line has the macros expanded: a value option, a
|
||||
/// `#define` of `shaders.properties` (function-like too), a standard macro, and a macro in
|
||||
/// the uniform's name. Our probe pack's shape; Iris 1.11.7 drew p0..p5 as 127, 191, 64, 255,
|
||||
/// 127 and 255 of 255 with the option at its default.
|
||||
#[test]
|
||||
fn custom_uniform_lines_read_the_options_and_macros() {
|
||||
let bytes = crate::pack::tests::zip(&[
|
||||
("shaders/final.fsh", "#define MY_OPT 2 // [1 2 3]\n"),
|
||||
(
|
||||
"shaders/shaders.properties",
|
||||
"#define PROP_DEF 3\n#define HALF(x) ((x) * 0.5)\n#define KEYNAME p5\n\
|
||||
uniform.float.p0 = MY_OPT * 0.25\n\
|
||||
uniform.float.p1 = PROP_DEF * 0.25\n\
|
||||
variable.float.v2 = MY_OPT * 0.125\nuniform.float.p2 = v2\n\
|
||||
uniform.float.p3 = if(MC_VERSION > 10000, 1.0, 0.0)\n\
|
||||
uniform.float.p4 = HALF(MY_OPT) * 0.5\n\
|
||||
uniform.float.KEYNAME = 1.0\n",
|
||||
),
|
||||
]);
|
||||
for (chosen, p0) in [(None, 0.5), (Some("3"), 0.75)] {
|
||||
let mut pack = Pack::from_zip_bytes("p.zip", bytes.clone()).unwrap();
|
||||
if let Some(value) = chosen {
|
||||
pack = pack.with_chosen(BTreeMap::from([("MY_OPT".into(), value.into())]));
|
||||
}
|
||||
let (properties, _) = read_properties(&pack, &mut crate::Macros::standard());
|
||||
assert!(properties.notes.is_empty(), "{:?}", properties.notes);
|
||||
let mut custom = crate::Custom::parse(&properties);
|
||||
let values: Vec<f64> = custom
|
||||
.eval(0.016, &|_| None)
|
||||
.iter()
|
||||
.map(|v| v.v[0])
|
||||
.collect();
|
||||
let p4 = if chosen.is_some() { 0.75 } else { 0.5 };
|
||||
assert_eq!(values, [p0, 0.75, p0 / 2.0, 1.0, p4, 1.0]);
|
||||
let names: Vec<_> = custom.uniforms().map(|(name, _)| name.to_owned()).collect();
|
||||
assert_eq!(names, ["p0", "p1", "p2", "p3", "p4", "p5"]);
|
||||
assert!(custom.notes.is_empty(), "{:?}", custom.notes);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -154,8 +154,9 @@ const BLOCK: [&str; 5] = [
|
||||
"gbuffers_basic",
|
||||
];
|
||||
|
||||
/// `gbuffers_clouds`: no fallback, the game draws its clouds otherwise (OQ-3).
|
||||
const CLOUDS: [&str; 1] = ["gbuffers_clouds"];
|
||||
/// `gbuffers_clouds` and its fallbacks, as in Iris (its ShaderDoc's gbuffers table): without
|
||||
/// any of them the game draws its clouds.
|
||||
const CLOUDS: [&str; 3] = ["gbuffers_clouds", "gbuffers_textured", "gbuffers_basic"];
|
||||
|
||||
/// `gbuffers_hand` and its fallbacks (2.3).
|
||||
const HAND: [&str; 4] = [
|
||||
@@ -872,7 +873,6 @@ pub fn load_post(
|
||||
inputs.entities.as_ref(),
|
||||
[&inputs.terrain_geometry, &inputs.entity_geometry].map(Option::as_ref),
|
||||
);
|
||||
post.config = std::mem::take(&mut post.config).finish();
|
||||
post
|
||||
}
|
||||
|
||||
@@ -918,6 +918,19 @@ fn load_shadow(
|
||||
let on = forced.unwrap_or(reads);
|
||||
loader.post.shadow_pass = on;
|
||||
if !on {
|
||||
// Observed in probe packs of ours (p9063a, p9063c), not a rule Iris states: Iris 1.11.7
|
||||
// honoured a `colortexNFormat` declared only in `shadow.fsh` or `shadowcomp.fsh` of a
|
||||
// pack Horizon runs without a shadow pass. So the colortex formats of the shadow
|
||||
// programs' fragment shaders are read here, and nothing else of them (nothing else was
|
||||
// probed). The other shadow programs' files were not probed; they are read as `shadow`.
|
||||
let names = ["shadow", "shadow_water", "shadow_entities", "shadow_block"]
|
||||
.map(str::to_owned)
|
||||
.into_iter()
|
||||
.chain(std::iter::once("shadowcomp".to_owned()))
|
||||
.chain((1..=99).map(|n| format!("shadowcomp{n}")));
|
||||
for name in names {
|
||||
loader.read_formats(&name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let names =
|
||||
@@ -1102,7 +1115,7 @@ impl Loader<'_> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.post.config.read(&vertex);
|
||||
self.post.config.read_vertex(&vertex);
|
||||
self.post.config.read(&fragment);
|
||||
let mut buffers =
|
||||
config::draw_buffers(&fragment).unwrap_or_else(|| default_draw_buffers(group.shadow()));
|
||||
@@ -1149,6 +1162,28 @@ impl Loader<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads the colortex formats of program `name` without loading it, when it is there and
|
||||
/// enabled: those of its fragment shader, as [`Self::load`] reads them.
|
||||
fn read_formats(&mut self, name: &str) {
|
||||
let path = |ext: &str| match self.folder {
|
||||
"" => format!("{name}.{ext}"),
|
||||
f => format!("{f}/{name}.{ext}"),
|
||||
};
|
||||
let (vsh, fsh) = (path("vsh"), path("fsh"));
|
||||
let on = enabled(
|
||||
self.properties,
|
||||
self.folder,
|
||||
name,
|
||||
&self.options,
|
||||
&self.switches,
|
||||
);
|
||||
if !on || !self.pack.exists(&fsh) || !self.pack.exists(&vsh) {
|
||||
return;
|
||||
}
|
||||
let fragment = self.preprocess(&fsh);
|
||||
self.post.config.read_formats(&fragment);
|
||||
}
|
||||
|
||||
/// Stage file `path` through the preprocessor; the pack's images declared without a format
|
||||
/// (GLSL lets a write-only image omit it) get the one `image.*` gives them.
|
||||
fn preprocess(&self, path: &str) -> Preprocessed {
|
||||
@@ -1228,7 +1263,7 @@ impl Loader<'_> {
|
||||
return None;
|
||||
}
|
||||
};
|
||||
self.post.config.read(vertex);
|
||||
self.post.config.read_vertex(vertex);
|
||||
self.post.config.read(fragment);
|
||||
let mut buffers =
|
||||
config::draw_buffers(fragment).unwrap_or_else(|| default_draw_buffers(true));
|
||||
@@ -1424,12 +1459,15 @@ fn scale(properties: &Properties, program: &str, group: Group) -> [f32; 3] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack names the `HIGHER_SHADOWCOLOR` feature, required or optional.
|
||||
/// Whether the pack names the `HIGHER_SHADOWCOLOR` feature, required or optional, in any case.
|
||||
fn higher_shadowcolor(properties: &Properties) -> bool {
|
||||
["iris.features.required", "iris.features.optional"]
|
||||
.iter()
|
||||
.filter_map(|k| properties.get(k))
|
||||
.any(|v| v.split_whitespace().any(|w| w == "HIGHER_SHADOWCOLOR"))
|
||||
.any(|v| {
|
||||
v.split_whitespace()
|
||||
.any(|w| w.eq_ignore_ascii_case("HIGHER_SHADOWCOLOR"))
|
||||
})
|
||||
}
|
||||
|
||||
/// `flip.<program>.<buffer>=<bool>` keys of one program.
|
||||
@@ -2259,6 +2297,12 @@ blend.composite1.colortex3 = ONE ONE ONE ONE
|
||||
let optional = "iris.features.optional = HIGHER_SHADOWCOLOR\n";
|
||||
let post = load(optional, Some("0,1,2,3,4"));
|
||||
assert_eq!(buffers(&post).1, Some(vec![0, 1, 2, 3, 4]));
|
||||
// Iris 1.11.7 takes the name in any case (#8996, a probe pack of our own).
|
||||
let post = load(
|
||||
"iris.features.optional = higher_shadowcolor\n",
|
||||
Some("0,1,2,3,4"),
|
||||
);
|
||||
assert_eq!(buffers(&post).1, Some(vec![0, 1, 2, 3, 4]));
|
||||
// No directive: shadowcolor0 and 1, as without the feature.
|
||||
let post = load(required, None);
|
||||
assert_eq!(buffers(&post).1, Some(vec![0, 1]));
|
||||
@@ -2371,6 +2415,53 @@ blend.composite1.colortex3 = ONE ONE ONE ONE
|
||||
assert!(off.clouds.is_none() && off.clouds_off);
|
||||
}
|
||||
|
||||
/// Without `gbuffers_clouds` the clouds draw with `gbuffers_textured`, else `gbuffers_basic`,
|
||||
/// blended; a pack with none of them leaves the clouds to the game.
|
||||
#[test]
|
||||
fn clouds_fall_back_to_textured_then_basic() {
|
||||
let vsh = "#version 120\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
let fsh = "#version 120\nvoid main() { gl_FragColor = vec4(1.0); }\n";
|
||||
let macros = Macros::standard();
|
||||
let inputs = GameInputs {
|
||||
clouds: Some(crate::GbuffersInputs {
|
||||
declarations: "layout(location = 0) in vec3 hz_v_pos;".into(),
|
||||
body: " hz_Vertex = vec4(hz_v_pos, 1.0);\n".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let clouds = |programs: &[&str]| {
|
||||
let files: Vec<(String, &str)> = programs
|
||||
.iter()
|
||||
.flat_map(|p| {
|
||||
[
|
||||
(format!("shaders/{p}.vsh"), vsh),
|
||||
(format!("shaders/{p}.fsh"), fsh),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(n, t)| (n.as_str(), *t)).collect();
|
||||
let pack = Pack::from_zip_bytes("clouds.zip", crate::pack::tests::zip(&files)).unwrap();
|
||||
load_post(&pack, "", &Properties::default(), ¯os, &inputs)
|
||||
.clouds
|
||||
.map(|p| (p.name, p.blend))
|
||||
};
|
||||
let blended = |name: &str| Some((name.to_owned(), Some(TRANSLUCENT_BLEND)));
|
||||
assert_eq!(
|
||||
clouds(&["gbuffers_textured", "gbuffers_basic"]),
|
||||
blended("gbuffers_textured")
|
||||
);
|
||||
assert_eq!(
|
||||
clouds(&["gbuffers_textured_lit", "gbuffers_basic"]),
|
||||
blended("gbuffers_basic")
|
||||
);
|
||||
assert_eq!(
|
||||
clouds(&["gbuffers_clouds", "gbuffers_textured"]),
|
||||
blended("gbuffers_clouds")
|
||||
);
|
||||
assert_eq!(clouds(&["gbuffers_terrain", "composite"]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn water_falls_back_to_terrain_and_blends() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/terrain-test");
|
||||
@@ -2439,8 +2530,12 @@ blend.composite1.colortex3 = ONE ONE ONE ONE
|
||||
);
|
||||
assert_eq!(particles.blend, Some(TRANSLUCENT_BLEND));
|
||||
assert!(post.particles_late && !post.translucent_late);
|
||||
// gbuffers_clouds has no fallback (OQ-3): without it the game draws its clouds.
|
||||
assert!(post.clouds.is_none());
|
||||
// Clouds fall back to gbuffers_textured, blended.
|
||||
let clouds = post.clouds.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
(clouds.name.as_str(), clouds.blend),
|
||||
("gbuffers_textured", Some(TRANSLUCENT_BLEND))
|
||||
);
|
||||
// The hand falls back to gbuffers_textured and does not blend.
|
||||
let hand = post.hand.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
@@ -2476,6 +2571,67 @@ blend.composite1.colortex3 = ONE ONE ONE ONE
|
||||
assert_eq!(blend(&properties, "gbuffers_basic"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_come_from_the_fragment_shaders_iris_reads() {
|
||||
// Cut down from probe packs p9063a, p9063b and p9063c (ours). Iris 1.11.7: colortex8,
|
||||
// declared only in `composite.vsh`, kept RGBA8; colortex12 and colortex6, declared only
|
||||
// in `shadow.fsh` and `shadowcomp.fsh` of a pack whose shadow pass Horizon doesn't run,
|
||||
// took RGBA16F; colortex1 with `gdepth` declared and no format kept RGBA8. Other
|
||||
// settings of those shadow files weren't probed, and Horizon leaves them out.
|
||||
let declare = |n: u32| format!("/*\nconst int colortex{n}Format = RGBA16F;\n*/\n");
|
||||
let write = "#version 120\n/* RENDERTARGETS: 1,6,8,12 */\nvoid main() { gl_FragData[0] = \
|
||||
vec4(2.5); gl_FragData[1] = vec4(2.5); gl_FragData[2] = vec4(2.5); \
|
||||
gl_FragData[3] = vec4(2.5); }\n";
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"original-format-files",
|
||||
zip(&[
|
||||
(
|
||||
"shaders/composite.vsh",
|
||||
format!("{}{VSH}", declare(8)).as_str(),
|
||||
),
|
||||
("shaders/composite.fsh", write),
|
||||
("shaders/shadow.vsh", VSH),
|
||||
(
|
||||
"shaders/shadow.fsh",
|
||||
format!(
|
||||
"{}const bool colortex12Clear = false;\n{write}",
|
||||
declare(12)
|
||||
)
|
||||
.as_str(),
|
||||
),
|
||||
("shaders/shadowcomp.vsh", VSH),
|
||||
(
|
||||
"shaders/shadowcomp.fsh",
|
||||
format!("{}{write}", declare(6)).as_str(),
|
||||
),
|
||||
("shaders/final.vsh", VSH),
|
||||
(
|
||||
"shaders/final.fsh",
|
||||
"#version 120\nuniform sampler2D gdepth;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor = texture2D(gdepth, uv); }\n",
|
||||
),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let post = load_post(
|
||||
&pack,
|
||||
"",
|
||||
&Properties::default(),
|
||||
¯os,
|
||||
&GameInputs::default(),
|
||||
);
|
||||
assert!(!post.shadow_pass);
|
||||
let half = crate::Format::parse("RGBA16F").unwrap();
|
||||
let formats: Vec<bool> = [1, 6, 8, 12]
|
||||
.iter()
|
||||
.map(|&n| post.config.buffers[n].format == half)
|
||||
.collect();
|
||||
assert_eq!(formats, [false, true, false, true]);
|
||||
assert_eq!(post.config.buffers[1].format, crate::Format::RGBA8);
|
||||
assert!(post.config.buffers[12].clear);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizons_test_pack_loads() {
|
||||
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/packs/post-test");
|
||||
|
||||
@@ -215,27 +215,9 @@ impl Run<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `#define NAME body` or `#define NAME(a, b) body` (no space before the `(`).
|
||||
fn define(&mut self, rest: &str, origin: Origin) {
|
||||
let len = rest
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.unwrap_or(rest.len());
|
||||
let (name, tail) = rest.split_at(len);
|
||||
if name.is_empty() {
|
||||
return self.diagnose(origin, "`#define` without a name");
|
||||
}
|
||||
if let Some(params) = tail.strip_prefix('(') {
|
||||
let Some((params, body)) = params.split_once(')') else {
|
||||
return self.diagnose(origin, &format!("`#define {name}(` without `)`"));
|
||||
};
|
||||
let params = params
|
||||
.split(',')
|
||||
.map(|p| p.trim().to_owned())
|
||||
.filter(|p| !p.is_empty())
|
||||
.collect();
|
||||
self.macros.define_function(name, params, body.trim());
|
||||
} else {
|
||||
self.macros.define(name, tail.trim());
|
||||
if let Err(e) = self.macros.define_line(rest) {
|
||||
self.diagnose(origin, &e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//! `item.properties`, `entity.properties` (4.1). `key=value` lines, `#` comments, a trailing `\`
|
||||
//! continuing a value on the next line, and conditional directives evaluated with the
|
||||
//! standard and option macros (1.5 item 2). `#define` and `#undef` work in every one of them:
|
||||
//! a public pack defines a macro in its `shaders.properties` and tests it further down.
|
||||
//! a public pack defines a macro in its `shaders.properties` and tests it further down. The
|
||||
//! lines of custom uniforms and variables (`uniform.*`, `variable.*`) have those macros expanded.
|
||||
//! No other escape is read: values are names, numbers and expressions. A later duplicate key
|
||||
//! replaces the earlier one, with a note.
|
||||
|
||||
@@ -71,11 +72,21 @@ impl Properties {
|
||||
continue;
|
||||
}
|
||||
match logical.split_once('=') {
|
||||
Some((key, value)) if !key.trim().is_empty() => out.insert(Entry {
|
||||
key: key.trim().to_owned(),
|
||||
value: value.trim().to_owned(),
|
||||
line: number,
|
||||
}),
|
||||
Some((key, value)) if !key.trim().is_empty() => {
|
||||
let (mut key, mut value) = (key.trim().to_owned(), value.trim().to_owned());
|
||||
// A custom uniform's or variable's line is read with the macros expanded, as
|
||||
// Iris 1.11.7 reads it (probe pack): options, standard macros and this
|
||||
// file's own `#define`s, in the name too.
|
||||
if key.starts_with("uniform.") || key.starts_with("variable.") {
|
||||
key = macros.expand(&key).trim().to_owned();
|
||||
value = macros.expand(&value).trim().to_owned();
|
||||
}
|
||||
out.insert(Entry {
|
||||
key,
|
||||
value,
|
||||
line: number,
|
||||
})
|
||||
}
|
||||
_ => out.note(number, "not a key=value line"),
|
||||
}
|
||||
}
|
||||
@@ -129,8 +140,9 @@ impl Properties {
|
||||
Ok(true) => {}
|
||||
Ok(false) if conditionals.active() => match word {
|
||||
"define" => {
|
||||
let (name, value) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||||
macros.define(name, value.trim());
|
||||
if let Err(e) = macros.define_line(rest) {
|
||||
self.note(number, &e);
|
||||
}
|
||||
}
|
||||
"undef" => macros.undef(rest),
|
||||
_ => {}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
//! Atomic functions on a component of a shared integer vector (`atomicAdd(sums[i], n)`,
|
||||
//! `atomicMax(lights[k].w, w)`): GLSL allows them, naga has no atomic vectors. Such a vector
|
||||
//! (or array of vectors) becomes an array of its components, `shared int sums_hz[4]`; a
|
||||
//! component is an element, a whole vector is read through `hz_get_sums()` and written through
|
||||
//! `hz_set_sums(v)`. The source keeps its lines.
|
||||
//! Atomic functions on a component of an integer vector in shared memory or in a buffer block
|
||||
//! (`atomicAdd(sums[i], n)`, `atomicMax(lights[k].w, w)`, `atomicAdd(buf.counts.x, 1u)`): GLSL
|
||||
//! allows them (GLSL 4.60, 8.11), naga has no atomic vectors. Such a vector (or array of vectors)
|
||||
//! becomes an array of its components, `shared int sums_hz[4]`, `uint counts_hz[2];` in the
|
||||
//! block; a component is an element, a whole vector is read through `hz_get_sums()` and written
|
||||
//! through `hz_set_sums(v)`. A `std430` buffer block keeps its layout: a padding member goes
|
||||
//! before the array when the vector's alignment would have moved it, and an array of 3-component
|
||||
//! vectors keeps a fourth, unused element per vector (its stride). Other layouts, runtime-sized
|
||||
//! arrays and blocks whose layout is not known up to the vector stay as written. The source
|
||||
//! keeps its lines.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::Write;
|
||||
@@ -22,15 +27,70 @@ const ATOMICS: [&str; 8] = [
|
||||
"atomicCompSwap",
|
||||
];
|
||||
|
||||
/// A shared vector rewritten: its scalar and vector types, size, and array length if any.
|
||||
/// A vector rewritten: its type, size, array length if any, the elements'
|
||||
/// name (`sums_hz`, `buf.counts_hz`) and the name of its getter and setter (`hz_get_<key>`).
|
||||
struct Split {
|
||||
scalar: &'static str,
|
||||
vector: String,
|
||||
size: usize,
|
||||
array: Option<String>,
|
||||
base: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// `pp` with its shared vectors that atomic functions use by component rewritten.
|
||||
/// How a rewritten vector is named where it is used: a name of its own (a shared variable, a
|
||||
/// member of a block without an instance name), or `instance.member`.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum Path<'a> {
|
||||
Name(&'a str),
|
||||
Member(&'a str, &'a str),
|
||||
}
|
||||
|
||||
/// `ivecN`/`uvecN`: the scalar type and N.
|
||||
fn integer_vector(ty: &str) -> Option<(&'static str, usize)> {
|
||||
let (scalar, n) = match ty {
|
||||
v if v.starts_with("ivec") => ("int", &v[4..]),
|
||||
v if v.starts_with("uvec") => ("uint", &v[4..]),
|
||||
_ => return None,
|
||||
};
|
||||
n.parse()
|
||||
.ok()
|
||||
.filter(|n| (2..=4).contains(n))
|
||||
.map(|n| (scalar, n))
|
||||
}
|
||||
|
||||
/// A member type's std430 base alignment and size in bytes (the rules Horizon's naga applies in
|
||||
/// `front/glsl/offset.rs`), for the scalars, vectors and float matrices; `None` for any other
|
||||
/// type.
|
||||
fn std430(ty: &str) -> Option<(u32, u32)> {
|
||||
let vector = |n: u32| match n {
|
||||
2 => Some((8, 8)),
|
||||
3 => Some((16, 12)),
|
||||
4 => Some((16, 16)),
|
||||
_ => None,
|
||||
};
|
||||
match ty {
|
||||
"int" | "uint" | "float" | "bool" => Some((4, 4)),
|
||||
_ if ty.starts_with("mat") => {
|
||||
let dims = &ty[3..];
|
||||
let (columns, rows) = match dims.split_once('x') {
|
||||
Some((c, r)) => (c.parse().ok()?, r.parse().ok()?),
|
||||
None => (dims.parse().ok()?, dims.parse().ok()?),
|
||||
};
|
||||
let (align, _) = vector(rows)?;
|
||||
vector(columns)?;
|
||||
Some((align, align * columns))
|
||||
}
|
||||
_ => {
|
||||
let n = ty
|
||||
.strip_prefix(['i', 'u', 'b'])
|
||||
.unwrap_or(ty)
|
||||
.strip_prefix("vec")?;
|
||||
vector(n.parse().ok()?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `pp` with its shared and buffer vectors that atomic functions use by component rewritten.
|
||||
pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
if !ATOMICS.iter().any(|a| pp.code.contains(a)) {
|
||||
return None;
|
||||
@@ -44,30 +104,32 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
let size: String = tokens[open + 1..close].concat();
|
||||
Some((size.trim().to_owned(), next(close)))
|
||||
};
|
||||
// The variables an atomic function takes a component of.
|
||||
let mut used = Vec::new();
|
||||
// What an atomic function takes a component of: its first name, and the name after a `.`.
|
||||
let mut used: Vec<(&str, Option<&str>)> = Vec::new();
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if ATOMICS.contains(&t)
|
||||
&& let Some(open) = next(i).filter(|&j| tokens[j] == "(")
|
||||
&& let Some(name) = next(open).filter(|&j| is_ident(tokens[j]))
|
||||
{
|
||||
used.push(tokens[name]);
|
||||
let member = next(name)
|
||||
.filter(|&j| tokens[j] == ".")
|
||||
.and_then(next)
|
||||
.filter(|&j| is_ident(tokens[j]))
|
||||
.map(|j| tokens[j]);
|
||||
used.push((tokens[name], member));
|
||||
}
|
||||
}
|
||||
// Their declarations: `shared ivec4 name;`, `shared ivec4[N] name;`, `shared ivec4 name[N];`.
|
||||
let mut splits: BTreeMap<&str, Split> = BTreeMap::new();
|
||||
let mut decl_ends = Vec::new();
|
||||
let mut splits: BTreeMap<Path, Split> = BTreeMap::new();
|
||||
// Declarations replaced (tokens `start..=end` by the text), and text after a token.
|
||||
let mut decls: Vec<(usize, usize, String)> = Vec::new();
|
||||
let mut after: Vec<(usize, String)> = Vec::new();
|
||||
// Shared declarations: `shared ivec4 name;`, `shared ivec4[N] name;`, `shared ivec4 name[N];`.
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if t != "shared" {
|
||||
continue;
|
||||
}
|
||||
let Some(ty) = next(i) else { continue };
|
||||
let (scalar, size) = match tokens[ty] {
|
||||
v if v.starts_with("ivec") => ("int", &v[4..]),
|
||||
v if v.starts_with("uvec") => ("uint", &v[4..]),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(size) = size.parse::<usize>() else {
|
||||
let Some((scalar, size)) = integer_vector(tokens[ty]) else {
|
||||
continue;
|
||||
};
|
||||
let mut j = next(ty);
|
||||
@@ -88,60 +150,168 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
let Some(end) = end.filter(|&e| tokens[e] == ";") else {
|
||||
continue;
|
||||
};
|
||||
if !used.contains(&name) {
|
||||
if !used.iter().any(|u| u.0 == name) {
|
||||
continue;
|
||||
}
|
||||
splits.insert(
|
||||
name,
|
||||
Split {
|
||||
scalar,
|
||||
vector: tokens[ty].to_owned(),
|
||||
let elements = match &array {
|
||||
None => format!("{name}_hz[{size}]"),
|
||||
Some(len) => format!("{name}_hz[{len}][{size}]"),
|
||||
};
|
||||
let split = Split {
|
||||
vector: tokens[ty].to_owned(),
|
||||
size,
|
||||
array,
|
||||
base: format!("{name}_hz"),
|
||||
key: name.to_owned(),
|
||||
};
|
||||
let decl = format!("shared {scalar} {elements}; {}", accessors(&split));
|
||||
decls.push((i, end, decl));
|
||||
splits.insert(Path::Name(name), split);
|
||||
}
|
||||
// Buffer blocks: `layout(std430, ...) buffer Name { members } instance;`.
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if t != "buffer" {
|
||||
continue;
|
||||
}
|
||||
let Some(open) = next(i)
|
||||
.filter(|&j| is_ident(tokens[j]))
|
||||
.and_then(next)
|
||||
.filter(|&j| tokens[j] == "{")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
// The declaration's qualifiers, back to the previous declaration.
|
||||
let first = (0..i)
|
||||
.rev()
|
||||
.find(|&j| matches!(tokens[j], ";" | "}"))
|
||||
.map_or(0, |j| j + 1);
|
||||
if !tokens[first..i].contains(&"std430") {
|
||||
continue;
|
||||
}
|
||||
let Some(close) = (open..tokens.len()).find(|&j| tokens[j] == "}") else {
|
||||
continue;
|
||||
};
|
||||
let (instance, end) = match next(close) {
|
||||
Some(j) if tokens[j] == ";" => (None, j),
|
||||
Some(j) if is_ident(tokens[j]) && next(j).is_some_and(|k| tokens[k] == ";") => {
|
||||
(Some(tokens[j]), next(j).unwrap())
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
// The members in order, with their std430 offsets while they are known.
|
||||
let mut offset = Some(0u32);
|
||||
let mut from = open + 1;
|
||||
for semi in (open + 1..close).filter(|&j| tokens[j] == ";") {
|
||||
let words: Vec<usize> = (from..semi).filter(|&j| significant(tokens[j])).collect();
|
||||
from = semi + 1;
|
||||
let words: Vec<usize> = words
|
||||
.into_iter()
|
||||
.skip_while(|&j| {
|
||||
matches!(
|
||||
tokens[j],
|
||||
"highp"
|
||||
| "mediump"
|
||||
| "lowp"
|
||||
| "coherent"
|
||||
| "volatile"
|
||||
| "restrict"
|
||||
| "readonly"
|
||||
| "writeonly"
|
||||
| "precise"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let [ty, name_at, ref rest @ ..] = words[..] else {
|
||||
offset = None;
|
||||
continue;
|
||||
};
|
||||
let (ty, name) = (tokens[ty], tokens[name_at]);
|
||||
// One declarator, with at most one array size. A runtime-sized array (`[]`, last)
|
||||
// stays as written: naga rejected the array of arrays it would become.
|
||||
let array = match rest.iter().map(|&j| tokens[j]).collect::<Vec<_>>()[..] {
|
||||
[] => None,
|
||||
["[", ref len @ .., "]"]
|
||||
if !len.is_empty() && !len.contains(&"[") && !len.contains(&",") =>
|
||||
{
|
||||
Some(len.concat())
|
||||
}
|
||||
_ => {
|
||||
offset = None;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let layout = std430(ty);
|
||||
let end_before = offset;
|
||||
let at = offset
|
||||
.zip(layout)
|
||||
.map(|(o, (align, _))| o.next_multiple_of(align));
|
||||
offset = at.zip(layout).and_then(|(at, (align, size))| match &array {
|
||||
None => Some(at + size),
|
||||
Some(len) => Some(at + len.parse::<u32>().ok()? * size.next_multiple_of(align)),
|
||||
});
|
||||
let Some((scalar, size)) = integer_vector(ty) else {
|
||||
continue;
|
||||
};
|
||||
let path = match instance {
|
||||
Some(instance) => Path::Member(instance, name),
|
||||
None => Path::Name(name),
|
||||
};
|
||||
let is_used = used.iter().any(|&u| match path {
|
||||
Path::Member(instance, member) => u == (instance, Some(member)),
|
||||
Path::Name(name) => u.0 == name,
|
||||
});
|
||||
let Some(at) = at.filter(|_| is_used) else {
|
||||
continue;
|
||||
};
|
||||
// The bytes the vector's alignment skips, which the elements' does not.
|
||||
let pad = end_before.map_or(0, |e| (at - e) / 4);
|
||||
let mut decl = String::new();
|
||||
if pad > 0 {
|
||||
write!(decl, "uint hz_pad_{name}[{pad}]; ").unwrap();
|
||||
}
|
||||
// In an array, a 3-component vector takes 16 bytes: four elements.
|
||||
let width = if array.is_some() && size == 3 {
|
||||
4
|
||||
} else {
|
||||
size
|
||||
};
|
||||
match &array {
|
||||
None => write!(decl, "{scalar} {name}_hz[{width}];"),
|
||||
Some(len) => write!(decl, "{scalar} {name}_hz[{len}][{width}];"),
|
||||
}
|
||||
.unwrap();
|
||||
let base = match instance {
|
||||
Some(instance) => format!("{instance}.{name}_hz"),
|
||||
None => format!("{name}_hz"),
|
||||
};
|
||||
let key = match instance {
|
||||
Some(instance) => format!("{instance}_{name}"),
|
||||
None => name.to_owned(),
|
||||
};
|
||||
let split = Split {
|
||||
vector: ty.to_owned(),
|
||||
size,
|
||||
array,
|
||||
},
|
||||
);
|
||||
decl_ends.push((i, end, name));
|
||||
base,
|
||||
key,
|
||||
};
|
||||
after.push((end, format!(" {}", accessors(&split))));
|
||||
decls.push((words[0], semi, decl));
|
||||
splits.insert(path, split);
|
||||
}
|
||||
}
|
||||
if splits.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out: Vec<String> = tokens.iter().map(|t| t.to_string()).collect();
|
||||
for &(start, end, name) in &decl_ends {
|
||||
let s = &splits[name];
|
||||
let (v, sc, n) = (&s.vector, s.scalar, s.size);
|
||||
let comps = |base: &str| {
|
||||
(0..n)
|
||||
.map(|c| format!("{base}[{c}]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
};
|
||||
let sets = |base: &str| {
|
||||
(0..n)
|
||||
.map(|c| format!("{base}[{c}] = v[{c}];"))
|
||||
.collect::<String>()
|
||||
};
|
||||
let mut decl = String::new();
|
||||
match &s.array {
|
||||
None => write!(
|
||||
decl,
|
||||
"shared {sc} {name}_hz[{n}]; {v} hz_get_{name}() {{ return {v}({}); }} \
|
||||
void hz_set_{name}({v} v) {{ {} }}",
|
||||
comps(&format!("{name}_hz")),
|
||||
sets(&format!("{name}_hz"))
|
||||
),
|
||||
Some(len) => write!(
|
||||
decl,
|
||||
"shared {sc} {name}_hz[{len}][{n}]; {v} hz_get_{name}(int k) {{ return {v}({}); }} \
|
||||
void hz_set_{name}(int k, {v} v) {{ {} }}",
|
||||
comps(&format!("{name}_hz[k]")),
|
||||
sets(&format!("{name}_hz[k]"))
|
||||
),
|
||||
}
|
||||
.unwrap();
|
||||
for t in &mut out[start..=end] {
|
||||
for (start, end, decl) in &decls {
|
||||
for t in &mut out[*start..=*end] {
|
||||
*t = t.chars().filter(|&c| c == '\n').collect();
|
||||
}
|
||||
out[start] = decl + &out[start];
|
||||
out[*start] = decl.clone() + &out[*start];
|
||||
}
|
||||
for (at, text) in &after {
|
||||
out[*at].push_str(text);
|
||||
}
|
||||
let component = |s: &str| match s {
|
||||
"x" | "r" | "s" => Some(0),
|
||||
@@ -154,23 +324,40 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
// or the whole vector.
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let name = tokens[i];
|
||||
let Some(s) = splits.get(name) else {
|
||||
let in_decl = decls.iter().any(|&(a, b, _)| (a..=b).contains(&i));
|
||||
let member = next(i)
|
||||
.filter(|&j| tokens[j] == ".")
|
||||
.and_then(next)
|
||||
.filter(|&j| is_ident(tokens[j]));
|
||||
let found = if in_decl || prev(i).is_some_and(|p| tokens[p] == ".") {
|
||||
None
|
||||
} else if let Some(m) = member
|
||||
&& let Some(s) = splits.get(&Path::Member(tokens[i], tokens[m]))
|
||||
{
|
||||
Some((s, m))
|
||||
} else {
|
||||
splits.get(&Path::Name(tokens[i])).map(|s| (s, i))
|
||||
};
|
||||
let Some((s, name_end)) = found else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
if decl_ends.iter().any(|&(a, b, _)| (a..=b).contains(&i))
|
||||
|| prev(i).is_some_and(|p| tokens[p] == ".")
|
||||
{
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut base = format!("{name}_hz");
|
||||
let key = &s.key;
|
||||
let mut base = s.base.clone();
|
||||
let mut getter_args = String::new();
|
||||
let mut last = i;
|
||||
let mut last = name_end;
|
||||
if s.array.is_some() {
|
||||
// `name.length()`: the array's own.
|
||||
if next(name_end).is_some_and(|j| tokens[j] == ".") {
|
||||
out[i] = base;
|
||||
for t in &mut out[i + 1..=name_end] {
|
||||
*t = t.chars().filter(|&c| c == '\n').collect();
|
||||
}
|
||||
i = name_end + 1;
|
||||
continue;
|
||||
}
|
||||
// `name[k]`: the index's tokens, written back as they are.
|
||||
let Some(open) = next(i).filter(|&j| tokens[j] == "[") else {
|
||||
let Some(open) = next(name_end).filter(|&j| tokens[j] == "[") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -225,7 +412,7 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
} else {
|
||||
format!("{getter_args}, ")
|
||||
};
|
||||
out[i] = format!("hz_set_{name}({args}hz_get_{name}({getter_args}) {op} (");
|
||||
out[i] = format!("hz_set_{key}({args}hz_get_{key}({getter_args}) {op} (");
|
||||
for t in &mut out[i + 1..=eq] {
|
||||
*t = t.chars().filter(|&c| c == '\n').collect();
|
||||
}
|
||||
@@ -242,7 +429,7 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
end = at;
|
||||
} else {
|
||||
// A swizzle of several components: read only.
|
||||
let get = format!("hz_get_{name}({getter_args})");
|
||||
let get = format!("hz_get_{key}({getter_args})");
|
||||
replacement = format!("{get}.{}", tokens[at]);
|
||||
end = at;
|
||||
}
|
||||
@@ -255,7 +442,7 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
} else {
|
||||
format!("{getter_args}, ")
|
||||
};
|
||||
out[i] = format!("hz_set_{name}({args}");
|
||||
out[i] = format!("hz_set_{key}({args}");
|
||||
for t in &mut out[i + 1..=eq] {
|
||||
*t = t.chars().filter(|&c| c == '\n').collect();
|
||||
}
|
||||
@@ -263,7 +450,7 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
i = eq + 1;
|
||||
continue;
|
||||
} else {
|
||||
replacement = format!("hz_get_{name}({getter_args})");
|
||||
replacement = format!("hz_get_{key}({getter_args})");
|
||||
}
|
||||
out[i] = replacement;
|
||||
for t in &mut out[i + 1..=end] {
|
||||
@@ -283,6 +470,32 @@ pub(super) fn rewrite(pp: &Preprocessed) -> Option<Preprocessed> {
|
||||
})
|
||||
}
|
||||
|
||||
/// `hz_get_<key>` and `hz_set_<key>`: a whole vector of `split` (of an array: the `k`th),
|
||||
/// read from and written to its elements.
|
||||
fn accessors(split: &Split) -> String {
|
||||
let (v, base, key) = (&split.vector, &split.base, &split.key);
|
||||
let element = if split.array.is_some() {
|
||||
format!("{base}[k]")
|
||||
} else {
|
||||
base.clone()
|
||||
};
|
||||
let comps = (0..split.size)
|
||||
.map(|c| format!("{element}[{c}]"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let sets: String = (0..split.size)
|
||||
.map(|c| format!("{element}[{c}] = v[{c}];"))
|
||||
.collect();
|
||||
let (k, k_comma) = if split.array.is_some() {
|
||||
("int k", "int k, ")
|
||||
} else {
|
||||
("", "")
|
||||
};
|
||||
format!(
|
||||
"{v} hz_get_{key}({k}) {{ return {v}({comps}); }} void hz_set_{key}({k_comma}{v} v) {{ {sets} }}"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! `bool` members of a storage buffer. GLSL gives a `bool` in a `layout(std430)` buffer a size and
|
||||
//! an alignment of 4; naga does not let one into a buffer at all ("not host-shareable"). Such a
|
||||
//! member becomes a `uint` of the same size and every use of it is rewritten: a read is
|
||||
//! `(flag != 0u)`, a plain assignment `flag = uint(...)`. The source keeps its lines.
|
||||
//!
|
||||
//! What the rewrite cannot make sure of is refused with the member's name and its line, rather
|
||||
//! than left for naga's unnamed "Global variable [6] '' is invalid": a compound assignment
|
||||
//! (`flag |= x`), an assignment used as a value, a local or parameter of the same name, a `bool`
|
||||
//! vector, and an instance array of such a buffer.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use super::Error;
|
||||
use super::scan::{is_ident, significant};
|
||||
use crate::Preprocessed;
|
||||
use crate::macros::lex;
|
||||
|
||||
/// A `bool` member: where its type is, and its name.
|
||||
struct Member<'a> {
|
||||
type_at: usize,
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
/// One buffer block with `bool` members.
|
||||
struct Block<'a> {
|
||||
/// The `{` and the `}` of the block.
|
||||
open: usize,
|
||||
close: usize,
|
||||
/// The instance name after the `}`, if the block has one.
|
||||
instance: Option<&'a str>,
|
||||
members: Vec<Member<'a>>,
|
||||
}
|
||||
|
||||
/// `pp` with its buffers' `bool` members rewritten, and what could not be.
|
||||
pub(super) fn rewrite(pp: &Preprocessed) -> (Option<Preprocessed>, Vec<Error>) {
|
||||
if !pp.code.contains("buffer") || !(pp.code.contains("bool") || pp.code.contains("bvec")) {
|
||||
return (None, Vec::new());
|
||||
}
|
||||
let tokens = lex(&pp.code);
|
||||
let next = |i: usize| (i + 1..tokens.len()).find(|&j| significant(tokens[j]));
|
||||
let prev = |i: usize| (0..i).rev().find(|&j| significant(tokens[j]));
|
||||
let line_of = |at: usize| -> Error {
|
||||
let line: usize = tokens[..at.min(tokens.len())]
|
||||
.iter()
|
||||
.map(|t| t.matches('\n').count())
|
||||
.sum();
|
||||
let o = pp.lines.get(line).copied().unwrap_or_default();
|
||||
Error {
|
||||
file: pp.files.get(o.file as usize).cloned().unwrap_or_default(),
|
||||
line: o.line,
|
||||
message: String::new(),
|
||||
}
|
||||
};
|
||||
let mut errors = Vec::new();
|
||||
let mut fail = |at: usize, message: String| {
|
||||
let mut e = line_of(at);
|
||||
e.message = message;
|
||||
errors.push(e);
|
||||
};
|
||||
// The matching close of the bracket at `open`.
|
||||
let matching = |open: usize| -> Option<usize> {
|
||||
let (up, down) = match tokens[open] {
|
||||
"{" => ("{", "}"),
|
||||
"[" => ("[", "]"),
|
||||
_ => ("(", ")"),
|
||||
};
|
||||
let mut depth = 0;
|
||||
for (j, &t) in tokens.iter().enumerate().skip(open) {
|
||||
if t == up {
|
||||
depth += 1;
|
||||
} else if t == down {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(j);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
};
|
||||
|
||||
// The buffer blocks that have `bool` members.
|
||||
let mut blocks: Vec<Block> = Vec::new();
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if t != "buffer" {
|
||||
continue;
|
||||
}
|
||||
let Some(open) = (i + 1..tokens.len())
|
||||
.take_while(|&j| !matches!(tokens[j], ";" | "(" | "}" | "="))
|
||||
.find(|&j| tokens[j] == "{")
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(close) = matching(open) else {
|
||||
continue;
|
||||
};
|
||||
let instance = next(close).filter(|&j| is_ident(tokens[j]));
|
||||
let mut members = Vec::new();
|
||||
// Statements of the block, split at their `;`.
|
||||
let mut start = open + 1;
|
||||
while start < close {
|
||||
let end = (start..close).find(|&j| tokens[j] == ";").unwrap_or(close);
|
||||
let words: Vec<usize> = (start..end).filter(|&j| significant(tokens[j])).collect();
|
||||
start = end + 1;
|
||||
let mut k = 0;
|
||||
// Qualifiers: `layout(...)`, `readonly`...
|
||||
while let Some(&w) = words.get(k) {
|
||||
if tokens[w] == "layout" {
|
||||
k = words
|
||||
.iter()
|
||||
.position(|&x| Some(x) == matching(words[k + 1]))
|
||||
.map_or(words.len(), |p| p + 1);
|
||||
} else if matches!(
|
||||
tokens[w],
|
||||
"readonly" | "writeonly" | "coherent" | "volatile" | "restrict"
|
||||
) || tokens[w].starts_with("highp")
|
||||
{
|
||||
k += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(&ty) = words.get(k) else { continue };
|
||||
let vector = matches!(tokens[ty], "bvec2" | "bvec3" | "bvec4");
|
||||
if tokens[ty] != "bool" && !vector {
|
||||
continue;
|
||||
}
|
||||
// Declarators: `name[N]`, separated by commas; `bool[N] name` puts the size first.
|
||||
let mut at = k + 1;
|
||||
if words.get(at).is_some_and(|&j| tokens[j] == "[")
|
||||
&& let Some(p) = words.iter().position(|&x| Some(x) == matching(words[at]))
|
||||
{
|
||||
at = p + 1;
|
||||
}
|
||||
let mut names = Vec::new();
|
||||
let mut depth = 0;
|
||||
let mut expect_name = true;
|
||||
for &j in &words[at.min(words.len())..] {
|
||||
match tokens[j] {
|
||||
"[" | "(" => depth += 1,
|
||||
"]" | ")" => depth -= 1,
|
||||
"," if depth == 0 => expect_name = true,
|
||||
t if depth == 0 && expect_name && is_ident(t) => {
|
||||
names.push((j, t));
|
||||
expect_name = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for &(j, name) in &names {
|
||||
if vector {
|
||||
fail(
|
||||
j,
|
||||
format!(
|
||||
"buffer member `{name}`: a `{}` is not supported in a storage buffer",
|
||||
tokens[ty]
|
||||
),
|
||||
);
|
||||
} else {
|
||||
members.push(Member { type_at: ty, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
if members.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if instance.is_some_and(|j| next(j).is_some_and(|n| tokens[n] == "[")) {
|
||||
for m in &members {
|
||||
fail(
|
||||
m.type_at,
|
||||
format!(
|
||||
"buffer member `{}`: an instance array of a buffer with `bool` members is not supported",
|
||||
m.name
|
||||
),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blocks.push(Block {
|
||||
open,
|
||||
close,
|
||||
instance: instance.map(|j| tokens[j]),
|
||||
members,
|
||||
});
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return (None, errors);
|
||||
}
|
||||
|
||||
// Texts put before and after tokens.
|
||||
let mut before: BTreeMap<usize, String> = BTreeMap::new();
|
||||
let mut after: BTreeMap<usize, String> = BTreeMap::new();
|
||||
let mut retyped: Vec<usize> = Vec::new();
|
||||
for block in &blocks {
|
||||
retyped.extend(block.members.iter().map(|m| m.type_at));
|
||||
}
|
||||
// The uses of each member.
|
||||
let kind = |i: usize| tokens[i];
|
||||
for block in &blocks {
|
||||
for member in &block.members {
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if t != member.name || (i > block.open && i < block.close) {
|
||||
continue;
|
||||
}
|
||||
// `instance . name`, or the bare name for a block without an instance.
|
||||
let (start, name_at) = match block.instance {
|
||||
Some(instance) => {
|
||||
let dot = prev(i).filter(|&d| kind(d) == ".");
|
||||
let owner = dot.and_then(prev).filter(|&o| kind(o) == instance);
|
||||
match owner {
|
||||
Some(o) => (o, i),
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if prev(i).is_some_and(|d| kind(d) == ".") {
|
||||
continue; // Another value's member of the same name.
|
||||
}
|
||||
// A declaration of the same name (a local, a parameter) hides it.
|
||||
if prev(i).is_some_and(|d| {
|
||||
is_ident(kind(d))
|
||||
&& !matches!(
|
||||
kind(d),
|
||||
"return" | "else" | "case" | "do" | "discard" | "in" | "out"
|
||||
)
|
||||
}) {
|
||||
fail(
|
||||
i,
|
||||
format!(
|
||||
"buffer member `{}` is also declared as a local or parameter; \
|
||||
rename one of them",
|
||||
member.name
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
(i, i)
|
||||
}
|
||||
};
|
||||
// The reference ends after its `[...]` groups.
|
||||
let mut last = name_at;
|
||||
let mut following = next(last);
|
||||
while let Some(open) = following.filter(|&j| kind(j) == "[") {
|
||||
match matching(open) {
|
||||
Some(close) => {
|
||||
last = close;
|
||||
following = next(close);
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
let op = following.map(kind);
|
||||
let adjacent = |j: usize| tokens.get(j + 1).copied();
|
||||
match (following, op) {
|
||||
// `ref = value;` as a statement.
|
||||
(Some(eq), Some("=")) if adjacent(eq) != Some("=") => {
|
||||
let statement = prev(start)
|
||||
.is_none_or(|p| matches!(kind(p), ";" | "{" | "}" | ")" | "else"));
|
||||
let mut depth = 0i32;
|
||||
let end = (eq + 1..tokens.len()).find(|&j| {
|
||||
match tokens[j] {
|
||||
"(" | "[" | "{" => depth += 1,
|
||||
")" | "]" | "}" => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
depth < 0 || (depth == 0 && tokens[j] == ";")
|
||||
});
|
||||
match (statement, end) {
|
||||
(true, Some(end)) => {
|
||||
let first = next(eq).unwrap_or(eq + 1);
|
||||
before.entry(first).or_default().push_str("uint(");
|
||||
before.entry(end).or_default().insert(0, ')');
|
||||
}
|
||||
_ => fail(
|
||||
i,
|
||||
format!(
|
||||
"buffer member `{}` is assigned inside an expression; \
|
||||
assign it in a statement of its own",
|
||||
member.name
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
// `ref |= value;` and the like.
|
||||
(Some(op_at), Some("|" | "&" | "^" | "+" | "-" | "*" | "/" | "%"))
|
||||
if adjacent(op_at) == Some("=") =>
|
||||
{
|
||||
fail(
|
||||
i,
|
||||
format!(
|
||||
"buffer member `{}`: `{}=` on a `bool` in a storage buffer is not \
|
||||
supported; write `{} = {} {} ...;`",
|
||||
member.name,
|
||||
kind(op_at),
|
||||
member.name,
|
||||
member.name,
|
||||
match kind(op_at) {
|
||||
"|" => "||",
|
||||
"&" => "&&",
|
||||
other => other,
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
// A read.
|
||||
_ => {
|
||||
before.entry(start).or_default().push('(');
|
||||
after.entry(last).or_default().push_str(" != 0u)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return (None, errors);
|
||||
}
|
||||
let mut code = String::with_capacity(pp.code.len() + 32);
|
||||
for (i, &t) in tokens.iter().enumerate() {
|
||||
if let Some(text) = before.get(&i) {
|
||||
code.push_str(text);
|
||||
}
|
||||
if retyped.contains(&i) {
|
||||
code.push_str("uint");
|
||||
} else {
|
||||
code.push_str(t);
|
||||
}
|
||||
if let Some(text) = after.get(&i) {
|
||||
code.push_str(text);
|
||||
}
|
||||
}
|
||||
(Some(Preprocessed { code, ..pp.clone() }), errors)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::rewrite;
|
||||
use crate::pack::tests::zip;
|
||||
use crate::{Macros, Pack, preprocess};
|
||||
|
||||
/// The rewritten code of a fragment stage, `None` when nothing changes.
|
||||
fn code(source: &str) -> Option<String> {
|
||||
let pack =
|
||||
Pack::from_zip_bytes("P.zip", zip(&[("shaders/composite.fsh", source)])).unwrap();
|
||||
let stage = preprocess(&pack, "composite.fsh", &Macros::standard());
|
||||
let (rewritten, errors) = rewrite(&stage);
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
rewritten.map(|p| p.code)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_become_comparisons_and_plain_assignments_convert_back() {
|
||||
const SOURCE: &str = "#version 430\nlayout(std430, binding = 4) buffer State { mat4 matrix; bool flag; };\n\
|
||||
void main() {\n if (flag) { }\n flag = !flag && true;\n bool seen = flag;\n}\n";
|
||||
let out = code(SOURCE).unwrap();
|
||||
assert!(out.contains("mat4 matrix; uint flag;"), "{out}");
|
||||
assert!(out.contains("if ((flag != 0u))"), "{out}");
|
||||
assert!(out.contains("flag = uint(!(flag != 0u) && true);"), "{out}");
|
||||
assert!(out.contains("bool seen = (flag != 0u);"), "{out}");
|
||||
// The rewrite adds no line and takes none: the pack's line numbers stay right.
|
||||
let pack =
|
||||
Pack::from_zip_bytes("P.zip", zip(&[("shaders/composite.fsh", SOURCE)])).unwrap();
|
||||
let before = preprocess(&pack, "composite.fsh", &Macros::standard());
|
||||
assert_eq!(out.lines().count(), before.code.lines().count());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instances_and_arrays_are_rewritten_where_they_are_read() {
|
||||
let out = code(
|
||||
"#version 430\nlayout(std430, binding = 4) buffer State { int n; bool flags[4]; bool on; } s;\n\
|
||||
void main() {\n float a = s.on ? 1.0 : 0.0;\n bool b = s.flags[2] && !s.flags[s.n];\n}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(out.contains("uint flags[4]; uint on;"), "{out}");
|
||||
assert!(out.contains("(s.on != 0u) ? 1.0"), "{out}");
|
||||
assert!(
|
||||
out.contains("(s.flags[2] != 0u) && !(s.flags[s.n] != 0u)"),
|
||||
"{out}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_to_do_without_a_bool_member_and_a_same_named_field_is_refused() {
|
||||
// No bool in a buffer: nothing to do.
|
||||
assert!(
|
||||
code(
|
||||
"#version 430
|
||||
layout(std430, binding = 4) buffer B { int n; };
|
||||
bool flag;
|
||||
void main() { }
|
||||
"
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
// Another value's member of the same name cannot be told from the buffer's by tokens
|
||||
// alone, so it is refused with the name, not rewritten wrongly.
|
||||
let pack = Pack::from_zip_bytes(
|
||||
"P.zip",
|
||||
zip(&[(
|
||||
"shaders/composite.fsh",
|
||||
"#version 430
|
||||
struct Light { bool flag; };
|
||||
layout(std430, binding = 4) buffer B { bool flag; };
|
||||
void main() {
|
||||
Light light;
|
||||
bool a = light.flag;
|
||||
}
|
||||
",
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
let (rewritten, errors) = rewrite(&preprocess(&pack, "composite.fsh", &Macros::standard()));
|
||||
assert!(rewritten.is_none());
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert!(errors[0].message.contains("`flag` is also declared"));
|
||||
assert_eq!(errors[0].line, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Let the GLSL compiler evaluate authored constants, then lay them out as the actual UBO.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use naga::{Expression, Handle, Literal, Module, TypeInner};
|
||||
|
||||
use super::{Error, scan::Stage};
|
||||
|
||||
pub(super) fn merge<'a>(
|
||||
stages: impl IntoIterator<Item = (&'a Stage<'a>, &'a Module)>,
|
||||
) -> Result<Vec<u8>, Vec<Error>> {
|
||||
let mut result = Vec::new();
|
||||
let mut seen = BTreeMap::<String, Vec<u8>>::new();
|
||||
for (stage, module) in stages {
|
||||
let (span, _) = super::block_layout(module);
|
||||
result.resize(span as usize, 0);
|
||||
let members = module.global_variables.iter().find_map(|(_, var)| {
|
||||
if var.space == naga::AddressSpace::Uniform
|
||||
&& var
|
||||
.binding
|
||||
.as_ref()
|
||||
.is_some_and(|b| b.group == 0 && b.binding == 0)
|
||||
&& let TypeInner::Struct { members, .. } = &module.types[var.ty].inner
|
||||
{
|
||||
Some(members)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
let Some(members) = members else { continue };
|
||||
for (name, at) in &stage.defaults {
|
||||
let Some(member) = members.iter().find(|m| {
|
||||
m.name.as_deref() == Some(name.as_str())
|
||||
|| m.name.as_deref() == Some(format!("hz_bool_{name}").as_str())
|
||||
}) else {
|
||||
// An engine-owned per-draw member was moved to its own block.
|
||||
continue;
|
||||
};
|
||||
let constant_name = format!("hz_default_{name}");
|
||||
let constant = module.constants.iter().find_map(|(_, c)| {
|
||||
(c.name.as_deref() == Some(constant_name.as_str())).then_some(c)
|
||||
});
|
||||
let failure = || {
|
||||
vec![stage.error(
|
||||
*at,
|
||||
&format!("`{name}` needs a supported constant initializer"),
|
||||
)]
|
||||
};
|
||||
let constant = constant.ok_or_else(failure)?;
|
||||
let mut words = Vec::new();
|
||||
flatten(module, constant.init, &mut words).ok_or_else(failure)?;
|
||||
let mut bytes = vec![0; span as usize];
|
||||
let mut words = words.into_iter();
|
||||
write(
|
||||
module,
|
||||
&module.types[member.ty].inner,
|
||||
&mut words,
|
||||
&mut bytes,
|
||||
member.offset as usize,
|
||||
)
|
||||
.ok_or_else(failure)?;
|
||||
if words.next().is_some() {
|
||||
return Err(failure());
|
||||
}
|
||||
if let Some(previous) = seen.get(name) {
|
||||
if previous != &bytes {
|
||||
return Err(vec![stage.error(
|
||||
*at,
|
||||
&format!("`{name}` has conflicting initial values across stages"),
|
||||
)]);
|
||||
}
|
||||
} else {
|
||||
for (dst, value) in result.iter_mut().zip(&bytes) {
|
||||
*dst |= value;
|
||||
}
|
||||
seen.insert(name.clone(), bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn flatten(module: &Module, value: Handle<Expression>, out: &mut Vec<[u8; 4]>) -> Option<()> {
|
||||
match &module.global_expressions[value] {
|
||||
Expression::Literal(literal) => out.push(match *literal {
|
||||
Literal::F32(v) => v.to_le_bytes(),
|
||||
Literal::I32(v) => v.to_le_bytes(),
|
||||
Literal::U32(v) => v.to_le_bytes(),
|
||||
Literal::Bool(v) => u32::from(v).to_le_bytes(),
|
||||
_ => return None,
|
||||
}),
|
||||
Expression::Constant(c) => flatten(module, module.constants[*c].init, out)?,
|
||||
Expression::Compose { components, .. } => {
|
||||
for &component in components {
|
||||
flatten(module, component, out)?;
|
||||
}
|
||||
}
|
||||
Expression::Splat { size, value } => {
|
||||
for _ in 0..*size as usize {
|
||||
flatten(module, *value, out)?;
|
||||
}
|
||||
}
|
||||
Expression::ZeroValue(ty) => zeros(module, &module.types[*ty].inner, out)?,
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn zeros(module: &Module, ty: &TypeInner, out: &mut Vec<[u8; 4]>) -> Option<()> {
|
||||
match ty {
|
||||
TypeInner::Scalar(_) => out.push([0; 4]),
|
||||
TypeInner::Vector { size, .. } => out.extend(vec![[0; 4]; *size as usize]),
|
||||
TypeInner::Matrix { columns, rows, .. } => {
|
||||
out.extend(vec![[0; 4]; *columns as usize * *rows as usize])
|
||||
}
|
||||
TypeInner::Array {
|
||||
base,
|
||||
size: naga::ArraySize::Constant(n),
|
||||
..
|
||||
} => {
|
||||
for _ in 0..n.get() {
|
||||
zeros(module, &module.types[*base].inner, out)?;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn write(
|
||||
module: &Module,
|
||||
ty: &TypeInner,
|
||||
words: &mut impl Iterator<Item = [u8; 4]>,
|
||||
bytes: &mut [u8],
|
||||
offset: usize,
|
||||
) -> Option<()> {
|
||||
match ty {
|
||||
TypeInner::Scalar(naga::Scalar { width: 4, .. }) => bytes
|
||||
.get_mut(offset..offset + 4)?
|
||||
.copy_from_slice(&words.next()?),
|
||||
TypeInner::Vector { size, scalar } => {
|
||||
for i in 0..*size as usize {
|
||||
write(
|
||||
module,
|
||||
&TypeInner::Scalar(*scalar),
|
||||
words,
|
||||
bytes,
|
||||
offset + i * 4,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
TypeInner::Matrix {
|
||||
columns,
|
||||
rows,
|
||||
scalar,
|
||||
} => {
|
||||
// The frontend rejects two-row std140 matrices; supported columns use 16 bytes.
|
||||
let stride = 16;
|
||||
for column in 0..*columns as usize {
|
||||
write(
|
||||
module,
|
||||
&TypeInner::Vector {
|
||||
size: *rows,
|
||||
scalar: *scalar,
|
||||
},
|
||||
words,
|
||||
bytes,
|
||||
offset + column * stride,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
TypeInner::Array {
|
||||
base,
|
||||
size: naga::ArraySize::Constant(n),
|
||||
stride,
|
||||
} => {
|
||||
for i in 0..n.get() as usize {
|
||||
write(
|
||||
module,
|
||||
&module.types[*base].inner,
|
||||
words,
|
||||
bytes,
|
||||
offset + i * *stride as usize,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
@@ -30,6 +30,8 @@ use crate::Preprocessed;
|
||||
mod anonymous_structs;
|
||||
mod array_constructors;
|
||||
mod atomic_vectors;
|
||||
mod buffer_bools;
|
||||
mod defaults;
|
||||
mod geometry;
|
||||
mod image_parameters;
|
||||
mod integer_bounds;
|
||||
@@ -51,6 +53,8 @@ pub struct Interface {
|
||||
/// colour) and `hz_fog` (density, start, end, 1 / (end - start)).
|
||||
pub uniform_size: u32,
|
||||
pub uniforms: Vec<Uniform>,
|
||||
/// std140 initial contents; runtime values overwrite their members each frame.
|
||||
pub initial_values: Vec<u8>,
|
||||
/// Each sampler: texture at `binding`, sampler object at `binding + 1`.
|
||||
pub samplers: Vec<Sampler>,
|
||||
/// The fragment stage's output locations.
|
||||
@@ -90,11 +94,12 @@ pub struct ComputeProgram {
|
||||
}
|
||||
|
||||
/// The uniforms a gbuffers program reads per draw (5.3's "D"), in a block of their own
|
||||
/// (`HzDraw`, std140: three ints, a pad, a vec4; 32 bytes) the game sets for each draw.
|
||||
pub const DRAW_UNIFORMS: [(&str, &str); 4] = [
|
||||
/// (`HzDraw`, std140: four ints, a vec4; 32 bytes) the game sets for each draw.
|
||||
pub const DRAW_UNIFORMS: [(&str, &str); 5] = [
|
||||
("renderStage", "int"),
|
||||
("entityId", "int"),
|
||||
("blockEntityId", "int"),
|
||||
("currentRenderedItemId", "int"),
|
||||
("entityColor", "vec4"),
|
||||
];
|
||||
|
||||
@@ -154,6 +159,9 @@ pub struct GbuffersInputs {
|
||||
/// The alpha test applied to output 0 (`gl_FragData[0]`, `gl_FragColor`), the loader's
|
||||
/// (3.2 `alphaTest.<program>`): `None` keeps every fragment.
|
||||
pub alpha_test: Option<AlphaTest>,
|
||||
/// The program may read `currentRenderedItemId` without declaring it, as Iris 1.11.7
|
||||
/// lets the hand's (observed with probe packs; terrain's may not).
|
||||
pub item_id_declared: bool,
|
||||
}
|
||||
|
||||
/// A fragment stays when its output 0's alpha compares to `reference` as `op` says (a GLSL
|
||||
@@ -229,6 +237,13 @@ const GBUFFERS_MATRICES: &str = " hz_ModelViewMatrix = gbufferModelView;\n
|
||||
mat4 hz_light = mat4(vec4(1.0 / 256.0, 0.0, 0.0, 0.0), vec4(0.0, 1.0 / 256.0, 0.0, 0.0), vec4(0.0, 0.0, 1.0, 0.0), vec4(1.0 / 32.0, 1.0 / 32.0, 0.0, 1.0));\n \
|
||||
hz_TextureMatrix = mat4[8](mat4(1.0), hz_light, hz_light, mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0), mat4(1.0));\n";
|
||||
|
||||
/// Whether `code` names `name` (a whole identifier).
|
||||
fn uses_name(code: &str, name: &str) -> bool {
|
||||
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
|
||||
code.match_indices(name)
|
||||
.any(|(at, _)| !code[..at].ends_with(ident) && !code[at + name.len()..].starts_with(ident))
|
||||
}
|
||||
|
||||
fn translate(
|
||||
vertex: &Preprocessed,
|
||||
fragment: &Preprocessed,
|
||||
@@ -241,23 +256,39 @@ fn translate(
|
||||
} else {
|
||||
GBUFFERS_MATRICES.to_owned()
|
||||
};
|
||||
// Struct types without a name get one (`anonymous_structs`), and interface blocks travel
|
||||
// as plain varyings (`interface_blocks`).
|
||||
// Struct types without a name get one (`anonymous_structs`), interface blocks travel
|
||||
// as plain varyings (`interface_blocks`), and buffer vectors that atomic functions use by
|
||||
// component become arrays (`atomic_vectors`).
|
||||
let flat = |pp: &Preprocessed| {
|
||||
let named = anonymous_structs::name(&pp.code);
|
||||
let code = named.as_deref().unwrap_or(&pp.code);
|
||||
interface_blocks::flatten(code)
|
||||
let flat = interface_blocks::flatten(code)
|
||||
.or(named)
|
||||
.map(|code| Preprocessed { code, ..pp.clone() })
|
||||
.map(|code| Preprocessed { code, ..pp.clone() });
|
||||
let split = atomic_vectors::rewrite(flat.as_ref().unwrap_or(pp));
|
||||
split.or(flat)
|
||||
};
|
||||
let (vertex_flat, fragment_flat) = (flat(vertex), flat(fragment));
|
||||
let vertex = vertex_flat.as_ref().unwrap_or(vertex);
|
||||
let fragment = fragment_flat.as_ref().unwrap_or(fragment);
|
||||
// A `bool` member of a storage buffer travels as a `uint` (`buffer_bools`).
|
||||
let ((vertex_bools, vertex_bool_errors), (fragment_bools, fragment_bool_errors)) = (
|
||||
buffer_bools::rewrite(vertex),
|
||||
buffer_bools::rewrite(fragment),
|
||||
);
|
||||
let vertex = vertex_bools.as_ref().unwrap_or(vertex);
|
||||
let fragment = fragment_bools.as_ref().unwrap_or(fragment);
|
||||
let mut v = Stage::scan(vertex, ShaderStage::Vertex);
|
||||
let mut f = Stage::scan(fragment, ShaderStage::Fragment);
|
||||
v.gbuffers = gbuffers.is_some();
|
||||
f.gbuffers = gbuffers.is_some();
|
||||
let mut errors: Vec<Error> = v.errors.drain(..).chain(f.errors.drain(..)).collect();
|
||||
let mut errors: Vec<Error> = v
|
||||
.errors
|
||||
.drain(..)
|
||||
.chain(f.errors.drain(..))
|
||||
.chain(vertex_bool_errors)
|
||||
.chain(fragment_bool_errors)
|
||||
.collect();
|
||||
|
||||
// One uniform block and one set of samplers for both stages.
|
||||
let mut uniforms: BTreeMap<String, String> = BTreeMap::new();
|
||||
@@ -315,6 +346,14 @@ fn translate(
|
||||
draw = true;
|
||||
}
|
||||
}
|
||||
// Where Iris declares `currentRenderedItemId` itself, a program may read it undeclared;
|
||||
// `HzDraw` declares it.
|
||||
let item = "currentRenderedItemId";
|
||||
draw |= gbuffers.is_some_and(|g| g.item_id_declared)
|
||||
&& !uniforms.contains_key(item)
|
||||
&& [vertex, fragment]
|
||||
.iter()
|
||||
.any(|pp| uses_name(&pp.code, item));
|
||||
}
|
||||
let samplers: Vec<Sampler> = samplers
|
||||
.into_iter()
|
||||
@@ -385,7 +424,7 @@ fn translate(
|
||||
if draw {
|
||||
h.push_str(
|
||||
"layout(set = 2, binding = 0) uniform HzDraw { int renderStage; int entityId; \
|
||||
int blockEntityId; int hz_draw_pad; vec4 entityColor; };\n",
|
||||
int blockEntityId; int currentRenderedItemId; vec4 entityColor; };\n",
|
||||
);
|
||||
}
|
||||
let direction = if stage.kind == ShaderStage::Vertex {
|
||||
@@ -522,6 +561,7 @@ fn translate(
|
||||
(Ok(v), Ok(f)) => (v, f),
|
||||
(a, b) => return Err(a.err().into_iter().chain(b.err()).flatten().collect()),
|
||||
};
|
||||
let initial_values = defaults::merge([(&v, &vertex), (&f, &fragment)])?;
|
||||
let (uniform_size, offsets) = block_layout(&fragment);
|
||||
let uniforms = offsets
|
||||
.into_iter()
|
||||
@@ -545,6 +585,7 @@ fn translate(
|
||||
Ok(Program {
|
||||
interface: Interface {
|
||||
uniform_size,
|
||||
initial_values,
|
||||
uniforms,
|
||||
samplers,
|
||||
outputs,
|
||||
@@ -647,8 +688,11 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
|
||||
let compute = named.as_ref().unwrap_or(compute);
|
||||
let split = atomic_vectors::rewrite(compute);
|
||||
let compute = split.as_ref().unwrap_or(compute);
|
||||
let (bools, bool_errors) = buffer_bools::rewrite(compute);
|
||||
let compute = bools.as_ref().unwrap_or(compute);
|
||||
let mut c = Stage::scan(compute, ShaderStage::Compute);
|
||||
let mut errors = std::mem::take(&mut c.errors);
|
||||
errors.extend(bool_errors);
|
||||
let mut uniforms: BTreeMap<String, String> = BTreeMap::new();
|
||||
for (name, ty, _) in &c.uniforms {
|
||||
uniforms.insert(name.clone(), ty.clone());
|
||||
@@ -731,8 +775,10 @@ pub fn translate_compute(compute: &Preprocessed) -> Result<ComputeProgram, Vec<E
|
||||
std::mem::take(&mut errors)
|
||||
})?;
|
||||
let (uniform_size, offsets) = block_layout(&module);
|
||||
let initial_values = defaults::merge([(&c, &module)])?;
|
||||
let mut interface = Interface {
|
||||
uniform_size,
|
||||
initial_values,
|
||||
uniforms: offsets
|
||||
.into_iter()
|
||||
.filter_map(|(member, offset)| {
|
||||
@@ -1407,6 +1453,89 @@ fn block_layout(module: &naga::Module) -> (u32, Vec<(String, u32)>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A gbuffers program whose fragment stage writes `float(<name>)` after `decl`.
|
||||
fn item_program(
|
||||
program: &str,
|
||||
decl: &str,
|
||||
name: &str,
|
||||
game: &GbuffersInputs,
|
||||
) -> Result<Program, Vec<Error>> {
|
||||
let vsh = "#version 330 compatibility\nvoid main() { gl_Position = ftransform(); }\n";
|
||||
let fsh = format!(
|
||||
"#version 330 compatibility\n{decl}\nlayout(location = 0) out vec4 c;\n\
|
||||
void main() {{ c = vec4(float({name})); }}\n"
|
||||
);
|
||||
let (v, f) = (format!("{program}.vsh"), format!("{program}.fsh"));
|
||||
let files = [
|
||||
(format!("shaders/{v}"), vsh.to_owned()),
|
||||
(format!("shaders/{f}"), fsh),
|
||||
];
|
||||
let files: Vec<(&str, &str)> = files
|
||||
.iter()
|
||||
.map(|(a, b)| (a.as_str(), b.as_str()))
|
||||
.collect();
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&files)).unwrap();
|
||||
let m = Macros::standard();
|
||||
translate_gbuffers(
|
||||
&preprocess(&pack, &v, &m),
|
||||
&preprocess(&pack, &f, &m),
|
||||
Target::Buffers,
|
||||
game,
|
||||
)
|
||||
}
|
||||
|
||||
fn item_game() -> GbuffersInputs {
|
||||
GbuffersInputs {
|
||||
declarations: "layout(location = 0) in vec3 game_pos;".into(),
|
||||
body: " hz_Vertex = vec4(game_pos, 1.0);\n".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `currentRenderedItemId` is set per draw, in `HzDraw`, as `entityId` is.
|
||||
#[test]
|
||||
fn the_rendered_item_id_is_a_per_draw_value() {
|
||||
let decl = "uniform int currentRenderedItemId;";
|
||||
let p = item_program("gbuffers_hand", decl, "currentRenderedItemId", &item_game())
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let names: Vec<&str> = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.map(|u| u.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
p.interface.draw && !names.contains(&"currentRenderedItemId"),
|
||||
"{names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hand's programs may read `currentRenderedItemId` without declaring it, as Iris lets
|
||||
/// them; terrain's may not, nor a longer name that begins with it.
|
||||
#[test]
|
||||
fn the_hand_may_read_the_rendered_item_id_undeclared() {
|
||||
let hand = GbuffersInputs {
|
||||
item_id_declared: true,
|
||||
..item_game()
|
||||
};
|
||||
let p = item_program("gbuffers_hand", "", "currentRenderedItemId", &hand)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
assert!(p.interface.draw);
|
||||
assert!(
|
||||
item_program(
|
||||
"gbuffers_terrain",
|
||||
"",
|
||||
"currentRenderedItemId",
|
||||
&item_game()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let longer = "uniform int currentRenderedItemId2;";
|
||||
let p = item_program("gbuffers_hand", longer, "currentRenderedItemId2", &hand)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
assert!(!p.interface.draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compute_dispatch_splats_absolute_vector_constants() {
|
||||
for (declaration, expected) in [
|
||||
@@ -1731,6 +1860,133 @@ mod tests {
|
||||
assert!(clamped, "sqrt's argument is clamped at zero");
|
||||
}
|
||||
|
||||
/// #8175: a `bool` member of a `layout(std430)` buffer (GLSL: size and alignment 4) is
|
||||
/// "not host-shareable" to naga. The shapes packs write: read in a condition, in an
|
||||
/// expression, written from a comparison, an array of them, beside other members.
|
||||
#[test]
|
||||
fn bool_members_of_std430_buffers_translate() {
|
||||
let mut failures = Vec::new();
|
||||
for (block, body) in [
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { mat4 matrix; bool flag; };",
|
||||
"if (flag) { gl_FragColor = vec4(1.0); } else { gl_FragColor = vec4(0.0); }",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flag; };",
|
||||
"gl_FragColor = vec4(float(flag), 0.0, 0.0, 1.0);",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { int count; bool flag; } state;",
|
||||
"gl_FragColor = vec4(state.flag ? 1.0 : 0.0, float(state.count), 0.0, 1.0);",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flags[4]; };",
|
||||
"gl_FragColor = vec4(flags[2] && !flags[1] ? 1.0 : 0.0, 0.0, 0.0, 1.0);",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { vec2 size; bool flag; };",
|
||||
"flag = size.x > 1.0;
|
||||
gl_FragColor = vec4(size, flag ? 1.0 : 0.0, 1.0);",
|
||||
),
|
||||
] {
|
||||
let fsh = format!(
|
||||
"#version 430 compatibility
|
||||
{block}
|
||||
void main() {{
|
||||
{body}
|
||||
}}
|
||||
"
|
||||
);
|
||||
if let Err(e) = program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", &fsh)],
|
||||
Target::Buffers,
|
||||
) {
|
||||
failures.push(format!("`{block}` / `{body}`: {e:?}"));
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"{}",
|
||||
failures.join(
|
||||
"
|
||||
"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// #8175: what the rewrite cannot make sure of is refused with the member's name and its
|
||||
/// line, not left to naga's "Global variable [6] '' is invalid".
|
||||
#[test]
|
||||
fn bool_buffer_members_that_cannot_be_rewritten_are_named() {
|
||||
for (block, body, wanted) in [
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flag; };",
|
||||
"flag |= true;",
|
||||
"`flag`: `|=`",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flag; };",
|
||||
"bool other = (flag = true);",
|
||||
"`flag` is assigned inside an expression",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flag; };",
|
||||
"bool flag = false;",
|
||||
"`flag` is also declared as a local or parameter",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bvec2 pair; };",
|
||||
"",
|
||||
"`pair`: a `bvec2` is not supported",
|
||||
),
|
||||
(
|
||||
"layout(std430, binding = 4) buffer State { bool flag; } states[2];",
|
||||
"",
|
||||
"an instance array",
|
||||
),
|
||||
] {
|
||||
let fsh = format!(
|
||||
"#version 430 compatibility
|
||||
{block}
|
||||
void main() {{
|
||||
{body}
|
||||
gl_FragColor = vec4(1.0);
|
||||
}}
|
||||
"
|
||||
);
|
||||
let errors = program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", &fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.err()
|
||||
.unwrap_or_else(|| panic!("`{body}` translated"));
|
||||
let error = errors
|
||||
.iter()
|
||||
.find(|e| e.message.contains(wanted))
|
||||
.unwrap_or_else(|| panic!("`{wanted}` in {errors:?}"));
|
||||
assert_eq!(error.file, "composite.fsh");
|
||||
assert!(error.line >= 2, "{error:?}");
|
||||
assert!(!error.message.contains("host-shareable"), "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #8175: a compute program reads and writes a `bool` of its buffer too.
|
||||
#[test]
|
||||
fn bool_members_of_std430_buffers_translate_in_compute_programs() {
|
||||
let source = "#version 430
|
||||
layout(local_size_x = 1) in;
|
||||
layout(std430, binding = 3) buffer State { int count; bool flag; };
|
||||
void main() {
|
||||
flag = count > 2;
|
||||
if (flag) { count = 0; }
|
||||
}
|
||||
";
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/setup.csh", source)])).unwrap();
|
||||
let stage = crate::preprocess(&pack, "setup.csh", &crate::Macros::standard());
|
||||
let result = crate::translate_compute(&stage);
|
||||
assert!(result.is_ok(), "{:?}", result.err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_programs_translate() {
|
||||
let vsh = "#version 330 core\nin vec3 vaPosition;\nin vec2 vaUV0;\nuniform mat4 modelViewMatrix, projectionMatrix;\n\
|
||||
@@ -2376,6 +2632,43 @@ in VertexData {{ {member} }} vIn;
|
||||
}
|
||||
}
|
||||
|
||||
/// GL_AMD_shader_trinary_minmax adds `min3`, `max3` and `mid3` for genType, genIType and
|
||||
/// genUType; naga has none of them. A pack's own `max3` is kept, with or without the
|
||||
/// extension (glslangValidator takes both).
|
||||
#[test]
|
||||
fn trinary_min_max_translate() {
|
||||
let ext = "#extension GL_AMD_shader_trinary_minmax : require\n";
|
||||
let calls = "uniform float k;\nuniform int i;\nuniform uint u;\nout vec4 c;\n\
|
||||
void main() {\n vec3 v = vec3(k, 0.5, 0.25);\n \
|
||||
float f = min3(k, 0.5, 0.25) + max3(v.xy, v.yz, v.zx).y + mid3(v, v.yzx, v.zxy).z\n \
|
||||
+ mid3(vec4(k), vec4(0.5), vec4(1.0)).w;\n \
|
||||
ivec2 n = max3(ivec2(i), ivec2(2), ivec2(-1)) + ivec2(mid3(i, 3, 7), min3(ivec4(i), ivec4(1), ivec4(2)).x);\n \
|
||||
uvec3 m = min3(uvec3(u), uvec3(2u), uvec3(9u)) + mid3(u, 4u, 8u) + max3(uvec4(u), uvec4(1u), uvec4(5u)).xyz;\n \
|
||||
c = vec4(f, float(n.x + n.y), float(m.x), mid3(1.0, 2.0, 3.0));\n}\n";
|
||||
let own = "uniform float k;\nout vec4 c;\n\
|
||||
float max3(float a, float b, float c) { return max(a, max(b, c)); }\n\
|
||||
void main() { c = vec4(max3(k, 0.5, 0.25)); }\n";
|
||||
for fsh in [
|
||||
format!("#version 330\n{ext}{calls}"),
|
||||
format!("#version 330\n{ext}{own}"),
|
||||
format!("#version 330\n{own}"),
|
||||
] {
|
||||
program(
|
||||
&[("composite.vsh", VSH_120), ("composite.fsh", &fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{fsh}: {e:?}"));
|
||||
}
|
||||
let csh = format!(
|
||||
"#version 430\n{ext}layout(local_size_x = 1) in;\n\
|
||||
layout(std430, binding = 0) buffer B {{ uvec2 data; }} b;\n\
|
||||
void main() {{ b.data = uvec2(max3(b.data.x, 2u, 3u), min3(b.data, uvec2(1u), uvec2(4u)).y); }}\n"
|
||||
);
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/composite.csh", &csh)])).unwrap();
|
||||
translate_compute(&preprocess(&pack, "composite.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// GLSL converts a returned value to the function's type (Kappa's `GetEmissionScale`):
|
||||
/// `return 1;` in a `float` function, an `int` or `ivec2` returned as `float` or `vec2`.
|
||||
#[test]
|
||||
@@ -2717,6 +3010,68 @@ in VertexData {{ {member} }} vIn;
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// The std430 offsets of the members of the buffer block at `binding`, by the names the
|
||||
/// pack gave them (a split vector's `_hz` dropped, our padding left out), and its size.
|
||||
fn buffer_layout(module: &naga::Module, binding: u32) -> (Vec<(String, u32)>, u32) {
|
||||
let (_, global) = module
|
||||
.global_variables
|
||||
.iter()
|
||||
.find(|(_, g)| g.binding.as_ref().is_some_and(|b| b.binding == binding))
|
||||
.expect("the block");
|
||||
let ty = &module.types[global.ty];
|
||||
let naga::TypeInner::Struct { members, span } = &ty.inner else {
|
||||
panic!("{ty:?}");
|
||||
};
|
||||
let members = members
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
let name = m.name.as_deref()?;
|
||||
(!name.starts_with("hz_pad_"))
|
||||
.then(|| (name.trim_end_matches("_hz").to_owned(), m.offset))
|
||||
})
|
||||
.collect();
|
||||
(members, *span)
|
||||
}
|
||||
|
||||
/// Atomic functions on a component of a buffer block's vector, which GLSL 4.60 8.11 allows
|
||||
/// (our probe pack's shape, in a composite vertex stage): the program translates, and the
|
||||
/// block keeps the std430 layout the fragment stage, which uses no atomics, gives it.
|
||||
#[test]
|
||||
fn atomics_on_buffer_vector_components_compile_and_keep_the_layout() {
|
||||
let block = "layout(std430, binding = 2) restrict buffer probeSums { uint n; uvec2 a; uvec3 c[2]; uint m; uvec2 b; } sums;\n";
|
||||
let vsh = format!(
|
||||
"#version 430 compatibility\n{block}out vec2 uv;\nvoid main() {{\n \
|
||||
atomicAdd(sums.a.x, 1u);\n atomicMax(sums.b.y, uint(gl_VertexID));\n \
|
||||
atomicOr(sums.c[gl_VertexID & 1].z, 2u);\n sums.a += uvec2(1u);\n \
|
||||
uv = vec2(sums.a.y + sums.c[1].x, sums.c.length());\n gl_Position = ftransform();\n}}\n"
|
||||
);
|
||||
let fsh = format!(
|
||||
"#version 430 compatibility\n{block}in vec2 uv;\nvoid main() {{\n \
|
||||
gl_FragData[0] = vec4(vec2(sums.a), vec2(sums.b)) + uv.x + float(sums.m + sums.c[0].z);\n}}\n"
|
||||
);
|
||||
let p = program(
|
||||
&[("composite.vsh", &vsh), ("composite.fsh", &fsh)],
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let expected: Vec<(String, u32)> = [("n", 0), ("a", 8), ("c", 16), ("m", 48), ("b", 56)]
|
||||
.map(|(n, o)| (n.to_owned(), o))
|
||||
.into();
|
||||
assert_eq!(buffer_layout(&p.fragment, 2), (expected.clone(), 64));
|
||||
assert_eq!(buffer_layout(&p.vertex, 2), (expected, 64));
|
||||
}
|
||||
|
||||
/// A block without an instance name: its member is named alone.
|
||||
#[test]
|
||||
fn atomics_on_vector_components_of_an_unnamed_buffer_block_compile() {
|
||||
let csh = "#version 430\nlayout(local_size_x = 4) in;\n\
|
||||
layout(std430, binding = 1) buffer Counts { ivec4 counts; };\n\
|
||||
void main() {\n atomicAdd(counts.w, 1);\n ivec4 c = counts;\n counts = c + 1;\n}\n";
|
||||
let pack = Pack::from_zip_bytes("P.zip", zip(&[("shaders/deferred_a.csh", csh)])).unwrap();
|
||||
translate_compute(&preprocess(&pack, "deferred_a.csh", &Macros::standard()))
|
||||
.unwrap_or_else(|e| panic!("{e:?}"));
|
||||
}
|
||||
|
||||
/// A stage's `#version` may come from a header it includes: the program is that version
|
||||
/// (`shared` is a keyword in GLSL 4.30, not a name to rename).
|
||||
#[test]
|
||||
|
||||
@@ -60,6 +60,9 @@ impl Stage<'_> {
|
||||
if self.scalar_normalize {
|
||||
p.push_str("float normalize(float x) { return sign(x); }\n");
|
||||
}
|
||||
for name in &self.trinary {
|
||||
p.push_str(&trinary(name));
|
||||
}
|
||||
if self.gbuffers {
|
||||
// Set by our `main` from the frame's matrices (gbuffers.rs).
|
||||
p.push_str(
|
||||
@@ -798,6 +801,32 @@ const TYPE_NAMES: [&str; 35] = [
|
||||
/// offsets, so each footprint is gathered at `P` moved by its offset over the texture's size,
|
||||
/// which picks the same four texels; the component, a constant in GLSL, picks one of four
|
||||
/// gathers.
|
||||
/// GL_AMD_shader_trinary_minmax's `min3`, `max3` or `mid3` (its additions to GLSL 4.30
|
||||
/// Section 8.3: genType, genIType and genUType overloads): the per-component minimum, maximum
|
||||
/// or median of x, y and z.
|
||||
fn trinary(name: &str) -> String {
|
||||
let body = match name {
|
||||
"min3" => "min(x, min(y, z))",
|
||||
"max3" => "max(x, max(y, z))",
|
||||
_ => "max(min(x, y), min(max(x, y), z))",
|
||||
};
|
||||
let mut p = String::new();
|
||||
for (scalar, vector) in [("float", "vec"), ("int", "ivec"), ("uint", "uvec")] {
|
||||
for n in 1..=4 {
|
||||
let ty = match n {
|
||||
1 => scalar.to_owned(),
|
||||
_ => format!("{vector}{n}"),
|
||||
};
|
||||
writeln!(
|
||||
p,
|
||||
"{ty} {name}({ty} x, {ty} y, {ty} z) {{ return {body}; }}"
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn gather_offsets() -> String {
|
||||
let mut p = String::new();
|
||||
for prefix in ["", "i", "u"] {
|
||||
|
||||
@@ -41,6 +41,8 @@ pub(super) struct Stage<'p> {
|
||||
/// Name, type with its array suffix, token of the type.
|
||||
pub(super) uniforms: Vec<(String, String, usize)>,
|
||||
pub(super) samplers: Vec<(String, String)>,
|
||||
/// Uniform names with constant initializers retained as compiler constants.
|
||||
pub(super) defaults: Vec<(String, usize)>,
|
||||
pub(super) images: Vec<ImageDecl>,
|
||||
/// The bindings of the SSBOs (`buffer` blocks), in set 4.
|
||||
pub(super) buffers: BTreeSet<u32>,
|
||||
@@ -65,6 +67,9 @@ pub(super) struct Stage<'p> {
|
||||
/// (`dot(float, float)`, `normalize(float)`) and naga lacks go in the prelude.
|
||||
pub(super) scalar_dot: bool,
|
||||
pub(super) scalar_normalize: bool,
|
||||
/// `min3`, `max3` and `mid3` the stage calls with GL_AMD_shader_trinary_minmax enabled and
|
||||
/// doesn't define itself: naga lacks them, so ours are in the prelude.
|
||||
pub(super) trinary: Vec<&'p str>,
|
||||
/// The pack's functions that take samplers: which parameters are samplers.
|
||||
pub(super) sampler_functions: HashMap<String, Vec<bool>>,
|
||||
/// The pack's structs: each member's type and name with its array suffix.
|
||||
@@ -297,6 +302,7 @@ impl<'p> Stage<'p> {
|
||||
tokens: lex(&pp.code),
|
||||
removed: Vec::new(),
|
||||
uniforms: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
samplers: Vec::new(),
|
||||
images: Vec::new(),
|
||||
buffers: BTreeSet::new(),
|
||||
@@ -310,6 +316,7 @@ impl<'p> Stage<'p> {
|
||||
query_lod: false,
|
||||
scalar_dot: false,
|
||||
scalar_normalize: false,
|
||||
trinary: Vec::new(),
|
||||
sampler_functions: HashMap::new(),
|
||||
structs: HashMap::new(),
|
||||
outputs: BTreeSet::new(),
|
||||
@@ -325,6 +332,12 @@ impl<'p> Stage<'p> {
|
||||
let (mut start, mut depth, mut parens) = (0, 0usize, 0usize);
|
||||
let mut own_inverse = false;
|
||||
let (mut own_dot, mut own_normalize) = (false, false);
|
||||
let mut own_trinary = Vec::new();
|
||||
let trinary_enabled = pp.extensions.iter().any(|e| {
|
||||
e.split_once(':').is_some_and(|(name, behavior)| {
|
||||
name.trim() == "GL_AMD_shader_trinary_minmax" && behavior.trim() != "disable"
|
||||
})
|
||||
});
|
||||
for i in 0..s.tokens.len() {
|
||||
match s.tokens[i] {
|
||||
"(" => parens += 1,
|
||||
@@ -376,6 +389,20 @@ impl<'p> Stage<'p> {
|
||||
own_normalize |= defines;
|
||||
}
|
||||
}
|
||||
name @ ("min3" | "max3" | "mid3") if trinary_enabled => {
|
||||
if !s.trinary.contains(&name) {
|
||||
s.trinary.push(name);
|
||||
}
|
||||
// A definition at the top level: `float max3(float a, ...)`.
|
||||
if depth == 0
|
||||
&& s.tokens[..i]
|
||||
.iter()
|
||||
.rfind(|t| significant(t))
|
||||
.is_some_and(|t| is_ident(t))
|
||||
{
|
||||
own_trinary.push(name);
|
||||
}
|
||||
}
|
||||
"inverse" => {
|
||||
s.inverse = true;
|
||||
// `mat4 inverse(mat4 m)`: a pack for GLSL 1.20, which has none, wrote its own.
|
||||
@@ -401,6 +428,7 @@ impl<'p> Stage<'p> {
|
||||
s.inverse &= !own_inverse;
|
||||
s.scalar_dot &= !own_dot;
|
||||
s.scalar_normalize &= !own_normalize;
|
||||
s.trinary.retain(|name| !own_trinary.contains(name));
|
||||
if s.gather_offsets {
|
||||
// Its sampler is passed as a texture and a sampler, as to the pack's own functions.
|
||||
s.sampler_functions
|
||||
@@ -603,6 +631,7 @@ impl<'p> Stage<'p> {
|
||||
}
|
||||
// Declarators: name [array] [= init], separated by commas.
|
||||
let mut declarators: Vec<(String, String)> = Vec::new();
|
||||
let mut defaults = String::new();
|
||||
let mut j = k + 1;
|
||||
while let Some(&n) = words.get(j) {
|
||||
let name = self.tokens[n].to_owned();
|
||||
@@ -617,10 +646,28 @@ impl<'p> Stage<'p> {
|
||||
_ => {}
|
||||
}
|
||||
if self.tokens[t] == "=" && nesting == 0 {
|
||||
// An initializer: uniforms are set by the game, so it is dropped.
|
||||
while words.get(j).is_some_and(|&t| self.tokens[t] != ",") {
|
||||
let start = j + 1;
|
||||
j += 1;
|
||||
let mut depth = 0;
|
||||
while let Some(&token) = words.get(j) {
|
||||
match self.tokens[token] {
|
||||
"," if depth == 0 => break,
|
||||
"(" | "[" => depth += 1,
|
||||
")" | "]" => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if storage == "uniform" && !is_sampler_type(&ty) && !ty.contains("image") {
|
||||
let value = words[start..j]
|
||||
.iter()
|
||||
.map(|&i| self.tokens[i])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
write!(defaults, "const {ty} hz_default_{name}{array} = {value}; ")
|
||||
.unwrap();
|
||||
self.defaults.push((name.clone(), n));
|
||||
}
|
||||
break;
|
||||
}
|
||||
array.push_str(self.tokens[t]);
|
||||
@@ -632,6 +679,9 @@ impl<'p> Stage<'p> {
|
||||
let fragment = self.kind == ShaderStage::Fragment;
|
||||
let input = matches!(storage, "in" | "attribute") || (storage == "varying" && fragment);
|
||||
self.removed.push(from..to + 1);
|
||||
if !defaults.is_empty() {
|
||||
self.replaced.insert(words[0], defaults);
|
||||
}
|
||||
for (name, array) in declarators {
|
||||
let decl = format!("{interpolation}{ty} @{array};");
|
||||
match storage {
|
||||
|
||||
@@ -324,17 +324,26 @@ fn array_length_uses_declaration_scope() {
|
||||
.iter()
|
||||
.find(|(_, function)| function.name.as_deref() == Some("query_length"))
|
||||
.unwrap();
|
||||
let (local, declaration) = function
|
||||
// `n` is a local, or a module constant (a `const` with a constant initializer).
|
||||
let local = function
|
||||
.local_variables
|
||||
.iter()
|
||||
.find(|(_, local)| local.name.as_deref() == Some("n"))
|
||||
.unwrap();
|
||||
let mut value = declaration.init.or_else(|| function.body.iter().find_map(|statement| match statement {
|
||||
naga::Statement::Store { pointer, value }
|
||||
if matches!(function.expressions[*pointer], naga::Expression::LocalVariable(handle) if handle == local) => Some(*value),
|
||||
_ => None,
|
||||
})).unwrap();
|
||||
let mut expressions = &function.expressions;
|
||||
.find(|(_, local)| local.name.as_deref() == Some("n"));
|
||||
let (mut value, mut expressions) = match local {
|
||||
Some((local, declaration)) => (declaration.init.or_else(|| function.body.iter().find_map(|statement| match statement {
|
||||
naga::Statement::Store { pointer, value }
|
||||
if matches!(function.expressions[*pointer], naga::Expression::LocalVariable(handle) if handle == local) => Some(*value),
|
||||
_ => None,
|
||||
})).unwrap(), &function.expressions),
|
||||
None => {
|
||||
let (_, constant) = module
|
||||
.constants
|
||||
.iter()
|
||||
.find(|(_, constant)| constant.name.as_deref() == Some("n"))
|
||||
.unwrap();
|
||||
(constant.init, &module.global_expressions)
|
||||
}
|
||||
};
|
||||
loop {
|
||||
match expressions[value] {
|
||||
naga::Expression::As { expr, .. } => value = expr,
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Original controls for `.length()` of an explicitly sized array as an array size: a constant
|
||||
//! expression "whether or not the object itself is constant" (GLSL 4.60, 4.3.3, as #8463
|
||||
//! quotes it), here members of storage buffers sizing shared arrays and a local one.
|
||||
//! glslangValidator takes the shader.
|
||||
use horizon_shaders::{Origin, Preprocessed, translate_compute};
|
||||
|
||||
const SOURCE: &str = "layout(local_size_x = 64) in;\n\
|
||||
layout(std430, binding = 1) restrict buffer probeList { uint count; uint[64] data; } pl;\n\
|
||||
layout(std430, binding = 2) buffer Anon { vec4 cells[12]; };\n\
|
||||
shared struct { uint n; uint[pl.data.length()] items; } sh;\n\
|
||||
shared uint flat_items[pl.data.length() / 2];\n\
|
||||
shared float per_cell[cells.length()];\n\
|
||||
void main() {\n\
|
||||
uint i = gl_LocalInvocationIndex;\n\
|
||||
float local_copy[cells.length()];\n\
|
||||
local_copy[i % 12u] = cells[i % 12u].x;\n\
|
||||
sh.items[i] = pl.data[i];\n\
|
||||
flat_items[i / 2u] = i;\n\
|
||||
per_cell[i % 12u] = local_copy[i % 12u];\n\
|
||||
barrier();\n\
|
||||
if (i == 0u) pl.count = uint(sh.items.length() + flat_items.length()) + uint(per_cell.length());\n\
|
||||
}\n";
|
||||
|
||||
fn stage(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["original.csh".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((430, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The number of elements of the array a variable or its member `member` holds.
|
||||
fn elements(module: &naga::Module, ty: naga::Handle<naga::Type>, member: Option<&str>) -> u32 {
|
||||
let ty = match (&module.types[ty].inner, member) {
|
||||
(naga::TypeInner::Struct { members, .. }, Some(name)) => {
|
||||
members
|
||||
.iter()
|
||||
.find(|m| m.name.as_deref() == Some(name))
|
||||
.unwrap()
|
||||
.ty
|
||||
}
|
||||
_ => ty,
|
||||
};
|
||||
match module.types[ty].inner {
|
||||
naga::TypeInner::Array {
|
||||
size: naga::ArraySize::Constant(n),
|
||||
..
|
||||
} => n.get(),
|
||||
ref other => panic!("not a sized array: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_of_a_sized_buffer_member_sizes_an_array() {
|
||||
let program = translate_compute(&stage(SOURCE)).unwrap_or_else(|e| panic!("{e:?}"));
|
||||
let module = &program.module;
|
||||
let global = |name: &str| {
|
||||
module
|
||||
.global_variables
|
||||
.iter()
|
||||
.find(|(_, g)| g.name.as_deref() == Some(name))
|
||||
.unwrap_or_else(|| panic!("no global {name}"))
|
||||
.1
|
||||
.ty
|
||||
};
|
||||
assert_eq!(elements(module, global("sh"), Some("items")), 64);
|
||||
assert_eq!(elements(module, global("flat_items"), None), 32);
|
||||
assert_eq!(elements(module, global("per_cell"), None), 12);
|
||||
let local = module
|
||||
.functions
|
||||
.iter()
|
||||
.flat_map(|(_, f)| f.local_variables.iter())
|
||||
.find(|(_, l)| l.name.as_deref() == Some("local_copy"))
|
||||
.unwrap()
|
||||
.1
|
||||
.ty;
|
||||
assert_eq!(elements(module, local, None), 12);
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(module)
|
||||
.unwrap();
|
||||
naga::back::spv::write_vec(module, &info, &naga::back::spv::Options::default(), None).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Original controls for `const` locals: one initialized with a constant expression is a
|
||||
//! constant expression itself (GLSL 4.60, 4.3.3, as #8462 quotes it), so it may be a texel
|
||||
//! offset (8.9.2, as #8462 quotes it). glslangValidator takes every shader here.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite};
|
||||
|
||||
fn stage(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["original.fsh".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((440, Some("compatibility".into()))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn translates(source: &str) {
|
||||
let program = translate_composite(
|
||||
&stage("void main() { gl_Position = gl_Vertex; }"),
|
||||
&stage(source),
|
||||
Target::Window,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("{source}\n{e:?}"));
|
||||
let info = naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::all(),
|
||||
)
|
||||
.validate(&program.fragment)
|
||||
.unwrap();
|
||||
naga::back::spv::write_vec(
|
||||
&program.fragment,
|
||||
&info,
|
||||
&naga::back::spv::Options::default(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
const SAMPLER: &str = "uniform sampler2D colortex0;\nuniform float k;\n";
|
||||
|
||||
#[test]
|
||||
fn a_const_local_is_a_texel_offset() {
|
||||
translates(&format!(
|
||||
"{SAMPLER}vec4 f(vec2 p) {{ const ivec2 offset = ivec2(0, -2); return textureLodOffset(colortex0, p, 0.0, offset); }}\n\
|
||||
void main() {{ gl_FragColor = f(gl_FragCoord.xy); }}\n"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_const_local_in_a_loop_is_a_texel_offset() {
|
||||
translates(&format!(
|
||||
"{SAMPLER}void main() {{\n vec4 s = vec4(0.0);\n for (int i = 0; i < 3; i++) {{\n \
|
||||
const ivec2 step = ivec2(1, 0) * 2;\n \
|
||||
s += textureOffset(colortex0, gl_FragCoord.xy, step) + texelFetchOffset(colortex0, ivec2(gl_FragCoord.xy), 0, step);\n }}\n \
|
||||
gl_FragColor = s;\n}}\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// A `const` local made of other constants, global and local, and then converted.
|
||||
#[test]
|
||||
fn a_const_local_of_other_constants_is_a_texel_offset() {
|
||||
translates(&format!(
|
||||
"{SAMPLER}const int far = 2;\n\
|
||||
void main() {{\n const int step = far;\n const int twice = step;\n \
|
||||
const ivec2 offset = ivec2(twice, -step) * 2;\n \
|
||||
gl_FragColor = textureOffset(colortex0, gl_FragCoord.xy, offset) * float(twice);\n}}\n"
|
||||
));
|
||||
}
|
||||
|
||||
/// Controls that already translated: a constant array indexed by a loop counter, and a `const`
|
||||
/// local whose initializer reads a uniform (not a constant expression; glslangValidator takes it).
|
||||
#[test]
|
||||
fn const_locals_that_are_not_offsets_still_translate() {
|
||||
translates(&format!(
|
||||
"{SAMPLER}void main() {{\n const float w[3] = float[3](0.25, 0.5, 0.25);\n const float scale = k * 2.0;\n \
|
||||
float s = 0.0;\n for (int i = 0; i < 3; i++) s += w[i];\n gl_FragColor = vec4(s * scale);\n}}\n"
|
||||
));
|
||||
}
|
||||
@@ -177,7 +177,12 @@ fn real_packs_read() {
|
||||
clouds: Some(terrain.clone()),
|
||||
weather: Some(terrain.clone()),
|
||||
lines: Some(terrain.clone()),
|
||||
hand: Some(terrain.clone()),
|
||||
// As the game's hand (`pack_hand::inputs`): it may read `currentRenderedItemId`
|
||||
// undeclared.
|
||||
hand: Some(horizon_shaders::GbuffersInputs {
|
||||
item_id_declared: true,
|
||||
..terrain.clone()
|
||||
}),
|
||||
terrain_geometry: Some(horizon_shaders::GeometryInputs {
|
||||
inputs: horizon_shaders::GbuffersInputs {
|
||||
declarations: format!(
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Original synthetic inputs; no third-party shader code is included.
|
||||
use horizon_shaders::{Origin, Preprocessed, Target, translate_composite, translate_compute};
|
||||
|
||||
fn source(code: &str) -> Preprocessed {
|
||||
Preprocessed {
|
||||
code: code.into(),
|
||||
files: vec!["original.glsl".into()],
|
||||
lines: code
|
||||
.lines()
|
||||
.enumerate()
|
||||
.map(|(i, _)| Origin {
|
||||
file: 0,
|
||||
line: i as u32 + 1,
|
||||
})
|
||||
.collect(),
|
||||
version: Some((430, None)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
const VERTEX: &str = "void main() { gl_Position = vec4(0.0); }";
|
||||
fn program(declarations: &str) -> horizon_shaders::Program {
|
||||
translate_composite(
|
||||
&source(VERTEX),
|
||||
&source(&format!(
|
||||
"{declarations}\nout vec4 color; void main() {{ color = vec4(1.0); }}"
|
||||
)),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn words(p: &horizon_shaders::Program, name: &str, n: usize) -> Vec<u32> {
|
||||
let offset = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == name)
|
||||
.unwrap()
|
||||
.offset as usize;
|
||||
p.interface.initial_values[offset..offset + n * 4]
|
||||
.chunks_exact(4)
|
||||
.map(|b| u32::from_le_bytes(b.try_into().unwrap()))
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn authored_scalar_and_vector_values_use_compiler_arithmetic_and_exact_integer_bits() {
|
||||
let p = program(
|
||||
"const float scale = 0.125; uniform float gain = scale * 3.0, bias = -0.5; uniform bool enabled = true; uniform uint mask = 4294967295u; uniform ivec4 shifts = ivec4(-7, 2, -3, 9); uniform vec3 tint = vec3(0.25, 0.5, 0.75); uniform float absent;",
|
||||
);
|
||||
assert_eq!(words(&p, "gain", 1), [0.375f32.to_bits()]);
|
||||
assert_eq!(words(&p, "bias", 1), [(-0.5f32).to_bits()]);
|
||||
assert_eq!(words(&p, "enabled", 1), [1]);
|
||||
assert_eq!(words(&p, "mask", 1), [u32::MAX]);
|
||||
assert_eq!(
|
||||
words(&p, "shifts", 4),
|
||||
[(-7i32) as u32, 2, (-3i32) as u32, 9]
|
||||
);
|
||||
assert_eq!(
|
||||
words(&p, "tint", 3),
|
||||
[0.25f32.to_bits(), 0.5f32.to_bits(), 0.75f32.to_bits()]
|
||||
);
|
||||
assert_eq!(words(&p, "absent", 1), [0]);
|
||||
}
|
||||
#[test]
|
||||
fn matrix_columns_and_arrays_keep_uniform_padding_and_neighbor_values() {
|
||||
let p = program(
|
||||
"uniform mat3 basis = mat3(1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0); uniform mat4 textureMatrix = mat4(1.0); uniform float stops[3] = float[](0.125, 0.375, 0.875); uniform vec4 after = vec4(0.625);",
|
||||
);
|
||||
let f = |x: f32| x.to_bits();
|
||||
assert_eq!(
|
||||
words(&p, "basis", 12),
|
||||
[
|
||||
f(1.),
|
||||
f(2.),
|
||||
f(3.),
|
||||
0,
|
||||
f(4.),
|
||||
f(5.),
|
||||
f(6.),
|
||||
0,
|
||||
f(7.),
|
||||
f(8.),
|
||||
f(9.),
|
||||
0
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
words(&p, "textureMatrix", 16),
|
||||
[
|
||||
f(1.),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
f(1.),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
f(1.),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
f(1.)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
words(&p, "stops", 12),
|
||||
[f(0.125), 0, 0, 0, f(0.375), 0, 0, 0, f(0.875), 0, 0, 0]
|
||||
);
|
||||
assert_eq!(words(&p, "after", 4), [f(0.625); 4]);
|
||||
}
|
||||
#[test]
|
||||
fn shared_stage_defaults_merge_by_value_and_conflicts_report_original_location() {
|
||||
let vertex = source("uniform float gain = 0.25; void main() { gl_Position = vec4(gain); }");
|
||||
let fragment = |decl: &str| {
|
||||
source(&format!(
|
||||
"{decl}\nout vec4 color; void main() {{ color = vec4(gain); }}"
|
||||
))
|
||||
};
|
||||
let p = translate_composite(
|
||||
&vertex,
|
||||
&fragment("uniform float gain = 1.0 / 4.0;"),
|
||||
Target::Buffers,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(words(&p, "gain", 1), [0.25f32.to_bits()]);
|
||||
let p =
|
||||
translate_composite(&vertex, &fragment("uniform float gain;"), Target::Buffers).unwrap();
|
||||
assert_eq!(words(&p, "gain", 1), [0.25f32.to_bits()]);
|
||||
let error = translate_composite(
|
||||
&vertex,
|
||||
&fragment("uniform float gain = 0.75;"),
|
||||
Target::Buffers,
|
||||
)
|
||||
.err()
|
||||
.unwrap();
|
||||
assert!(
|
||||
error
|
||||
.iter()
|
||||
.any(|e| e.file == "original.glsl" && e.line == 1 && e.message.contains("conflicting")),
|
||||
"{error:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn compute_programs_receive_initial_contents_and_nonconstant_uniform_initializers_fail() {
|
||||
let p = translate_compute(&source(
|
||||
"layout(local_size_x=1) in; uniform vec4 seed = vec4(0.125,0.25,0.5,1.0); void main() {}",
|
||||
))
|
||||
.unwrap();
|
||||
let u = p
|
||||
.interface
|
||||
.uniforms
|
||||
.iter()
|
||||
.find(|u| u.name == "seed")
|
||||
.unwrap();
|
||||
let b = &p.interface.initial_values[u.offset as usize..u.offset as usize + 16];
|
||||
assert_eq!(
|
||||
b,
|
||||
[0.125f32, 0.25, 0.5, 1.0]
|
||||
.iter()
|
||||
.flat_map(|v| v.to_le_bytes())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(translate_composite(&source(VERTEX), &source("uniform float runtime; uniform float value = runtime; out vec4 c; void main(){ c=vec4(value); }"), Target::Buffers).is_err());
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, RwLock};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// How urgent a job is, before its source's key: every queued job of a lower
|
||||
/// class runs before any of a higher one.
|
||||
@@ -114,6 +114,10 @@ struct Entry {
|
||||
/// Taken out of the pool: the worker that ends its last job tells
|
||||
/// [`Registration`]'s drop, which waits for it.
|
||||
retired: AtomicBool,
|
||||
/// When one of its jobs last ended (or it was registered).
|
||||
last_job: Mutex<Instant>,
|
||||
/// It was told it is idle ([`release_quiet_sources`]) since that job.
|
||||
quiet: AtomicBool,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
@@ -251,6 +255,8 @@ impl Pool {
|
||||
source,
|
||||
running: AtomicUsize::new(0),
|
||||
retired: AtomicBool::new(false),
|
||||
last_job: Mutex::new(Instant::now()),
|
||||
quiet: AtomicBool::new(false),
|
||||
}));
|
||||
self.inner.notify(true);
|
||||
Registration {
|
||||
@@ -359,6 +365,42 @@ fn choose(inner: &Inner) -> Option<(Arc<Entry>, bool)> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell each source that has had no job for the idle delay, while others keep
|
||||
/// the workers busy, that it is idle: its state in every worker
|
||||
/// ([`Source::idle`]) and what its workers share ([`Source::all_idle`]). The
|
||||
/// pool's own release needs every worker idle at once, which a client that
|
||||
/// keeps meshing after its world loaded never is, while its server's
|
||||
/// generator holds buffers and caches nothing reads until the player moves on.
|
||||
fn release_quiet_sources(inner: &Inner) {
|
||||
let quiet: Vec<Arc<Entry>> = inner
|
||||
.sources
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
!e.quiet.load(Ordering::SeqCst)
|
||||
&& e.running.load(Ordering::SeqCst) == 0
|
||||
&& lock(&e.last_job).elapsed() >= inner.idle_release
|
||||
&& e.source.next().is_none()
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut freed = false;
|
||||
for entry in quiet {
|
||||
if entry.quiet.swap(true, Ordering::SeqCst) {
|
||||
continue;
|
||||
}
|
||||
for worker in 0..inner.threads.load(Ordering::Relaxed) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.idle(worker)));
|
||||
}
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| entry.source.all_idle()));
|
||||
freed = true;
|
||||
}
|
||||
if freed {
|
||||
crate::release_free_memory();
|
||||
}
|
||||
}
|
||||
|
||||
/// One more far job running, if fewer than `cap` are.
|
||||
fn take_far_place(inner: &Inner, cap: usize) -> bool {
|
||||
let mut n = inner.far.load(Ordering::SeqCst);
|
||||
@@ -376,6 +418,11 @@ fn take_far_place(inner: &Inner, cap: usize) -> bool {
|
||||
|
||||
fn work(inner: &Inner, worker: usize) {
|
||||
let mut released = false;
|
||||
// When this worker last ran a job. A wake that finds no job for it (another
|
||||
// worker took it, or a source woke the pool for nothing) doesn't restart its
|
||||
// wait: with jobs queued every few seconds on some worker, a pool of idle
|
||||
// workers would otherwise never free anything.
|
||||
let mut idle_since = Instant::now();
|
||||
loop {
|
||||
let epoch = inner.epoch.load(Ordering::SeqCst);
|
||||
if let Some((entry, far)) = choose(inner) {
|
||||
@@ -393,11 +440,15 @@ fn work(inner: &Inner, worker: usize) {
|
||||
if far {
|
||||
inner.far.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
*lock(&entry.last_job) = Instant::now();
|
||||
entry.quiet.store(false, Ordering::SeqCst);
|
||||
entry.running.fetch_sub(1, Ordering::SeqCst);
|
||||
if entry.retired.load(Ordering::SeqCst) {
|
||||
drop(lock(&inner.sleep));
|
||||
inner.finished.notify_all();
|
||||
}
|
||||
idle_since = Instant::now();
|
||||
release_quiet_sources(inner);
|
||||
continue;
|
||||
}
|
||||
let sleep = lock(&inner.sleep);
|
||||
@@ -412,9 +463,10 @@ fn work(inner: &Inner, worker: usize) {
|
||||
);
|
||||
false
|
||||
} else {
|
||||
let left = inner.idle_release.saturating_sub(idle_since.elapsed());
|
||||
let (sleep, wait) = inner
|
||||
.wake
|
||||
.wait_timeout_while(sleep, inner.idle_release, still)
|
||||
.wait_timeout_while(sleep, left, still)
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
drop(sleep);
|
||||
wait.timed_out()
|
||||
@@ -439,13 +491,14 @@ fn work(inner: &Inner, worker: usize) {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| source.idle(worker)));
|
||||
}
|
||||
if last {
|
||||
// The allocator keeps freed pages until memory runs short: once
|
||||
// every worker has freed its own, hand them back.
|
||||
for source in &sources {
|
||||
let _ = catch_unwind(AssertUnwindSafe(|| source.all_idle()));
|
||||
}
|
||||
crate::release_free_memory();
|
||||
}
|
||||
// The allocator keeps freed pages until memory runs short: hand back
|
||||
// what this worker just freed (its own heap), and the shared arenas'
|
||||
// once every worker has.
|
||||
crate::release_free_memory();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -616,6 +669,72 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A pool woken far more often than its idle delay, with no job for most of
|
||||
/// its workers (the game queues a few meshes a second long after loading):
|
||||
/// each worker still frees its state once it has run nothing for the delay,
|
||||
/// and the whole pool once none has.
|
||||
#[test]
|
||||
fn workers_woken_for_nothing_still_free_their_state() {
|
||||
let release = Duration::from_millis(300);
|
||||
let pool = Pool::with_idle_release(4, "test-job", release);
|
||||
let counter = Arc::new(Counter {
|
||||
left: Mutex::new(0),
|
||||
done: AtomicUsize::new(0),
|
||||
per_worker: PerWorker::new(pool.threads()),
|
||||
idle: AtomicBool::new(false),
|
||||
all_idle: AtomicBool::new(false),
|
||||
});
|
||||
let reg = pool.register(counter.clone());
|
||||
let deadline = Instant::now() + release * 10;
|
||||
while !counter.all_idle.load(Ordering::Relaxed) {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"woken every 50 ms, the workers never released"
|
||||
);
|
||||
reg.notify_all();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(counter.idle.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
/// One source idle while another keeps the workers busy (a client meshing
|
||||
/// beside its server's generator): the idle one is still told, once.
|
||||
#[test]
|
||||
fn a_quiet_source_is_released_while_another_keeps_the_workers_busy() {
|
||||
let release = Duration::from_millis(300);
|
||||
let pool = Pool::with_idle_release(2, "test-job", release);
|
||||
let counter = |left| {
|
||||
Arc::new(Counter {
|
||||
left: Mutex::new(left),
|
||||
done: AtomicUsize::new(0),
|
||||
per_worker: PerWorker::new(pool.threads()),
|
||||
idle: AtomicBool::new(false),
|
||||
all_idle: AtomicBool::new(false),
|
||||
})
|
||||
};
|
||||
let (quiet, busy) = (counter(4), counter(0));
|
||||
let quiet_reg = pool.register(quiet.clone());
|
||||
let busy_reg = pool.register(busy.clone());
|
||||
quiet_reg.notify_all();
|
||||
let deadline = Instant::now() + release * 10;
|
||||
while !quiet.all_idle.load(Ordering::Relaxed) {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the quiet source was never released"
|
||||
);
|
||||
*lock(&busy.left) += 1;
|
||||
busy_reg.notify();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert_eq!(quiet.done.load(Ordering::Relaxed), 4);
|
||||
assert!(quiet.idle.load(Ordering::Relaxed));
|
||||
assert!(quiet.per_worker.slots.iter().all(|s| lock(s).is_none()));
|
||||
assert!(
|
||||
!busy.all_idle.load(Ordering::Relaxed),
|
||||
"the busy source is not idle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_workers_free_their_state() {
|
||||
let release = Duration::from_millis(300);
|
||||
|
||||
@@ -25,7 +25,7 @@ use horizon_world::{
|
||||
worldgen::{
|
||||
biome::BiomeId,
|
||||
blending::{BlendingData, Direction8, OldChunk, OldWorld, Sides},
|
||||
generator::{GeneratorData, OverworldGenerator, ProtoChunk},
|
||||
generator::{FlatSettings, GeneratorData, OverworldGenerator, ProtoChunk},
|
||||
overrides::{PackFile, WorldgenOverrides},
|
||||
},
|
||||
};
|
||||
@@ -48,6 +48,42 @@ fn registry(resources: &Path) -> Result<&'static BlockRegistry, Error> {
|
||||
Ok(Box::leak(Box::new(registry)))
|
||||
}
|
||||
|
||||
/// A `flat_level_generator_preset`'s settings, from the client resources' data.
|
||||
fn flat_preset(resources: &Path, id: &str) -> Result<FlatSettings, Error> {
|
||||
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
|
||||
let file = resources.join(format!(
|
||||
"data/{namespace}/worldgen/flat_level_generator_preset/{path}.json"
|
||||
));
|
||||
let json: Value = serde_json::from_str(&fs::read_to_string(&file)?)?;
|
||||
let s = &json["settings"];
|
||||
let layers = s["layers"]
|
||||
.as_array()
|
||||
.ok_or("preset layers")?
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let block = l["block"].as_str().ok_or("layer block")?.to_owned();
|
||||
Ok((block, l["height"].as_i64().ok_or("layer height")? as i32))
|
||||
})
|
||||
.collect::<Result<_, Error>>()?;
|
||||
let structure_overrides = match &s["structure_overrides"] {
|
||||
Value::Array(sets) => Some(
|
||||
sets.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect(),
|
||||
),
|
||||
Value::String(set) => Some(vec![set.clone()]),
|
||||
_ => None,
|
||||
};
|
||||
Ok(FlatSettings {
|
||||
layers,
|
||||
biome: s["biome"].as_str().unwrap_or("minecraft:plains").to_owned(),
|
||||
structure_overrides,
|
||||
features: s["features"].as_bool().unwrap_or(false),
|
||||
lakes: s["lakes"].as_bool().unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
/// Type-prefixed scalars with sorted compound keys, as the oracle prints them.
|
||||
fn canonical(tag: &Tag, out: &mut String) {
|
||||
match tag {
|
||||
@@ -268,8 +304,15 @@ pub fn main() -> Result<(), Error> {
|
||||
for warning in data.warnings() {
|
||||
eprintln!("data pack worldgen: {warning}");
|
||||
}
|
||||
let generator =
|
||||
OverworldGenerator::for_dimension(data.clone(), seed, "minecraft:normal", &dimension)?;
|
||||
// HORIZON_FLAT: a flat_level_generator_preset id; the overworld is that superflat world.
|
||||
let generator = match std::env::var("HORIZON_FLAT") {
|
||||
Ok(preset) => {
|
||||
OverworldGenerator::flat(data.clone(), seed, &flat_preset(&resources, &preset)?, true)?
|
||||
}
|
||||
Err(_) => {
|
||||
OverworldGenerator::for_dimension(data.clone(), seed, "minecraft:normal", &dimension)?
|
||||
}
|
||||
};
|
||||
let mut worker = generator.worker();
|
||||
let old_spec = std::env::var("HORIZON_OLD_CHUNKS").ok();
|
||||
let old_rect = match &old_spec {
|
||||
|
||||
@@ -958,6 +958,10 @@ pub struct FlatSettings {
|
||||
pub biome: String,
|
||||
/// `structure_overrides`: the structure sets that may generate (all of them when `None`).
|
||||
pub structure_overrides: Option<Vec<String>>,
|
||||
/// `features`: the biome's own placed features decorate the world.
|
||||
pub features: bool,
|
||||
/// `lakes`: the lava lakes (`createLakesList`) take the biome's lakes step.
|
||||
pub lakes: bool,
|
||||
}
|
||||
const CACHE_LIMIT: usize = 16384;
|
||||
/// What a generator's shared caches hold ([`OverworldGenerator::cache_census`]).
|
||||
@@ -3377,6 +3381,9 @@ impl OverworldGenerator {
|
||||
null.push(hole);
|
||||
}
|
||||
}
|
||||
// `updateLayers`: `voidGen` when every layer is air (no layers at all, too).
|
||||
let air = registry.default_state("minecraft:air");
|
||||
let void_gen = states.iter().all(|&id| Some(id) == air);
|
||||
states.truncate(level_height.max(0) as usize);
|
||||
null.truncate(states.len());
|
||||
// `FlatLevelSource.getSeaLevel`; its minimum Y and depth are in `generation`.
|
||||
@@ -3393,15 +3400,22 @@ impl OverworldGenerator {
|
||||
states,
|
||||
null,
|
||||
});
|
||||
// No placed features here (the layers are the whole terrain), but the structure
|
||||
// steps run: the plan keeps the steps and leaves their feature lists empty.
|
||||
pipeline.decoration =
|
||||
super::generator_decoration::Decoration::new_with(&data, &pipeline.source, false)?.map(
|
||||
|mut decoration| {
|
||||
decoration.top_layers = top_layers;
|
||||
Arc::new(decoration)
|
||||
},
|
||||
);
|
||||
// `FlatLevelSource` decorates with `adjustGenerationSettings` of the biome.
|
||||
let adjust = super::generator_decoration::FlatDecoration::new(
|
||||
flat.features,
|
||||
flat.lakes,
|
||||
void_gen,
|
||||
biome == "minecraft:the_void",
|
||||
);
|
||||
pipeline.decoration = super::generator_decoration::Decoration::new_with(
|
||||
&data,
|
||||
&pipeline.source,
|
||||
Some(adjust),
|
||||
)?
|
||||
.map(|mut decoration| {
|
||||
decoration.top_layers = top_layers;
|
||||
Arc::new(decoration)
|
||||
});
|
||||
if let (Some(state), Some(only)) = (pipeline.structures.as_mut(), &flat.structure_overrides)
|
||||
{
|
||||
state.restrict_to(only);
|
||||
@@ -4203,6 +4217,75 @@ mod tests {
|
||||
Some(GeneratorData::load(&root.join("worldgen"), registry).expect("worldgen data"))
|
||||
}
|
||||
|
||||
/// A superflat world decorates with what `adjustGenerationSettings` keeps of its biome
|
||||
/// (26.3 bytecode): the lava lakes of `createLakesList` in the lakes step (0 and 1) when
|
||||
/// `lakes` is on; the biome's own features with `features` on, but those of the two
|
||||
/// structure steps (plains' monster rooms are underground structures); nothing of the
|
||||
/// biome when every layer is air, unless the biome is the_void.
|
||||
#[test]
|
||||
fn a_superflat_world_keeps_what_adjust_generation_settings_keeps() {
|
||||
let Some(data) = prepared_data() else {
|
||||
return;
|
||||
};
|
||||
use super::super::generator_decoration::{Decoration, FlatDecoration};
|
||||
let plains = ResolvedBiomeSource::Fixed(super::super::biome_source::FixedBiomeSource {
|
||||
biome: "minecraft:plains".into(),
|
||||
});
|
||||
let kept = |layer: &str, features, lakes, name: &str| {
|
||||
let void_gen = layer == "minecraft:air";
|
||||
let adjust = FlatDecoration::new(features, lakes, void_gen, false);
|
||||
let decoration = Decoration::new_with(&data, &plains, Some(adjust))
|
||||
.unwrap()
|
||||
.expect("decoration");
|
||||
decoration.seed(name).map(|(_, index, step)| (index, step))
|
||||
};
|
||||
let grass = "minecraft:grass_block";
|
||||
// The Overworld preset: lakes and features.
|
||||
assert_eq!(
|
||||
kept(grass, true, true, "minecraft:lake_lava_underground"),
|
||||
Some((0, 1))
|
||||
);
|
||||
assert_eq!(
|
||||
kept(grass, true, true, "minecraft:lake_lava_surface"),
|
||||
Some((1, 1))
|
||||
);
|
||||
assert_eq!(
|
||||
kept(grass, true, true, "minecraft:trees_plains").map(|s| s.1),
|
||||
Some(9)
|
||||
);
|
||||
assert_eq!(
|
||||
kept(grass, true, true, "minecraft:freeze_top_layer").map(|s| s.1),
|
||||
Some(10)
|
||||
);
|
||||
assert_eq!(kept(grass, true, true, "minecraft:monster_room"), None);
|
||||
// Lakes alone: no tree, no flower, no ore.
|
||||
assert_eq!(
|
||||
kept(grass, false, true, "minecraft:lake_lava_surface"),
|
||||
Some((1, 1))
|
||||
);
|
||||
assert_eq!(kept(grass, false, true, "minecraft:trees_plains"), None);
|
||||
assert_eq!(kept(grass, false, true, "minecraft:ore_iron_upper"), None);
|
||||
// Features alone: plains' own lakes step stays.
|
||||
assert_eq!(
|
||||
kept(grass, true, false, "minecraft:lake_lava_surface").map(|s| s.1),
|
||||
Some(1)
|
||||
);
|
||||
assert_eq!(
|
||||
kept(grass, true, false, "minecraft:ore_iron_upper").map(|s| s.1),
|
||||
Some(6)
|
||||
);
|
||||
// Neither: the steps run for structures, with no feature in them.
|
||||
assert_eq!(
|
||||
kept(grass, false, false, "minecraft:lake_lava_surface"),
|
||||
None
|
||||
);
|
||||
// Every layer air (`voidGen`) and a biome other than the_void: no biome features.
|
||||
assert_eq!(
|
||||
kept("minecraft:air", true, false, "minecraft:trees_plains"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
/// Seed 1234's strongholds where vanilla 26.3 puts them
|
||||
/// (VanillaStructureStartOracle with HORIZON_RINGS). Each search sampled
|
||||
/// its quarts through fresh resolvers, which broke climate ties their own
|
||||
|
||||
@@ -113,19 +113,56 @@ fn possible_biomes(source: &ResolvedBiomeSource) -> Vec<String> {
|
||||
.filter(|name| seen.insert(name.clone()))
|
||||
.collect()
|
||||
}
|
||||
/// `FlatLevelGeneratorSettings.adjustGenerationSettings`: what a superflat world keeps of its
|
||||
/// biome's generation settings.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct FlatDecoration {
|
||||
/// `(!voidGen || biome is the_void) && decoration`: the biome's own placed features, but
|
||||
/// those of the two structure steps (and of the lakes step when `lakes` is on).
|
||||
pub(super) features: bool,
|
||||
/// `addLakes`: the lakes step is `createLakesList`.
|
||||
pub(super) lakes: bool,
|
||||
}
|
||||
/// `GenerationStep.Decoration` ordinals `adjustGenerationSettings` treats on their own.
|
||||
const LAKES_STEP: usize = 1;
|
||||
const UNDERGROUND_STRUCTURES_STEP: usize = 3;
|
||||
const SURFACE_STRUCTURES_STEP: usize = 4;
|
||||
/// `FlatLevelGeneratorSettings.createLakesList`.
|
||||
const FLAT_LAKES: [&str; 2] = [
|
||||
"minecraft:lake_lava_underground",
|
||||
"minecraft:lake_lava_surface",
|
||||
];
|
||||
impl FlatDecoration {
|
||||
/// The settings' `features` and `lakes`, for layers that are all air (`void_gen`) or not and a
|
||||
/// biome that is the_void or not.
|
||||
pub(super) fn new(features: bool, lakes: bool, void_gen: bool, the_void: bool) -> Self {
|
||||
Self {
|
||||
features: (!void_gen || the_void) && features,
|
||||
lakes,
|
||||
}
|
||||
}
|
||||
/// Which of the biome's own entries step `step` keeps.
|
||||
fn keeps_biome_step(self, step: usize) -> bool {
|
||||
self.features
|
||||
&& step != UNDERGROUND_STRUCTURES_STEP
|
||||
&& step != SURFACE_STRUCTURES_STEP
|
||||
&& !(self.lakes && step == LAKES_STEP)
|
||||
}
|
||||
}
|
||||
impl Decoration {
|
||||
pub(super) fn new(
|
||||
data: &Arc<GeneratorData>,
|
||||
source: &ResolvedBiomeSource,
|
||||
) -> Result<Option<Self>, GeneratorError> {
|
||||
Self::new_with(data, source, true)
|
||||
Self::new_with(data, source, None)
|
||||
}
|
||||
/// `new`, with `features` off: the biome's decoration steps, none of its placed features
|
||||
/// (a superflat world without `features`).
|
||||
/// `new` for a superflat world (`flat`): its fixed biome's settings as
|
||||
/// `adjustGenerationSettings` leaves them. The steps stay (structures place in them);
|
||||
/// the `FillLayerFeature`s are `top_layers`.
|
||||
pub(super) fn new_with(
|
||||
data: &Arc<GeneratorData>,
|
||||
source: &ResolvedBiomeSource,
|
||||
with_features: bool,
|
||||
flat: Option<FlatDecoration>,
|
||||
) -> Result<Option<Self>, GeneratorError> {
|
||||
let Some(resources) = &data.feature_resources else {
|
||||
return Ok(None);
|
||||
@@ -201,8 +238,25 @@ impl Decoration {
|
||||
.ok_or_else(|| resource(format!("unknown biome {name}")))?;
|
||||
let json: Value = serde_json::from_str(data.biome_json(id))?;
|
||||
let mut steps = Vec::new();
|
||||
for step in json["features"].as_array().into_iter().flatten() {
|
||||
for (index, step) in json["features"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.enumerate()
|
||||
{
|
||||
let mut list = Vec::new();
|
||||
// The builder takes the lakes first, then the biome's steps it keeps.
|
||||
if flat.is_some_and(|f| f.lakes) && index == LAKES_STEP {
|
||||
for name in FLAT_LAKES {
|
||||
let feature = identity(name);
|
||||
list.push(feature);
|
||||
biome_features[id.0 as usize].insert(feature);
|
||||
}
|
||||
}
|
||||
if flat.is_some_and(|f| !f.keeps_biome_step(index)) {
|
||||
steps.push(list);
|
||||
continue;
|
||||
}
|
||||
let entries = match step {
|
||||
Value::String(tag) if tag.starts_with('#') => tags
|
||||
.get(&super::tags::identifier(&tag[1..])?)
|
||||
@@ -220,15 +274,19 @@ impl Decoration {
|
||||
_ => return Err(resource("biome feature step")),
|
||||
};
|
||||
for entry in entries {
|
||||
if !with_features {
|
||||
continue;
|
||||
}
|
||||
let feature = identity(&super::tags::identifier(&entry)?);
|
||||
list.push(feature);
|
||||
biome_features[id.0 as usize].insert(feature);
|
||||
}
|
||||
steps.push(list);
|
||||
}
|
||||
// A biome with fewer steps still gets the lakes (`PlainBuilder` grows its list).
|
||||
if flat.is_some_and(|f| f.lakes) && steps.len() <= LAKES_STEP {
|
||||
steps.resize_with(LAKES_STEP, Vec::new);
|
||||
let lakes = FLAT_LAKES.map(&mut identity).to_vec();
|
||||
biome_features[id.0 as usize].extend(lakes.iter().copied());
|
||||
steps.push(lakes);
|
||||
}
|
||||
plan.push((id, steps));
|
||||
}
|
||||
let plan = FeatureDecorationPlan::new(plan).map_err(|e| resource(e.to_string()))?;
|
||||
|
||||
@@ -174,7 +174,7 @@ HTTP and JSON on 127.0.0.1. A running server writes its address and a random tok
|
||||
| `GET` | `/api/v1/backups/download` | `?name=<file>` | the zip | Download a backup |
|
||||
| `POST` | `/api/v1/restore` | `{"name": "<backup>", "dimension": "minecraft:the_nether"}` (`dimension` optional) | `{"name": "<backup>", "dimension": "<id>" or null}` | Restart the game with that backup's world, or only one dimension of it (what it replaces is kept in backups/) |
|
||||
| `POST` | `/api/v1/update` | `{"tag": "...", "timeout": seconds}` (both optional) | `{"ok": true}`; `status` says how it went (`update`) | Restart on that installed release, or download the newest first; goes back if the game isn't up within the timeout |
|
||||
| `GET` | `/api/v1/metrics/prometheus` | nothing | the latest sample as Prometheus text: `horizon_up`, `horizon_tps`, `horizon_mspt{stat}`, `horizon_players_online`, `horizon_memory_rss_bytes`... | Metrics for a monitoring tool to scrape |
|
||||
| `GET` | `/api/v1/metrics/prometheus` | nothing | the latest sample as Prometheus text: `horizon_up`, `horizon_tps`, `horizon_mspt{stat}`, `horizon_players_online`, `horizon_memory_rss_bytes`, `horizon_loaded_chunks{dimension}` and `horizon_entities{dimension}` (players and block entities apart)... | Metrics for a monitoring tool to scrape |
|
||||
| `GET` | `/api/v1/players` | nothing | `online` (name, uuid, address, ping_ms, position, op_level), `max_players`, `ops`, `whitelist` (`enabled`, `players`), `bans`, `ip_bans` (with reason, source, created, expires) | Who is online, and vanilla's lists |
|
||||
| `GET` | `/api/v1/mods` | nothing | `engine` (`found`, `target`, `base`, `horizon`, `hooks`) and `native` (id, version, sha256), from the `native-mods.json` the launcher wrote beside the game program | The native mods the server's game program was built with |
|
||||
| `POST` | `/api/v1/players/kick` | `{"name", "reason"}` (reason optional) | `{"changed": bool, "output": [...]}`: whether the list (or who is online) changed, and the console's lines | Kick an online player |
|
||||
|
||||
+3
-2
@@ -75,14 +75,14 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
|
||||
## Not there yet
|
||||
|
||||
- **Metrics**: entities and per-dimension counts in the Prometheus text.
|
||||
- **Mods on a dedicated server**: turn on and off, and their settings, once native mods can be built for the
|
||||
server ([mods.md](../mods.md), [native-mods.md](../native-mods.md#multiplayer)). `GET /api/v1/mods` lists the native
|
||||
mods the game program was built with.
|
||||
- **Vanilla's management protocol**: `serversettings/*` (the live settings not yet done) and the world
|
||||
upgrade's notifications (the others are sent). The methods answer
|
||||
an internal error saying so; a kick's translatable message is sent as its key.
|
||||
- **Compatibility tests**: golden replies per endpoint, and a `server.toml` from each release line.
|
||||
- **Compatibility tests**: golden replies for the remaining endpoint groups (the lifecycle replies are pinned),
|
||||
and a `server.toml` from each release line.
|
||||
|
||||
## Changelog
|
||||
|
||||
@@ -93,6 +93,7 @@ addition; a change to one starts `/api/v2`, served beside `/api/v1`.
|
||||
`whitelist/{add,remove}`, stopped-server list edits, `metrics/prometheus`, `GET`/`PATCH config`,
|
||||
`DELETE backups`, `GET backups/download`; `POST start` resumes a stopped supervisor;
|
||||
`server.toml` gets `[api]` (`bind`, `token`, `allow_public`); `GET events` (server-sent events, or JSON).
|
||||
- The API compatibility suite pins the `/api/v1` status, metrics, logs, start, stop and restart replies.
|
||||
- RCON (`enable-rcon`, `rcon.port`, `rcon.password`): vanilla's remote console, served by the supervisor.
|
||||
- GS4 query (`enable-query`, `query.port`): vanilla's UDP query (handshake, basic and full stat), served by the
|
||||
supervisor.
|
||||
|
||||
+23
-20
@@ -1,9 +1,9 @@
|
||||
# Cosmetics
|
||||
|
||||
Small items Horizon players wear: a hat, something on the back, a pet on the shoulder, a style for
|
||||
their name tag. They are
|
||||
drawn by Horizon clients only and change nothing in the game. **Off by default**: nothing below
|
||||
runs until the feature flag is turned on.
|
||||
Small items Horizon players wear: a hat, something on the back, a shirt, a pet on the shoulder, a
|
||||
style for their name tag. They are drawn by Horizon clients only and change nothing in the game.
|
||||
**On by default** (Ludovic, 2026-11-04: on when the shop opens), other players' and your own shown;
|
||||
the settings below hide them.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -39,13 +39,17 @@ y points down, as in vanilla's `ModelPart`s and Blockbench's "Modded Entity" mod
|
||||
}
|
||||
```
|
||||
|
||||
- `slot`: `head`, `back`, `shoulder` or `name_tag`; a player wears at most one item per slot.
|
||||
- `slot`: `head`, `back`, `torso`, `shoulder` or `name_tag`; a player wears at most one item per
|
||||
slot. A `torso` item is hidden under a chestplate (not an elytra).
|
||||
- `bone`: the player model part the item follows (`head`, `body`, `left_arm`, `right_arm`,
|
||||
`left_leg`, `right_leg`; `head` if left out). The item sits at the bone's matrix times `attach` (offset in pixels,
|
||||
rotation in degrees, a uniform scale in (0, 2]).
|
||||
- `parts`: a tree of parts, each with a pivot, a rotation in degrees and boxes with Minecraft's box
|
||||
UV (`ModelPart.Cube`'s faces and order), grown by `inflate`, mirrored in x on request. Names are
|
||||
unique.
|
||||
unique and never start with `cosmetic`.
|
||||
- A **top-level part's `bone`** (optional) makes it ride another bone than the item's, under the
|
||||
same attachment: a shirt's sleeves (`"bone": "right_arm"`) follow the arms as they swing. Only
|
||||
top-level parts take one; the reach limit counts from each part's own bone.
|
||||
- `animations`: loops on top of a part's rest pose, a sine of `period` ticks (4 to 1200) started
|
||||
`phase` periods in. `swing` turns the part `amplitude` degrees (at most 45) about the axis, `bob`
|
||||
moves it `amplitude` pixels (at most 4), `spin` turns it all the way round once per period.
|
||||
@@ -92,9 +96,9 @@ does the asking.
|
||||
|
||||
## In the game
|
||||
|
||||
- **The flag**: `horizon_cosmetics::ON_BY_DEFAULT` is `false`. Until it is turned on nothing runs:
|
||||
no thread, no request, no setting, no drawing. `HORIZON_COSMETICS=1` turns them on for one run
|
||||
(development, the bench); `HORIZON_COSMETICS_API=<url>` points the game at another service, and
|
||||
- **The flag**: `horizon_cosmetics::ON_BY_DEFAULT` is `true`. `HORIZON_COSMETICS=0` turns
|
||||
everything off for one run (no thread, no request, no setting, no drawing), `=1` on;
|
||||
`HORIZON_COSMETICS_API=<url>` points the game at another service, and
|
||||
`HORIZON_COSMETICS_LOCAL=<folder>` reads items from a folder instead: every player and every
|
||||
mannequin wears its first item of each slot (the art and the bench, without the network).
|
||||
- **Settings** (Extras, under "Cosmetics", shown only with the flag on), the same three as the
|
||||
@@ -141,10 +145,12 @@ players see it.
|
||||
`PUT /v1/visible {"visible"}` ("Show my cosmetics": off, `/v1/worn` leaves the player out) with
|
||||
that session (`Authorization: Bearer`), kept in memory for the time the service gave, less a
|
||||
minute.
|
||||
- **The founder set** is free and can be claimed only between the window's start and end; after
|
||||
that it is retired for good and never offered again. Players who claimed it keep it.
|
||||
- **Launcher themes** (`"slot": "theme"`) have no item file: the catalogue carries their six sky
|
||||
stops and accent hue, and the game never asks for them.
|
||||
- **The founder set** (Founder Visor, Founder Tee, Founder Backpack, Founder Tag) is free and can
|
||||
be claimed only between the window's start and end; after that it is retired for good and never
|
||||
offered again. Players who claimed it keep it.
|
||||
- **Launcher themes** are never claimed or sold: they are free for everyone in Settings ›
|
||||
Appearance (*Launcher theme*: Horizon or First Light, `horizon_launcher::themes::THEMES`). A
|
||||
catalogue entry with `"slot": "theme"` is ignored.
|
||||
- **Offline**: the last catalogue and answer, and the changes not sent yet, are kept per account
|
||||
in the launcher's data folder (`wardrobe/<uuid>.json`; never a session or token), item files
|
||||
under their hash. A change shows at once; the ones the service couldn't take (offline, or a
|
||||
@@ -154,14 +160,14 @@ players see it.
|
||||
## The Wardrobe page (`launcher/src/wardrobe_page.rs`)
|
||||
|
||||
A top-bar page between Servers and Versions, for the selected Microsoft account (signed out, it asks
|
||||
you to sign in). It shows with the flag on, and also, whatever the flag says, when the service has a
|
||||
founder set to show (`horizon_launcher::shop`): the launcher asks `GET /v1/shop` at start and every
|
||||
you to sign in). It shows only when the service has a founder set to show (or with
|
||||
`HORIZON_COSMETICS=1`, for development), whatever cosmetics in the game do (`horizon_launcher::shop`): the launcher asks `GET /v1/shop` at start and every
|
||||
half hour, with no session and nothing about the player. The page shows when the set can be claimed
|
||||
now (`open`), when the public window has dates (to say when it opens, or that it is over), or when the
|
||||
selected account's own answer, kept from its last visit, does (a claim, or a preview the service grants
|
||||
that account). With no window set, the page stays hidden.
|
||||
|
||||
- **A row per slot** (head, back, shoulder pet, name tag, launcher theme) with what it wears,
|
||||
- **A row per slot** (head, back, shirt, shoulder pet, name tag) with what it wears,
|
||||
"Nothing equipped" or "None owned yet"; **your player** in the middle, on your own skin, wearing
|
||||
it, with your name tag style over the head (`horizon_cosmetics::preview`); **what you own for the
|
||||
chosen slot**, "Nothing" first. Equipping shows at once.
|
||||
@@ -175,9 +181,6 @@ that account). With no window set, the page stays hidden.
|
||||
- **States**: offline, the page keeps working from what's kept on disk and says how many changes
|
||||
wait to sync; a service failure says what you wear still shows in the game, with *Try again* and
|
||||
*Copy details* (host, status, time).
|
||||
- **A launcher theme** worn (First Light) replaces the sky's six stops and the accent's hue
|
||||
(`Saved::theme`, `Theme::with_sky`), from the outfit kept on disk, so it holds offline too. Taking
|
||||
it off brings the player's own look back.
|
||||
- `HORIZON_LAUNCHER_PAGE=wardrobe` in a development build shows the page for a made-up account
|
||||
wearing `HORIZON_COSMETICS_LOCAL`'s items; `HORIZON_WARDROBE_STATE=offline|failed|closed|empty`
|
||||
shows that state, `theme` wears First Light.
|
||||
shows that state.
|
||||
+10
-3
@@ -176,8 +176,8 @@ than Pixelify. Hover and focus are vanilla's white edge.
|
||||
- **Inventory tabs** switch the kinds in Mods: the open tab joins the panel under it.
|
||||
- **Slots** hold pictures: a mod's or pack's own icon and gallery images from its source,
|
||||
downloaded when first shown and kept on disk (`launcher/src/browser/images.rs`); the launcher
|
||||
ships no third-party pictures. The Horizon instance shows a grass block drawn from the player's
|
||||
own prepared Minecraft textures. Sources are named in text ("from Modrinth"): Modrinth's logo is
|
||||
ships no third-party pictures. Plain Minecraft shows Horizon's own grass block
|
||||
(`tools/launcher_grass_block.py`), Horizon the brand icon (`docs/media/brand/icon.svg`). Sources are named in text ("from Modrinth"): Modrinth's logo is
|
||||
all rights reserved, so it is not shown.
|
||||
- **Partner banner**: the one exception to shipping no third-party pictures. Sculk, a server host,
|
||||
sponsors Horizon: its banner (`launcher/assets/sculk-banner.webp`, `launcher/src/partner.rs`) sits
|
||||
@@ -321,8 +321,15 @@ as `--glass-*`.
|
||||
`HORIZON_LAUNCHER_MOTION` works as for Night. Dark appearance only so far; Light keeps the dawn.
|
||||
- **Frosted plates, round corners, no borders.** A plate is the scene behind it blurred 24 px and
|
||||
saturated 140 %, under white at 6 % and the accent's dark violet (`base`) at 22 %. Menus and
|
||||
popovers blur 48 px and darken to 62 %. Corners (`glass::radius`): dialogs 28, panels 20,
|
||||
popovers take the same glass with a shadow (the mockup's darker `hzl-float` read as another
|
||||
material beside the cards); toasts keep it. Corners (`glass::radius`): dialogs 28, panels 20,
|
||||
menus 16, the Play group 14, rows and chips 12, buttons and tabs 10. No pixel steps, no strokes.
|
||||
- **Motion as the website plays it** (`launcher/src/motion.rs`): hovers ease over `quick`'s
|
||||
150 ms curve, toasts and a new page arrive as `hzl-pop` (a fade and a 6 px rise over 200 ms,
|
||||
`cubic-bezier(0.2, 0.7, 0.2, 1)`), a switch's knob slides for 200 ms. Menus open at once. Under
|
||||
reduced motion each is drawn where it ends.
|
||||
- **Light**: plates are white at 84 % over the dawn sky washed with white (55 %), so the page
|
||||
reads white; icons and text on the accent keep `on-accent`.
|
||||
- **One accent, mixed like the mockup.** `accent` (`#6b4bff`) fills the main action;
|
||||
`accent-soft` (half accent, half white in OKLab) draws progress, ticks, links and the focus ring;
|
||||
text on the accent turns dark when a player's accent is light. `Glass::new` does the mockup's
|
||||
|
||||
+3
-3
@@ -127,9 +127,9 @@ browser installed from it, the URLs asked for in one request (`GET /projects?ids
|
||||
(`fabric.mod.json`/`quilt.mod.json` `icon`, `mods.toml` `logoFile`), read from the jar off the UI thread and scaled to
|
||||
the row; else a generic glyph. The tab builds only the rows near the view, so a pack of hundreds costs what a few do.
|
||||
|
||||
**Vanilla Minecraft** is marked with a grass block drawn at run time from the player's own prepared game files
|
||||
(`assets/minecraft/textures/block/grass_block_*.png`, tinted with the plains grass colour): the game-version filter
|
||||
shows it. No Mojang file or logo is in the launcher or the repository.
|
||||
**Vanilla Minecraft** is marked with Horizon's own grass block (`launcher/assets/logos/grass-block.png`, drawn by
|
||||
`tools/launcher_grass_block.py` from our palette, not from Mojang's textures): the game-version filter, the Java Edition
|
||||
tab and plain Minecraft instances show it. No Mojang file or logo is in the launcher or the repository.
|
||||
|
||||
**Source logos.** Modrinth's logo is not shown: Modrinth's own repository says its branding is all rights reserved and
|
||||
"the use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, ... is strictly
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} van {max}",
|
||||
"{support} in Horizon": "{support} in Horizon",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 dag",
|
||||
"1 day {h} h": "1 dag {h} u",
|
||||
"April": "April",
|
||||
"August": "Augustus",
|
||||
"Back to the wardrobe": "Terug na die klerekas",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Gebind aan jou Minecraft-rekening, op elke rekenaar waarop jy aanmeld.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Gebind aan jou Minecraft-rekening. Niks om te betaal nie, geen kaart gevra nie.",
|
||||
"Changes waiting to sync: {n}": "Veranderinge wat op sinkronisering wag: {n}",
|
||||
"Claimed · Open the wardrobe": "Opgeëis · Maak die klerekas oop",
|
||||
"Claiming…": "Eis tans op…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetiese items is net vir Horizon-spelers sigbaar. Niks verander hoe die speletjie speel nie.",
|
||||
"December": "Desember",
|
||||
"February": "Februarie",
|
||||
"First Light": "Eerste Lig",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Vir die spelers wat eerste hier was. Gratis om op te eis vanaf {date}; sodra jy dit opgeëis het, is dit joune om te hou.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Vir die spelers wat eerste hier was. Gratis om op te eis tot {date}; sodra jy dit opgeëis het, is dit joune om te hou.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Vir die spelers wat eerste hier was. Gratis om op te eis; sodra jy dit opgeëis het, is dit joune om te hou.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Vir die spelers wat eerste hier was. Die datums om dit op te eis is nog nie vasgestel nie.",
|
||||
"Free": "Gratis",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Gratis vir almal. Eerste Lig verf die lug net voor sonsopkoms agter die ligte voorkoms, met 'n blou aksent.",
|
||||
"Free for founders": "Gratis vir stigters",
|
||||
"Free for founders, until {date}": "Gratis vir stigters, tot {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Dit word nie meer aangebied nie en sal nie verkoop word nie. Spelers wat dit opgeëis het, behou dit.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Dit was gratis om op te eis tot {date}. Dit word nie meer aangebied nie en sal nie verkoop word nie; joune bly joune.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Dit is in jou klerekas, joune om te hou. Dra dit vanaf die Klerekas-oortjie.",
|
||||
"January": "Januarie",
|
||||
"July": "Julie",
|
||||
"June": "Junie",
|
||||
"March": "Maart",
|
||||
"May": "Mei",
|
||||
"Not open yet": "Nog nie oop nie",
|
||||
"November": "November",
|
||||
"October": "Oktober",
|
||||
"Open the wardrobe": "Maak die klerekas oop",
|
||||
"Opens in {time}": "Maak oop oor {time}",
|
||||
"Opens {date}": "Maak oop op {date}",
|
||||
"Owned": "In besit",
|
||||
"Preview": "Voorskou",
|
||||
"September": "September",
|
||||
"Shirt": "Hemp",
|
||||
"Shop": "Winkel",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Meld aan om dit op te eis. Kosmetiese items behoort aan die Microsoft-rekening wat Minecraft: Java Edition besit, so hulle volg jou na elke rekenaar.",
|
||||
"Soon": "Binnekort",
|
||||
"The claim didn't go through: {why}": "Die opeis het nie deurgegaan nie: {why}",
|
||||
"The founder set": "Die stigtersstel",
|
||||
"The founder set isn't offered any more.": "Die stigtersstel word nie meer aangebied nie.",
|
||||
"The founder set was free to claim until {date}.": "Die stigtersstel was gratis om op te eis tot {date}.",
|
||||
"The shop didn't load.": "Die winkel het nie gelaai nie.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Die winkel het 'n verbinding nodig. Jy kan steeds verander wat jy dra in die Klerekas: dit stoor op hierdie rekenaar en sinkroniseer wanneer jy weer aanlyn is.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Goed om vir jou speler te dra, sigbaar vir almal wat op Horizon speel.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Wat jy dra, wys steeds in die speletjie: die lanseerder het jou laaste uitrusting gehou. Probeer weer oor 'n oomblik.",
|
||||
"You're offline.": "Jy is vanlyn.",
|
||||
"Your name": "Jou naam",
|
||||
"less than a minute": "minder as 'n minuut",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} dae",
|
||||
"{d} days {h} h": "{d} dae {h} u",
|
||||
"{h} h {m} min": "{h} u {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "{time} oor om dit op te eis"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "«حفظ التغييرات» يطبّق التبديل. لا يُحذف شيء من مجلده.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "تبقى إضافاته المصنوعة لـ {loader} في مجلده لكنها لن تعمل.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "تبقى إضافاته المصنوعة لـ Minecraft {from} في مجلده؛ قد لا يعمل بعضها في {to}.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "حُفظت عوالمه بـ Minecraft {from}: يرقّيها {to} نهائيًا، ولا يستطيع فتحها إن كان أقدم."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "حُفظت عوالمه بـ Minecraft {from}: يرقّيها {to} نهائيًا، ولا يستطيع فتحها إن كان أقدم.",
|
||||
"January": "يناير",
|
||||
"February": "فبراير",
|
||||
"March": "مارس",
|
||||
"April": "أبريل",
|
||||
"May": "مايو",
|
||||
"June": "يونيو",
|
||||
"July": "يوليو",
|
||||
"August": "أغسطس",
|
||||
"September": "سبتمبر",
|
||||
"October": "أكتوبر",
|
||||
"November": "نوفمبر",
|
||||
"December": "ديسمبر",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{date}, {time}": "{date}، {time}",
|
||||
"less than a minute": "أقل من دقيقة",
|
||||
"{m} min": "{m} د",
|
||||
"{h} h {m} min": "{h} س {m} د",
|
||||
"1 day": "يوم واحد",
|
||||
"1 day {h} h": "يوم واحد و{h} س",
|
||||
"{d} days": "{d} أيام",
|
||||
"{d} days {h} h": "{d} أيام و{h} س",
|
||||
"{time} left to claim it": "متبقٍ {time} للحصول عليها",
|
||||
"Opens in {time}": "يفتح بعد {time}",
|
||||
"Opens {date}": "يفتح في {date}",
|
||||
"Shop": "المتجر",
|
||||
"Free": "مجاني",
|
||||
"Owned": "تملكه",
|
||||
"Soon": "قريبًا",
|
||||
"Your name": "اسمك",
|
||||
"The founder set": "طقم المؤسسين",
|
||||
"Not open yet": "لم يُفتح بعد",
|
||||
"Free for founders, until {date}": "مجاني للمؤسسين حتى {date}",
|
||||
"Claiming…": "جارٍ الحصول عليه…",
|
||||
"Open the wardrobe": "افتح خزانة الملابس",
|
||||
"Claimed · Open the wardrobe": "تم الحصول عليه · افتح خزانة الملابس",
|
||||
"Back to the wardrobe": "العودة إلى خزانة الملابس",
|
||||
"You're offline.": "أنت غير متصل.",
|
||||
"The shop didn't load.": "لم يتم تحميل المتجر.",
|
||||
"Changes waiting to sync: {n}": "تغييرات بانتظار المزامنة: {n}",
|
||||
"The claim didn't go through: {why}": "لم يكتمل الحصول عليه: {why}",
|
||||
"The founder set was free to claim until {date}.": "كان طقم المؤسسين مجانيًا حتى {date}.",
|
||||
"The founder set isn't offered any more.": "لم يعد طقم المؤسسين متاحًا.",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "مرتبط بحساب Minecraft الخاص بك، على كل حاسوب تسجّل الدخول منه.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "مرتبط بحساب Minecraft الخاص بك. لا شيء للدفع، ولا تُطلب بطاقة.",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "تظهر الإكسسوارات للاعبي Horizon فقط. لا شيء يغيّر طريقة اللعب.",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "للاعبين الذين كانوا هنا أولًا. مجاني ابتداءً من {date}؛ وبعد الحصول عليه يبقى لك.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "للاعبين الذين كانوا هنا أولًا. مجاني حتى {date}؛ وبعد الحصول عليه يبقى لك.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "للاعبين الذين كانوا هنا أولًا. لم تُحدَّد مواعيد الحصول عليه بعد.",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "لم يعد متاحًا ولن يُباع. يحتفظ به من حصلوا عليه.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "كان مجانيًا حتى {date}. لم يعد متاحًا ولن يُباع؛ وما لديك يبقى لك.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "إنه في خزانة ملابسك ويبقى لك. ارتدِه من تبويب خزانة الملابس.",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "سجّل الدخول للحصول عليه. الإكسسوارات تخص حساب Microsoft الذي يملك Minecraft: Java Edition، لذا ترافقك على كل حاسوب.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "يحتاج المتجر إلى اتصال. لا يزال بإمكانك تغيير ما ترتديه في خزانة الملابس: يُحفظ على هذا الحاسوب ويُزامَن عند عودتك للاتصال.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "أشياء يرتديها لاعبك، يراها كل من يلعب على Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "ما ترتديه لا يزال يظهر في اللعبة: احتفظ المشغّل بآخر زيّ لك. حاول مجددًا بعد قليل.",
|
||||
"Preview": "معاينة",
|
||||
"Free for founders": "مجاني للمؤسسين",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "للاعبين الذين كانوا هنا أولًا. مجاني؛ وبعد الحصول عليه يبقى لك.",
|
||||
"First Light": "الفجر الأول",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "مجاني للجميع. يرسم «الفجر الأول» السماء قبيل شروق الشمس خلف المظهر الفاتح، بلمسة زرقاء.",
|
||||
"Shirt": "قميص"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} / {max}",
|
||||
"{support} in Horizon": "Horizon-da {support}",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 gün",
|
||||
"1 day {h} h": "1 gün {h} saat",
|
||||
"April": "Aprel",
|
||||
"August": "Avqust",
|
||||
"Back to the wardrobe": "Qarderoba qayıt",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Minecraft hesabınıza bağlıdır, daxil olduğunuz hər kompüterdə.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Minecraft hesabınıza bağlıdır. Ödəniş yoxdur, kart tələb olunmur.",
|
||||
"Changes waiting to sync: {n}": "Sinxronlaşmanı gözləyən dəyişikliklər: {n}",
|
||||
"Claimed · Open the wardrobe": "Əldə edildi · Qarderobu aç",
|
||||
"Claiming…": "Əldə edilir…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetika yalnız Horizon oyunçularına görünür. Oyunun gedişatını heç nə dəyişmir.",
|
||||
"December": "Dekabr",
|
||||
"February": "Fevral",
|
||||
"First Light": "İlk Şəfəq",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Ən əvvəl burada olan oyunçular üçün. {date} tarixindən pulsuz əldə etmək olar; əldə etdikdən sonra həmişəlik sizindir.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Ən əvvəl burada olan oyunçular üçün. {date} tarixinədək pulsuz əldə etmək olar; əldə etdikdən sonra həmişəlik sizindir.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Ən əvvəl burada olan oyunçular üçün. Pulsuz əldə etmək olar; əldə etdikdən sonra həmişəlik sizindir.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Ən əvvəl burada olan oyunçular üçün. Əldə etmə tarixləri hələ müəyyən edilməyib.",
|
||||
"Free": "Pulsuz",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Hamı üçün pulsuz. İlk Şəfəq açıq görünüşdə günəş çıxmazdan əvvəlki səmanı mavi vurğu ilə boyayır.",
|
||||
"Free for founders": "Qurucular üçün pulsuz",
|
||||
"Free for founders, until {date}": "Qurucular üçün pulsuz, {date} tarixinədək",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Artıq təklif olunmur və satılmayacaq. Onu əldə edən oyunçularda qalır.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "{date} tarixinədək pulsuz əldə etmək olardı. Artıq təklif olunmur və satılmayacaq; sizdə olan sizdə qalır.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Qarderobunuzdadır, həmişəlik sizindir. Qarderob nişanından geyinin.",
|
||||
"January": "Yanvar",
|
||||
"July": "İyul",
|
||||
"June": "İyun",
|
||||
"March": "Mart",
|
||||
"May": "May",
|
||||
"Not open yet": "Hələ açıq deyil",
|
||||
"November": "Noyabr",
|
||||
"October": "Oktyabr",
|
||||
"Open the wardrobe": "Qarderobu aç",
|
||||
"Opens in {time}": "{time} sonra açılır",
|
||||
"Opens {date}": "{date} tarixində açılır",
|
||||
"Owned": "Sahibsiniz",
|
||||
"Preview": "Önbaxış",
|
||||
"September": "Sentyabr",
|
||||
"Shirt": "Köynək",
|
||||
"Shop": "Mağaza",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Əldə etmək üçün daxil olun. Kosmetika Minecraft: Java Edition-a sahib olan Microsoft hesabına aiddir, ona görə hər kompüterdə sizinlə olur.",
|
||||
"Soon": "Tezliklə",
|
||||
"The claim didn't go through: {why}": "Əldə etmək alınmadı: {why}",
|
||||
"The founder set": "Qurucu dəsti",
|
||||
"The founder set isn't offered any more.": "Qurucu dəsti artıq təklif olunmur.",
|
||||
"The founder set was free to claim until {date}.": "Qurucu dəsti {date} tarixinədək pulsuz əldə edilə bilərdi.",
|
||||
"The shop didn't load.": "Mağaza yüklənmədi.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Mağaza bağlantı tələb edir. Qarderobda geyiminizi yenə dəyişə bilərsiniz: bu kompüterdə saxlanılır və yenidən onlayn olduqda sinxronlaşır.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Oyunçunuz üçün geyimlər, Horizon-da oynayan hər kəsə görünür.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Geyiminiz oyunda hələ də görünür: launcher son geyiminizi saxlayıb. Bir azdan yenidən cəhd edin.",
|
||||
"You're offline.": "Oflayn vəziyyətdəsiniz.",
|
||||
"Your name": "Adınız",
|
||||
"less than a minute": "bir dəqiqədən az",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} gün",
|
||||
"{d} days {h} h": "{d} gün {h} saat",
|
||||
"{h} h {m} min": "{h} saat {m} dəq",
|
||||
"{m} min": "{m} dəq",
|
||||
"{time} left to claim it": "əldə etmək üçün {time} qalıb"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{count} min ago": "{count} мин элек",
|
||||
"{count} on the official SMP": "Рәсми SMP-ла {count}",
|
||||
"{mode} · stopped": "{mode} · туҡтатылған",
|
||||
"{online} of {max}": "{max} эсенән {online}"
|
||||
"{online} of {max}": "{max} эсенән {online}",
|
||||
"1 day": "1 көн",
|
||||
"1 day {h} h": "1 көн {h} с",
|
||||
"April": "Апрель",
|
||||
"August": "Август",
|
||||
"Back to the wardrobe": "Гардеробҡа ҡайтыу",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Minecraft иҫәп яҙмаһына бәйле, инеүсе һәр компьютерҙа.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Minecraft иҫәп яҙмаһына бәйле. Түләргә кәрәкмәй, карта һорамайбыҙ.",
|
||||
"Changes waiting to sync: {n}": "Синхронлаштырыуҙы көткән үҙгәрештәр: {n}",
|
||||
"Claimed · Open the wardrobe": "Алынды · Гардеробты асыу",
|
||||
"Claiming…": "Алына…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Косметика тик Horizon уйынсыларына ғына күренә. Уйындың барышын бер нәмә лә үҙгәртмәй.",
|
||||
"December": "Декабрь",
|
||||
"February": "Февраль",
|
||||
"First Light": "Беренсе яҡтылыҡ",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Беренсе булып килгән уйынсылар өсөн. {date} көнөнән башлап бушлай алырға була; алғас, мәңгелеккә һинеке.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Беренсе булып килгән уйынсылар өсөн. {date} көнөнә тиклем бушлай алырға була; алғас, мәңгелеккә һинеке.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Беренсе булып килгән уйынсылар өсөн. Бушлай алырға була; алғас, мәңгелеккә һинеке.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Беренсе булып килгән уйынсылар өсөн. Алыу көндәре әлегә билдәләнмәгән.",
|
||||
"Free": "Бушлай",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Бөтәһенә лә бушлай. Беренсе яҡтылыҡ яҡты күренештә кояш сыҡҡансы тиклемге күҙҙе зәңгәр аксент менән буяй.",
|
||||
"Free for founders": "Нигеҙләүселәр өсөн бушлай",
|
||||
"Free for founders, until {date}": "Нигеҙләүселәр өсөн бушлай, {date} көнөнә тиклем",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Ул инде тәҡдим ителмәй һәм һатылмаясаҡ. Уны алған уйынсыларҙа ҡала.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Уны {date} көнөнә тиклем бушлай алырға мөмкин ине. Ул инде тәҡдим ителмәй һәм һатылмаясаҡ; һинең әйбер һинеке булып ҡала.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Ул һинең гардеробыңда, мәңгелеккә һинеке. Уны «Гардероб» бүлегенән кейә алаһың.",
|
||||
"January": "Ғинуар",
|
||||
"July": "Июль",
|
||||
"June": "Июнь",
|
||||
"March": "Март",
|
||||
"May": "Май",
|
||||
"Not open yet": "Әлегә асыҡ түгел",
|
||||
"November": "Ноябрь",
|
||||
"October": "Октябрь",
|
||||
"Open the wardrobe": "Гардеробты асыу",
|
||||
"Opens in {time}": "{time} аҙаҡ асыла",
|
||||
"Opens {date}": "{date} асыла",
|
||||
"Owned": "Һинеке",
|
||||
"Preview": "Алдан ҡарау",
|
||||
"September": "Сентябрь",
|
||||
"Shirt": "Күлдәк",
|
||||
"Shop": "Магазин",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Алыр өсөн инегеҙ. Косметика Minecraft: Java Edition эйәһе булған Microsoft иҫәп яҙмаһына ҡарай, шуға күрә һәр компьютерҙа һинең менән.",
|
||||
"Soon": "Тиҙҙән",
|
||||
"The claim didn't go through: {why}": "Алыу барып сыҡманы: {why}",
|
||||
"The founder set": "Нигеҙләүселәр йыйылмаһы",
|
||||
"The founder set isn't offered any more.": "Нигеҙләүселәр йыйылмаһы инде тәҡдим ителмәй.",
|
||||
"The founder set was free to claim until {date}.": "Нигеҙләүселәр йыйылмаһын {date} көнөнә тиклем бушлай алырға мөмкин ине.",
|
||||
"The shop didn't load.": "Магазин ыңғайланманы.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Магазинға тоташыу кәрәк. Гардеробта кейемеңде барыбер үҙгәртә алаһың: ул был компьютерҙа һаҡлана һәм кире онлайн булғас синхронлаша.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Уйынсыңа кейем, Horizon-да уйнаусы һәр кемгә күренә.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Кейгәнең уйында барыбер күренә: лаунчер һуңғы кейемде һаҡлаған. Бер аҙҙан яңынан тырышып ҡара.",
|
||||
"You're offline.": "Һин офлайнһың.",
|
||||
"Your name": "Исемең",
|
||||
"less than a minute": "минуттан әҙерәк",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} көн",
|
||||
"{d} days {h} h": "{d} көн {h} с",
|
||||
"{h} h {m} min": "{h} с {m} мин",
|
||||
"{m} min": "{m} мин",
|
||||
"{time} left to claim it": "алыуға {time} ҡалды"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} з {max}",
|
||||
"{support} in Horizon": "{support} у Horizon",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 дзень",
|
||||
"1 day {h} h": "1 дзень {h} гадз",
|
||||
"April": "Красавік",
|
||||
"August": "Жнівень",
|
||||
"Back to the wardrobe": "Назад у гардэроб",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Прывязана да вашага ўліковага запісу Minecraft, на кожным камп’ютары, дзе вы ўваходзіце.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Прывязана да вашага ўліковага запісу Minecraft. Нічога плаціць не трэба, карта не патрабуецца.",
|
||||
"Changes waiting to sync: {n}": "Змены, якія чакаюць сінхранізацыі: {n}",
|
||||
"Claimed · Open the wardrobe": "Атрымана · Адкрыць гардэроб",
|
||||
"Claiming…": "Атрымліваем…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Касметыка бачная толькі гульцам Horizon. Яна ніяк не змяняе гульнявы працэс.",
|
||||
"December": "Снежань",
|
||||
"February": "Люты",
|
||||
"First Light": "Першае святло",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Для гульцоў, якія былі тут першымі. Можна атрымаць бясплатна з {date}; атрымаўшы, вы пакідаеце яго назаўсёды.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Для гульцоў, якія былі тут першымі. Можна атрымаць бясплатна да {date}; атрымаўшы, вы пакідаеце яго назаўсёды.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Для гульцоў, якія былі тут першымі. Можна атрымаць бясплатна; атрымаўшы, вы пакідаеце яго назаўсёды.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Для гульцоў, якія былі тут першымі. Даты атрымання пакуль не вызначаны.",
|
||||
"Free": "Бясплатна",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Бясплатна для ўсіх. «Першае святло» афарбоўвае неба перад усходам сонца ў светлым афармленні з сінім акцэнтам.",
|
||||
"Free for founders": "Бясплатна для заснавальнікаў",
|
||||
"Free for founders, until {date}": "Бясплатна для заснавальнікаў, да {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Больш не прапаноўваецца і прадавацца не будзе. Гульцы, якія яго атрымалі, пакідаюць яго сабе.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Яго можна было атрымаць бясплатна да {date}. Больш не прапаноўваецца і прадавацца не будзе; тое, што ў вас ёсць, застаецца вашым.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Ён ужо ў вашым гардэробе, назаўсёды ваш. Апранайце яго на ўкладцы «Гардэроб».",
|
||||
"January": "Студзень",
|
||||
"July": "Ліпень",
|
||||
"June": "Чэрвень",
|
||||
"March": "Сакавік",
|
||||
"May": "Травень",
|
||||
"Not open yet": "Пакуль не адкрыта",
|
||||
"November": "Лістапад",
|
||||
"October": "Кастрычнік",
|
||||
"Open the wardrobe": "Адкрыць гардэроб",
|
||||
"Opens in {time}": "Адкрыецца праз {time}",
|
||||
"Opens {date}": "Адкрыецца {date}",
|
||||
"Owned": "У вас",
|
||||
"Preview": "Прагляд",
|
||||
"September": "Верасень",
|
||||
"Shirt": "Кашуля",
|
||||
"Shop": "Крама",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Увайдзіце, каб атрымаць. Касметыка належыць уліковаму запісу Microsoft, які валодае Minecraft: Java Edition, таму яна ідзе за вамі на кожны камп’ютар.",
|
||||
"Soon": "Хутка",
|
||||
"The claim didn't go through: {why}": "Атрымаць не атрымалася: {why}",
|
||||
"The founder set": "Набор заснавальніка",
|
||||
"The founder set isn't offered any more.": "Набор заснавальніка больш не прапаноўваецца.",
|
||||
"The founder set was free to claim until {date}.": "Набор заснавальніка можна было атрымаць бясплатна да {date}.",
|
||||
"The shop didn't load.": "Краму не ўдалося загрузіць.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Для крамы патрэбна злучэнне. Змяняць, што вы носіце, можна і ў Гардэробе: змены захоўваюцца на гэтым камп’ютары і сінхранізуюцца, калі вы зноў будзеце ў сетцы.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Рэчы для вашага персанажа, якія бачаць усе, хто гуляе ў Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Тое, што вы носіце, па-ранейшаму бачна ў гульні: лаўнчар захаваў ваш апошні вобраз. Паспрабуйце яшчэ раз крыху пазней.",
|
||||
"You're offline.": "Вы па-за сеткай.",
|
||||
"Your name": "Ваша імя",
|
||||
"less than a minute": "менш за хвіліну",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} дн.",
|
||||
"{d} days {h} h": "{d} дн. {h} гадз",
|
||||
"{h} h {m} min": "{h} гадз {m} хв",
|
||||
"{m} min": "{m} хв",
|
||||
"{time} left to claim it": "засталося {time}, каб атрымаць"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "„Запази промените“ го превключва. Нищо в папката му не се изтрива.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "Модовете му за {loader} остават в папката му, но няма да се зареждат.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "Модовете му за Minecraft {from} остават в папката му; някои може да не се заредят в {to}.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Световете му са записани от Minecraft {from}: {to} ги надгражда завинаги и не може да ги отвори, ако е по-стар."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Световете му са записани от Minecraft {from}: {to} ги надгражда завинаги и не може да ги отвори, ако е по-стар.",
|
||||
"January": "януари",
|
||||
"February": "февруари",
|
||||
"March": "март",
|
||||
"April": "април",
|
||||
"May": "май",
|
||||
"June": "юни",
|
||||
"July": "юли",
|
||||
"August": "август",
|
||||
"September": "септември",
|
||||
"October": "октомври",
|
||||
"November": "ноември",
|
||||
"December": "декември",
|
||||
"{day} {month} {year}": "{day} {month} {year} г.",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"less than a minute": "по-малко от минута",
|
||||
"{m} min": "{m} мин",
|
||||
"{h} h {m} min": "{h} ч {m} мин",
|
||||
"1 day": "1 ден",
|
||||
"1 day {h} h": "1 ден {h} ч",
|
||||
"{d} days": "{d} дни",
|
||||
"{d} days {h} h": "{d} дни {h} ч",
|
||||
"{time} left to claim it": "Остават {time}, за да я вземеш",
|
||||
"Opens in {time}": "Отваря след {time}",
|
||||
"Opens {date}": "Отваря на {date}",
|
||||
"Shop": "Магазин",
|
||||
"Free": "Безплатно",
|
||||
"Owned": "Притежаваш",
|
||||
"Soon": "Скоро",
|
||||
"Your name": "Твоето име",
|
||||
"The founder set": "Комплектът на основателите",
|
||||
"Not open yet": "Още не е отворено",
|
||||
"Free for founders, until {date}": "Безплатно за основателите до {date}",
|
||||
"Claiming…": "Взимане…",
|
||||
"Open the wardrobe": "Отвори гардероба",
|
||||
"Claimed · Open the wardrobe": "Взето · Отвори гардероба",
|
||||
"Back to the wardrobe": "Обратно към гардероба",
|
||||
"You're offline.": "Нямаш връзка.",
|
||||
"The shop didn't load.": "Магазинът не се зареди.",
|
||||
"Changes waiting to sync: {n}": "Промени, чакащи синхронизация: {n}",
|
||||
"The claim didn't go through: {why}": "Взимането не мина: {why}",
|
||||
"The founder set was free to claim until {date}.": "Комплектът на основателите беше безплатен до {date}.",
|
||||
"The founder set isn't offered any more.": "Комплектът на основателите вече не се предлага.",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Свързано с акаунта ти в Minecraft, на всеки компютър, на който влезеш.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Свързано с акаунта ти в Minecraft. Нищо за плащане, без карта.",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Козметиката се вижда само от играчи на Horizon. Нищо не променя играта.",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "За играчите, които бяха тук първи. Безплатно от {date}; веднъж взето, остава твое.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "За играчите, които бяха тук първи. Безплатно до {date}; веднъж взето, остава твое.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "За играчите, които бяха тук първи. Датите за взимане още не са определени.",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Вече не се предлага и няма да се продава. Взелите го го запазват.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Беше безплатно до {date}. Вече не се предлага и няма да се продава; твоето остава твое.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "В гардероба ти е и остава твое. Облечи го от раздела Гардероб.",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Влез, за да го вземеш. Козметиката принадлежи на акаунта в Microsoft, който притежава Minecraft: Java Edition, така че те следва на всеки компютър.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Магазинът има нужда от връзка. Пак можеш да сменяш облеклото си в Гардероба: запазва се на този компютър и се синхронизира, когато се свържеш.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Неща за обличане на твоя играч, видими за всички, които играят на Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Облеклото ти още се вижда в играта: лаунчерът запази последния ти тоалет. Опитай пак след малко.",
|
||||
"Preview": "Преглед",
|
||||
"Free for founders": "Безплатно за основателите",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "За играчите, които бяха тук първи. Безплатно; веднъж взето, остава твое.",
|
||||
"First Light": "Първа светлина",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Безплатна за всички. «Първа светлина» рисува небето точно преди изгрев зад светлия външен вид, със син акцент.",
|
||||
"Shirt": "Тениска"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{count} min ago": "{count} mun zo",
|
||||
"{count} on the official SMP": "{count} war ar SMP ofisiel",
|
||||
"{mode} · stopped": "{mode} · paouezet",
|
||||
"{online} of {max}": "{online} diwar {max}"
|
||||
"{online} of {max}": "{online} diwar {max}",
|
||||
"1 day": "1 devezh",
|
||||
"1 day {h} h": "1 devezh {h} e",
|
||||
"April": "Ebrel",
|
||||
"August": "Eost",
|
||||
"Back to the wardrobe": "Distreiñ d’an dilhad",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Staget ouzh ho kont Minecraft, war an holl urzhiataerioù ma kevrit.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Staget ouzh ho kont Minecraft. Netra da baeañ, kartenn ebet goulennet.",
|
||||
"Changes waiting to sync: {n}": "Cheñchamantoù o c’hortoz da vezañ goubredet: {n}",
|
||||
"Claimed · Open the wardrobe": "Goulennet · Digeriñ an dilhad",
|
||||
"Claiming…": "O c’houlenn…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "N’eo gwelet ar pennadoù kened nemet gant c’hoarierien Horizon. Netra ne cheñch an doare da c’hoari.",
|
||||
"December": "Kerzu",
|
||||
"February": "C’hwevrer",
|
||||
"First Light": "Gouloù kentañ",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Evit ar re a oa amañ da gentañ. Digoust da c’houlenn adalek {date}; ur wech goulennet eo ho re da virout.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Evit ar re a oa amañ da gentañ. Digoust da c’houlenn betek {date}; ur wech goulennet eo ho re da virout.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Evit ar re a oa amañ da gentañ. Digoust da c’houlenn; ur wech goulennet eo ho re da virout.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Evit ar re a oa amañ da gentañ. An deiziadoù evit e c’houlenn n’int ket termenet c’hoazh.",
|
||||
"Free": "Digoust",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Digoust evit an holl. Gouloù kentañ a livañ an oabl a-raok ar sav-heol dindan neuz sklaer, gant un doare glas.",
|
||||
"Free for founders": "Digoust evit ar grouerien",
|
||||
"Free for founders, until {date}": "Digoust evit ar grouerien, betek {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "N’eo ket kinniget ken ha ne vo ket gwerzhet. Ar re en deus goulennet a vir anezhañ.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Digoust e oa da c’houlenn betek {date}. N’eo ket kinniget ken ha ne vo ket gwerzhet; ho hini a chom ho hini.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Er gwiskamant emañ, ho hini da virout. Gwiskit anezhañ eus ar bajenn Dilhad.",
|
||||
"January": "Genver",
|
||||
"July": "Gouere",
|
||||
"June": "Mezheven",
|
||||
"March": "Meurzh",
|
||||
"May": "Mae",
|
||||
"Not open yet": "N’eo ket digor c’hoazh",
|
||||
"November": "Du",
|
||||
"October": "Here",
|
||||
"Open the wardrobe": "Digeriñ an dilhad",
|
||||
"Opens in {time}": "Digor a vo a-benn {time}",
|
||||
"Opens {date}": "Digor a vo d’an {date}",
|
||||
"Owned": "Ho pezet",
|
||||
"Preview": "Rakwelet",
|
||||
"September": "Gwengolo",
|
||||
"Shirt": "Roched",
|
||||
"Shop": "Stal",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Kevreit evit e c’houlenn. Ar pennadoù kened a zo da gont Microsoft ar perc’henn Minecraft: Java Edition, setu e heuliont ac’hanoc’h war an holl urzhiataerioù.",
|
||||
"Soon": "A-benn nebeut",
|
||||
"The claim didn't go through: {why}": "N’eo ket aet mat ar goulenn: {why}",
|
||||
"The founder set": "Ar sagad krouer",
|
||||
"The founder set isn't offered any more.": "N’eo ket kinniget ken ar sagad krouer.",
|
||||
"The founder set was free to claim until {date}.": "Digoust e oa ar sagad krouer da c’houlenn betek {date}.",
|
||||
"The shop didn't load.": "N’eo ket bet karget ar stal.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Ezhomm en deus ar stal eus ur gevreadenn. Gallout a rit cheñch ar pezh a wiskit er gwiskamant atav: enrollet eo war an urzhiataer-mañ ha goubredet pa vezot en-linenn en-dro.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Traoù da wiskañ evit ho c’hoarier, gwelet gant an holl a c’hoari war Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Ar pezh a wiskit a zo gwelet atav er c’hoari: miret en deus al lañser ho gwiskamant diwezhañ. Klaskit en-dro a-benn un nebeut.",
|
||||
"You're offline.": "Ezlinenn oc’h.",
|
||||
"Your name": "Hoc’h anv",
|
||||
"less than a minute": "nebeutoc’h eget ur vunutenn",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} devezh",
|
||||
"{d} days {h} h": "{d} devezh {h} e",
|
||||
"{h} h {m} min": "{h} e {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "{time} da c’hortoz evit e c’houlenn"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} od {max}",
|
||||
"{support} in Horizon": "{support} u Horizonu",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 dan",
|
||||
"1 day {h} h": "1 dan {h} h",
|
||||
"April": "april",
|
||||
"August": "august",
|
||||
"Back to the wardrobe": "Nazad u ormar",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Vezano za vaš Minecraft račun, na svakom računaru na kojem se prijavite.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Vezano za vaš Minecraft račun. Nema plaćanja, ne traži se kartica.",
|
||||
"Changes waiting to sync: {n}": "Promjene koje čekaju sinhronizaciju: {n}",
|
||||
"Claimed · Open the wardrobe": "Preuzeto · Otvori ormar",
|
||||
"Claiming…": "Preuzimanje…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kozmetika se vidi samo Horizon igračima. Ništa ne mijenja način igranja.",
|
||||
"December": "decembar",
|
||||
"February": "februar",
|
||||
"First Light": "Prva svjetlost",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Za igrače koji su bili prvi ovdje. Besplatno za preuzimanje od {date}; kad ga preuzmete, vaš je zauvijek.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Za igrače koji su bili prvi ovdje. Besplatno za preuzimanje do {date}; kad ga preuzmete, vaš je zauvijek.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Za igrače koji su bili prvi ovdje. Besplatno za preuzimanje; kad ga preuzmete, vaš je zauvijek.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Za igrače koji su bili prvi ovdje. Datumi preuzimanja još nisu određeni.",
|
||||
"Free": "Besplatno",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Besplatno za sve. Prva svjetlost boji nebo neposredno prije izlaska sunca iza svijetle teme, s plavim akcentom.",
|
||||
"Free for founders": "Besplatno za osnivače",
|
||||
"Free for founders, until {date}": "Besplatno za osnivače, do {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Više se ne nudi i neće se prodavati. Igrači koji su ga preuzeli zadržavaju ga.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Bio je besplatan za preuzimanje do {date}. Više se ne nudi i neće se prodavati; vaš ostaje vaš.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Nalazi se u vašem ormaru, vaš je zauvijek. Nosite ga na kartici Ormar.",
|
||||
"January": "januar",
|
||||
"July": "juli",
|
||||
"June": "juni",
|
||||
"March": "mart",
|
||||
"May": "maj",
|
||||
"Not open yet": "Još nije otvoreno",
|
||||
"November": "novembar",
|
||||
"October": "oktobar",
|
||||
"Open the wardrobe": "Otvori ormar",
|
||||
"Opens in {time}": "Otvara se za {time}",
|
||||
"Opens {date}": "Otvara se {date}",
|
||||
"Owned": "U posjedu",
|
||||
"Preview": "Pregled",
|
||||
"September": "septembar",
|
||||
"Shirt": "Majica",
|
||||
"Shop": "Prodavnica",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Prijavite se da ga preuzmete. Kozmetika pripada Microsoft računu koji posjeduje Minecraft: Java Edition, pa vas prati na svaki računar.",
|
||||
"Soon": "Uskoro",
|
||||
"The claim didn't go through: {why}": "Preuzimanje nije uspjelo: {why}",
|
||||
"The founder set": "Osnivački set",
|
||||
"The founder set isn't offered any more.": "Osnivački set se više ne nudi.",
|
||||
"The founder set was free to claim until {date}.": "Osnivački set je bio besplatan za preuzimanje do {date}.",
|
||||
"The shop didn't load.": "Prodavnica se nije učitala.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Prodavnici je potrebna veza. I dalje možete mijenjati šta nosite u Ormaru: sprema se na ovom računaru i sinhronizira kad se vratite na mrežu.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Stvari za nošenje za vašeg igrača, vidljive svima koji igraju na Horizonu.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Ono što nosite i dalje se vidi u igri: launcher je zadržao vaš posljednji izgled. Pokušajte ponovo za trenutak.",
|
||||
"You're offline.": "Niste na mreži.",
|
||||
"Your name": "Vaše ime",
|
||||
"less than a minute": "manje od minute",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day}. {month} {year}.",
|
||||
"{d} days": "{d} dana",
|
||||
"{d} days {h} h": "{d} dana {h} h",
|
||||
"{h} h {m} min": "{h} h {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "preostalo {time} za preuzimanje"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} de {max}",
|
||||
"{support} in Horizon": "{support} a Horizon",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 dia",
|
||||
"1 day {h} h": "1 dia {h} h",
|
||||
"April": "d’abril",
|
||||
"August": "d’agost",
|
||||
"Back to the wardrobe": "Torna a l’armari",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Vinculat al vostre compte de Minecraft, a cada ordinador on inicieu la sessió.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Vinculat al vostre compte de Minecraft. Res a pagar, no es demana cap targeta.",
|
||||
"Changes waiting to sync: {n}": "Canvis pendents de sincronitzar: {n}",
|
||||
"Claimed · Open the wardrobe": "Reclamat · Obre l’armari",
|
||||
"Claiming…": "Reclamant…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Els cosmètics només els veuen els jugadors d’Horizon. Res no canvia la manera de jugar.",
|
||||
"December": "de desembre",
|
||||
"February": "de febrer",
|
||||
"First Light": "Primera Llum",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Per als jugadors que hi eren des del principi. Es pot reclamar gratis a partir del {date}; un cop reclamat, és teu per sempre.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Per als jugadors que hi eren des del principi. Es pot reclamar gratis fins al {date}; un cop reclamat, és teu per sempre.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Per als jugadors que hi eren des del principi. Es pot reclamar gratis; un cop reclamat, és teu per sempre.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Per als jugadors que hi eren des del principi. Les dates per reclamar-lo encara no s’han fixat.",
|
||||
"Free": "Gratuït",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Gratuït per a tothom. Primera Llum pinta el cel just abans de l’alba darrere l’aparença clara, amb un accent blau.",
|
||||
"Free for founders": "Gratuït per als fundadors",
|
||||
"Free for founders, until {date}": "Gratuït per als fundadors, fins al {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Ja no s’ofereix i no es vendrà. Els jugadors que el van reclamar el conserven.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Es podia reclamar gratis fins al {date}. Ja no s’ofereix i no es vendrà; el teu continua sent teu.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "És a l’armari, teu per sempre. Posa-te’l des de la pestanya Armari.",
|
||||
"January": "de gener",
|
||||
"July": "de juliol",
|
||||
"June": "de juny",
|
||||
"March": "de març",
|
||||
"May": "de maig",
|
||||
"Not open yet": "Encara no és obert",
|
||||
"November": "de novembre",
|
||||
"October": "d’octubre",
|
||||
"Open the wardrobe": "Obre l’armari",
|
||||
"Opens in {time}": "S’obre d’aquí a {time}",
|
||||
"Opens {date}": "S’obre el {date}",
|
||||
"Owned": "Tens",
|
||||
"Preview": "Previsualització",
|
||||
"September": "de setembre",
|
||||
"Shirt": "Samarreta",
|
||||
"Shop": "Botiga",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Inicieu la sessió per reclamar-lo. Els cosmètics pertanyen al compte de Microsoft que posseeix Minecraft: Java Edition, així que us segueixen a tots els ordinadors.",
|
||||
"Soon": "Aviat",
|
||||
"The claim didn't go through: {why}": "La reclamació no s’ha completat: {why}",
|
||||
"The founder set": "El conjunt de fundadors",
|
||||
"The founder set isn't offered any more.": "El conjunt de fundadors ja no s’ofereix.",
|
||||
"The founder set was free to claim until {date}.": "El conjunt de fundadors es podia reclamar gratis fins al {date}.",
|
||||
"The shop didn't load.": "La botiga no s’ha carregat.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "La botiga necessita connexió. Encara podeu canviar què porteu a l’Armari: es desa en aquest ordinador i se sincronitza quan torneu a estar en línia.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Coses per portar al vostre jugador, que veu tothom qui juga a Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "El que porteu continua veient-se al joc: el llançador ha conservat el vostre últim vestit. Torneu-ho a provar d’aquí a un moment.",
|
||||
"You're offline.": "Esteu fora de línia.",
|
||||
"Your name": "El vostre nom",
|
||||
"less than a minute": "menys d’un minut",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} del {year}",
|
||||
"{d} days": "{d} dies",
|
||||
"{d} days {h} h": "{d} dies {h} h",
|
||||
"{h} h {m} min": "{h} h {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "queden {time} per reclamar-lo"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "Uložit změny ji přepne. Z její složky se nic nesmaže.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "Její módy pro {loader} zůstanou v její složce, ale nenačtou se.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "Její módy pro Minecraft {from} zůstanou v její složce; některé se v {to} možná nenačtou.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Její světy uložil Minecraft {from}: {to} je natrvalo aktualizuje, a pokud je starší, nedokáže je otevřít."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Její světy uložil Minecraft {from}: {to} je natrvalo aktualizuje, a pokud je starší, nedokáže je otevřít.",
|
||||
"1 day": "1 den",
|
||||
"1 day {h} h": "1 den {h} h",
|
||||
"April": "dubna",
|
||||
"August": "srpna",
|
||||
"Back to the wardrobe": "Zpět do šatní skříně",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Vázáno na váš účet Minecraft, na každém počítači, kde se přihlásíte.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Vázáno na váš účet Minecraft. Nic neplatíte, nežádá se žádná karta.",
|
||||
"Changes waiting to sync: {n}": "Změny čekající na synchronizaci: {n}",
|
||||
"Claimed · Open the wardrobe": "Získáno · Otevřít šatní skříň",
|
||||
"Claiming…": "Získávání…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetika je vidět jen hráčům Horizonu. Nic nemění způsob hraní.",
|
||||
"December": "prosince",
|
||||
"February": "února",
|
||||
"First Light": "První světlo",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Pro hráče, kteří tu byli první. Zdarma k získání od {date}; jakmile si ho vezmete, je váš navždy.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Pro hráče, kteří tu byli první. Zdarma k získání do {date}; jakmile si ho vezmete, je váš navždy.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Pro hráče, kteří tu byli první. Zdarma k získání; jakmile si ho vezmete, je váš navždy.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Pro hráče, kteří tu byli první. Termíny získání zatím nejsou stanoveny.",
|
||||
"Free": "Zdarma",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Zdarma pro všechny. První světlo vybarví oblohu těsně před východem slunce za světlým vzhledem, s modrým akcentem.",
|
||||
"Free for founders": "Zdarma pro zakladatele",
|
||||
"Free for founders, until {date}": "Zdarma pro zakladatele, do {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Už se nenabízí a nebude se prodávat. Hráči, kteří ho získali, si ho nechávají.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Bylo zdarma k získání do {date}. Už se nenabízí a nebude se prodávat; co máte, zůstává vaše.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Je ve vaší šatní skříni, váš navždy. Oblékněte si ho na kartě Šatní skříň.",
|
||||
"January": "ledna",
|
||||
"July": "července",
|
||||
"June": "června",
|
||||
"March": "března",
|
||||
"May": "května",
|
||||
"Not open yet": "Ještě není otevřeno",
|
||||
"November": "listopadu",
|
||||
"October": "října",
|
||||
"Open the wardrobe": "Otevřít šatní skříň",
|
||||
"Opens in {time}": "Otevře se za {time}",
|
||||
"Opens {date}": "Otevře se {date}",
|
||||
"Owned": "Vlastněno",
|
||||
"Preview": "Náhled",
|
||||
"September": "září",
|
||||
"Shirt": "Tričko",
|
||||
"Shop": "Obchod",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Přihlaste se a získejte ho. Kosmetika patří k účtu Microsoft, který vlastní Minecraft: Java Edition, takže vás provází na každý počítač.",
|
||||
"Soon": "Brzy",
|
||||
"The claim didn't go through: {why}": "Získání se nepodařilo: {why}",
|
||||
"The founder set": "Zakladatelská sada",
|
||||
"The founder set isn't offered any more.": "Zakladatelská sada se už nenabízí.",
|
||||
"The founder set was free to claim until {date}.": "Zakladatelská sada byla zdarma k získání do {date}.",
|
||||
"The shop didn't load.": "Obchod se nenačetl.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Obchod potřebuje připojení. V Šatní skříni můžete dál měnit, co nosíte: ukládá se na tomto počítači a synchronizuje se, až budete zase online.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Věci k nošení pro vašeho hráče, viditelné pro všechny, kdo hrají na Horizonu.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "To, co nosíte, je ve hře stále vidět: launcher si zapamatoval váš poslední outfit. Zkuste to za chvíli znovu.",
|
||||
"You're offline.": "Jste offline.",
|
||||
"Your name": "Vaše jméno",
|
||||
"less than a minute": "méně než minuta",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day}. {month} {year}",
|
||||
"{d} days": "{d} dní",
|
||||
"{d} days {h} h": "{d} dní {h} h",
|
||||
"{h} h {m} min": "{h} h {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "zbývá {time} k získání"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{count} min ago": "{count} мин каялла",
|
||||
"{count} on the official SMP": "Официаллӑ SMP-ра {count}",
|
||||
"{mode} · stopped": "{mode} · чарӑннӑ",
|
||||
"{online} of {max}": "{max} шутран {online}"
|
||||
"{online} of {max}": "{max} шутран {online}",
|
||||
"1 day": "1 кун",
|
||||
"1 day {h} h": "1 кун {h} сехет",
|
||||
"April": "Ака",
|
||||
"August": "Ҫурла",
|
||||
"Back to the wardrobe": "Каялла гардероба",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Minecraft аккаунчӗпе çыхӑннӑ, кӗрекен кашни компьютерта.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Minecraft аккаунчӗпе çыхӑннӑ. Тӳлемелли çук, карта ыйтмаççӗ.",
|
||||
"Changes waiting to sync: {n}": "Синхронизацие кӗтекен улшӑнусем: {n}",
|
||||
"Claimed · Open the wardrobe": "Илнӗ · Гардероба уҫса пăхас",
|
||||
"Claiming…": "Илет…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Косметика Horizon вылякансене ҫеҫ курӑнать. Вӑйӑ мӗнле пынине нимӗн те улăштармасть.",
|
||||
"December": "Раштав",
|
||||
"February": "Нарӑс",
|
||||
"First Light": "Малтанхи çутӑ",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Пирвайхи килнӗ вылякансем валли. {date} пуҫласа тӳлевсӗр илме пулать; илсен яланлӑхах санӑн.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Пирвайхи килнӗ вылякансем валли. {date} таран тӳлевсӗр илме пулать; илсен яланлӑхах санӑн.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Пирвайхи килнӗ вылякансем валли. Тӳлевсӗр илме пулать; илсен яланлӑхах санӑн.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Пирвайхи килнӗ вылякансем валли. Илме пулакан кунсене халлӗхе палӑртман.",
|
||||
"Free": "Тӳлевсӗр",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Пурне те тӳлевсӗр. Малтанхи çутӑ çутӑ тĕслĕхре хӗвел тухас умӗнхи пӗлӗте кӑвак палӑртуллӑн сӑрлать.",
|
||||
"Free for founders": "Никӗслекенсем валли тӳлевсӗр",
|
||||
"Free for founders, until {date}": "Никӗслекенсем валли тӳлевсӗр, {date} таран",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Ăна халь памаççӗ, сутмӗççӗ те. Илнӗ вылякансен ӑна упрать.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Ăна {date} таран тӳлевсӗр илме пулнӑ. Халь памаççӗ, сутмӗççӗ те; санӑнни санах юлать.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Вӑл санӑн гардеробра, яланлӑхах санӑн. «Гардероб» ҫыпӑҫтарӑвӗнчен тăхăн.",
|
||||
"January": "Кӑрлач",
|
||||
"July": "Утӑ",
|
||||
"June": "Ҫӗртме",
|
||||
"March": "Пуш",
|
||||
"May": "Ҫу",
|
||||
"Not open yet": "Халлӗхе уҫӑ мар",
|
||||
"November": "Чӳк",
|
||||
"October": "Юпа",
|
||||
"Open the wardrobe": "Гардероба уҫас",
|
||||
"Opens in {time}": "{time} иртсен уҫӑлать",
|
||||
"Opens {date}": "{date} уҫӑлать",
|
||||
"Owned": "Санӑн",
|
||||
"Preview": "Малтан пăхса илесси",
|
||||
"September": "Авӑн",
|
||||
"Shirt": "Кӗпе",
|
||||
"Shop": "Лавкка",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Илме кӗр. Косметика Minecraft: Java Edition хуҫи Microsoft аккаунтне пырать, ҫавӑнпа кашни компьютертах санпа пырать.",
|
||||
"Soon": "Часах",
|
||||
"The claim didn't go through: {why}": "Илесси пулмарӗ: {why}",
|
||||
"The founder set": "Никӗслекен пуххи",
|
||||
"The founder set isn't offered any more.": "Никӗслекен пуххине халь памаççӗ.",
|
||||
"The founder set was free to claim until {date}.": "Никӗслекен пуххине {date} таран тӳлевсӗр илме пулнӑ.",
|
||||
"The shop didn't load.": "Лавкка тиеннӗ мар.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Лавккана çыхӑну кирлӗ. Гардеробра мӗн тăхӑннине пур пекех улӑштарма пулать: ку компьютерта упранать, онлайна таврӑнсан синхронизациленет.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Вылякана тăхӑнмалли япаласем, Horizon çинче вылякан пурне те курӑнаççӗ.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Мӗн тăхӑннине вӑйӑра пур пекех куратӑр: лаунчер юлашки тумтире упранӑ. Кăштах тӑхтаса тепӗр хут тӑрӑшса пăхăр.",
|
||||
"You're offline.": "Эсир офлайн.",
|
||||
"Your name": "Санӑн ят",
|
||||
"less than a minute": "минутран кӗскерех",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} кун",
|
||||
"{d} days {h} h": "{d} кун {h} сех",
|
||||
"{h} h {m} min": "{h} сех {m} мин",
|
||||
"{m} min": "{m} мин",
|
||||
"{time} left to claim it": "илме {time} юлнӑ"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{online} of {max}": "{online} o {max}",
|
||||
"{support} in Horizon": "{support} yn Horizon",
|
||||
"{value} px": "{value} px",
|
||||
"{value}%": "{value}%"
|
||||
"{value}%": "{value}%",
|
||||
"1 day": "1 diwrnod",
|
||||
"1 day {h} h": "1 diwrnod {h} awr",
|
||||
"April": "Ebrill",
|
||||
"August": "Awst",
|
||||
"Back to the wardrobe": "Yn ôl i’r cwpwrdd dillad",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Wedi’i rwymo wrth eich cyfrif Minecraft, ar bob cyfrifiadur rydych chi’n mewngofnodi arno.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Wedi’i rwymo wrth eich cyfrif Minecraft. Dim byd i’w dalu, dim cerdyn.",
|
||||
"Changes waiting to sync: {n}": "Newidiadau’n aros i gysoni: {n}",
|
||||
"Claimed · Open the wardrobe": "Wedi’i hawlio · Agor y cwpwrdd dillad",
|
||||
"Claiming…": "Yn hawlio…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Dim ond chwaraewyr Horizon sy’n gweld colur. Does dim byd yn newid sut mae’r gêm yn chwarae.",
|
||||
"December": "Rhagfyr",
|
||||
"February": "Chwefror",
|
||||
"First Light": "Y Golau Cyntaf",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "I’r chwaraewyr oedd yma gyntaf. Am ddim i’w hawlio o {date}; unwaith y byddwch wedi’i hawlio, mae’n eiddo i chi am byth.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "I’r chwaraewyr oedd yma gyntaf. Am ddim i’w hawlio tan {date}; unwaith y byddwch wedi’i hawlio, mae’n eiddo i chi am byth.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "I’r chwaraewyr oedd yma gyntaf. Am ddim i’w hawlio; unwaith y byddwch wedi’i hawlio, mae’n eiddo i chi am byth.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "I’r chwaraewyr oedd yma gyntaf. Dyw’r dyddiadau i’w hawlio ddim wedi’u pennu eto.",
|
||||
"Free": "Am ddim",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Am ddim i bawb. Mae’r Golau Cyntaf yn paentio’r awyr cyn codiad haul y tu ôl i’r ymddangosiad golau, gydag acen las.",
|
||||
"Free for founders": "Am ddim i’r sylfaenwyr",
|
||||
"Free for founders, until {date}": "Am ddim i’r sylfaenwyr, tan {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Nid yw’n cael ei gynnig bellach ac ni fydd yn cael ei werthu. Mae’r chwaraewyr a’i hawliodd yn ei gadw.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Roedd am ddim i’w hawlio tan {date}. Nid yw’n cael ei gynnig bellach ac ni fydd yn cael ei werthu; mae’ch un chi yn parhau’n eiddo i chi.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Mae yn eich cwpwrdd dillad, yn eiddo i chi am byth. Gwisgwch ef o’r tab Cwpwrdd Dillad.",
|
||||
"January": "Ionawr",
|
||||
"July": "Gorffennaf",
|
||||
"June": "Mehefin",
|
||||
"March": "Mawrth",
|
||||
"May": "Mai",
|
||||
"Not open yet": "Heb agor eto",
|
||||
"November": "Tachwedd",
|
||||
"October": "Hydref",
|
||||
"Open the wardrobe": "Agor y cwpwrdd dillad",
|
||||
"Opens in {time}": "Yn agor ymhen {time}",
|
||||
"Opens {date}": "Yn agor {date}",
|
||||
"Owned": "Yn eich meddiant",
|
||||
"Preview": "Rhagolwg",
|
||||
"September": "Medi",
|
||||
"Shirt": "Crys",
|
||||
"Shop": "Siop",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Mewngofnodwch i’w hawlio. Mae colur yn perthyn i’r cyfrif Microsoft sy’n berchen ar Minecraft: Java Edition, felly maen nhw’n eich dilyn i bob cyfrifiadur.",
|
||||
"Soon": "Cyn bo hir",
|
||||
"The claim didn't go through: {why}": "Wnaeth yr hawlio ddim mynd drwodd: {why}",
|
||||
"The founder set": "Set y sylfaenwyr",
|
||||
"The founder set isn't offered any more.": "Nid yw set y sylfaenwyr yn cael ei chynnig bellach.",
|
||||
"The founder set was free to claim until {date}.": "Roedd set y sylfaenwyr am ddim i’w hawlio tan {date}.",
|
||||
"The shop didn't load.": "Wnaeth y siop ddim llwytho.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Mae’r siop angen cysylltiad. Gallwch chi newid yr hyn rydych chi’n ei wisgo yn y Cwpwrdd Dillad o hyd: mae’n cadw ar y cyfrifiadur hwn ac yn cysoni pan fyddwch chi ar-lein eto.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Pethau i’w gwisgo ar eich chwaraewr, i’w gweld gan bawb sy’n chwarae ar Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Mae’r hyn rydych chi’n ei wisgo yn dal i ddangos yn y gêm: cadwodd y lansiwr eich gwisg ddiwethaf. Ceisiwch eto mewn eiliad.",
|
||||
"You're offline.": "Rydych chi all-lein.",
|
||||
"Your name": "Eich enw",
|
||||
"less than a minute": "llai na munud",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} diwrnod",
|
||||
"{d} days {h} h": "{d} diwrnod {h} awr",
|
||||
"{h} h {m} min": "{h} awr {m} munud",
|
||||
"{m} min": "{m} munud",
|
||||
"{time} left to claim it": "{time} ar ôl i’w hawlio"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "Gem ændringer skifter den. Intet i dens mappe slettes.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "Dens mods lavet til {loader} bliver i dens mappe, men indlæses ikke.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "Dens mods lavet til Minecraft {from} bliver i dens mappe; nogle indlæses måske ikke i {to}.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Dens verdener blev gemt af Minecraft {from}: {to} opgraderer dem for altid og kan ikke åbne dem, hvis den er ældre."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Dens verdener blev gemt af Minecraft {from}: {to} opgraderer dem for altid og kan ikke åbne dem, hvis den er ældre.",
|
||||
"January": "januar",
|
||||
"February": "februar",
|
||||
"March": "marts",
|
||||
"April": "april",
|
||||
"May": "maj",
|
||||
"June": "juni",
|
||||
"July": "juli",
|
||||
"August": "august",
|
||||
"September": "september",
|
||||
"October": "oktober",
|
||||
"November": "november",
|
||||
"December": "december",
|
||||
"{day} {month} {year}": "{day}. {month} {year}",
|
||||
"{date}, {time}": "{date} kl. {time}",
|
||||
"less than a minute": "under et minut",
|
||||
"{m} min": "{m} min.",
|
||||
"{h} h {m} min": "{h} t. {m} min.",
|
||||
"1 day": "1 dag",
|
||||
"1 day {h} h": "1 dag {h} t.",
|
||||
"{d} days": "{d} dage",
|
||||
"{d} days {h} h": "{d} dage {h} t.",
|
||||
"{time} left to claim it": "{time} tilbage til at hente det",
|
||||
"Opens in {time}": "Åbner om {time}",
|
||||
"Opens {date}": "Åbner {date}",
|
||||
"Shop": "Butik",
|
||||
"Free": "Gratis",
|
||||
"Owned": "Ejet",
|
||||
"Soon": "Snart",
|
||||
"Your name": "Dit navn",
|
||||
"The founder set": "Grundlæggersættet",
|
||||
"Not open yet": "Ikke åbent endnu",
|
||||
"Free for founders, until {date}": "Gratis for grundlæggere indtil {date}",
|
||||
"Claiming…": "Henter…",
|
||||
"Open the wardrobe": "Åbn garderoben",
|
||||
"Claimed · Open the wardrobe": "Hentet · Åbn garderoben",
|
||||
"Back to the wardrobe": "Tilbage til garderoben",
|
||||
"You're offline.": "Du er offline.",
|
||||
"The shop didn't load.": "Butikken blev ikke indlæst.",
|
||||
"Changes waiting to sync: {n}": "Ændringer, der venter på synkronisering: {n}",
|
||||
"The claim didn't go through: {why}": "Det lykkedes ikke at hente det: {why}",
|
||||
"The founder set was free to claim until {date}.": "Grundlæggersættet var gratis at hente indtil {date}.",
|
||||
"The founder set isn't offered any more.": "Grundlæggersættet tilbydes ikke længere.",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Knyttet til din Minecraft-konto, på alle computere, du logger ind på.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Knyttet til din Minecraft-konto. Intet at betale, intet kort.",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetik vises kun for Horizon-spillere. Intet ændrer, hvordan spillet spilles.",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Til spillerne, der var her først. Gratis at hente fra {date}; når det er hentet, er det dit.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Til spillerne, der var her først. Gratis at hente indtil {date}; når det er hentet, er det dit.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Til spillerne, der var her først. Datoerne for at hente det er ikke fastsat endnu.",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Det tilbydes ikke længere og bliver ikke solgt. Dem, der hentede det, beholder det.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Det var gratis at hente indtil {date}. Det tilbydes ikke længere og sælges ikke; dit forbliver dit.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Det er i din garderobe og er dit. Tag det på fra fanen Garderobe.",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Log ind for at hente det. Kosmetik hører til den Microsoft-konto, der ejer Minecraft: Java Edition, så den følger dig på alle computere.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Butikken kræver forbindelse. Du kan stadig ændre, hvad du har på, i Garderoben: det gemmes på denne computer og synkroniseres, når du er online igen.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Ting din spiller kan bære, som alle, der spiller på Horizon, kan se.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Det, du har på, vises stadig i spillet: launcheren gemte dit sidste outfit. Prøv igen om lidt.",
|
||||
"Preview": "Forhåndsvisning",
|
||||
"Free for founders": "Gratis for grundlæggere",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Til spillerne, der var her først. Gratis at hente; når det er hentet, er det dit.",
|
||||
"First Light": "Første lys",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Gratis for alle. Første lys maler himlen lige før solopgang bag det lyse udseende, med en blå accent.",
|
||||
"Shirt": "Skjorte"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "„Änderungen speichern“ stellt sie um. In ihrem Ordner wird nichts gelöscht.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "Ihre Mods für {loader} bleiben in ihrem Ordner, laden aber nicht.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "Ihre Mods für Minecraft {from} bleiben in ihrem Ordner; manche laden in {to} vielleicht nicht.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Ihre Welten wurden von Minecraft {from} gespeichert: {to} aktualisiert sie endgültig und kann sie nicht öffnen, wenn es älter ist."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Ihre Welten wurden von Minecraft {from} gespeichert: {to} aktualisiert sie endgültig und kann sie nicht öffnen, wenn es älter ist.",
|
||||
"January": "Januar",
|
||||
"February": "Februar",
|
||||
"March": "März",
|
||||
"April": "April",
|
||||
"May": "Mai",
|
||||
"June": "Juni",
|
||||
"July": "Juli",
|
||||
"August": "August",
|
||||
"September": "September",
|
||||
"October": "Oktober",
|
||||
"November": "November",
|
||||
"December": "Dezember",
|
||||
"{day} {month} {year}": "{day}. {month} {year}",
|
||||
"{date}, {time}": "{date}, {time} Uhr",
|
||||
"less than a minute": "weniger als eine Minute",
|
||||
"{m} min": "{m} Min.",
|
||||
"{h} h {m} min": "{h} Std. {m} Min.",
|
||||
"1 day": "1 Tag",
|
||||
"1 day {h} h": "1 Tag {h} Std.",
|
||||
"{d} days": "{d} Tage",
|
||||
"{d} days {h} h": "{d} Tage {h} Std.",
|
||||
"{time} left to claim it": "Noch {time}, um es zu holen",
|
||||
"Opens in {time}": "Öffnet in {time}",
|
||||
"Opens {date}": "Öffnet am {date}",
|
||||
"Shop": "Shop",
|
||||
"Free": "Gratis",
|
||||
"Owned": "Im Besitz",
|
||||
"Soon": "Bald",
|
||||
"Your name": "Dein Name",
|
||||
"The founder set": "Das Gründer-Set",
|
||||
"Not open yet": "Noch nicht offen",
|
||||
"Free for founders, until {date}": "Gratis für Gründer bis {date}",
|
||||
"Claiming…": "Wird geholt…",
|
||||
"Open the wardrobe": "Kleiderschrank öffnen",
|
||||
"Claimed · Open the wardrobe": "Geholt · Kleiderschrank öffnen",
|
||||
"Back to the wardrobe": "Zurück zum Kleiderschrank",
|
||||
"You're offline.": "Du bist offline.",
|
||||
"The shop didn't load.": "Der Shop wurde nicht geladen.",
|
||||
"Changes waiting to sync: {n}": "Änderungen warten auf Abgleich: {n}",
|
||||
"The claim didn't go through: {why}": "Das Holen hat nicht geklappt: {why}",
|
||||
"The founder set was free to claim until {date}.": "Das Gründer-Set war bis {date} gratis erhältlich.",
|
||||
"The founder set isn't offered any more.": "Das Gründer-Set wird nicht mehr angeboten.",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "An dein Minecraft-Konto gebunden, auf jedem Computer, auf dem du dich anmeldest.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "An dein Minecraft-Konto gebunden. Nichts zu bezahlen, keine Karte nötig.",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetik sehen nur Horizon-Spieler. Am Spiel selbst ändert sich nichts.",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Für die Spieler, die von Anfang an dabei waren. Ab {date} gratis; einmal geholt, gehört es dir.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Für die Spieler, die von Anfang an dabei waren. Bis {date} gratis; einmal geholt, gehört es dir.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Für die Spieler, die von Anfang an dabei waren. Die Daten stehen noch nicht fest.",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Es wird nicht mehr angeboten und auch nicht verkauft. Wer es geholt hat, behält es.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Es war bis {date} gratis. Es wird nicht mehr angeboten und nicht verkauft; deins bleibt deins.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Es ist in deinem Kleiderschrank und gehört dir. Zieh es im Tab Kleiderschrank an.",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Melde dich an, um es zu holen. Kosmetik gehört zum Microsoft-Konto, dem Minecraft: Java Edition gehört, und folgt dir so auf jeden Computer.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Der Shop braucht eine Verbindung. Im Kleiderschrank kannst du trotzdem ändern, was du trägst: Es wird auf diesem Computer gespeichert und abgeglichen, sobald du wieder online bist.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Dinge für deinen Spieler, die alle sehen, die auf Horizon spielen.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Was du trägst, ist im Spiel weiter zu sehen: Der Launcher hat dein letztes Outfit behalten. Versuch es gleich noch einmal.",
|
||||
"Preview": "Vorschau",
|
||||
"Free for founders": "Gratis für Gründer",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Für die Spieler, die von Anfang an dabei waren. Gratis; einmal geholt, gehört es dir.",
|
||||
"First Light": "Erstes Licht",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Für alle kostenlos. Erstes Licht malt den Himmel kurz vor Sonnenaufgang hinter das helle Erscheinungsbild, mit blauem Akzent.",
|
||||
"Shirt": "Shirt"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"Save changes switches it. Nothing in its folder is deleted.": "Η Αποθήκευση αλλαγών την εφαρμόζει. Τίποτα στον φάκελό της δεν διαγράφεται.",
|
||||
"Its mods made for {loader} stay in its folder but won't load.": "Τα mods της για {loader} μένουν στον φάκελό της, αλλά δεν θα φορτώνονται.",
|
||||
"Its mods made for Minecraft {from} stay in its folder; some may not load in {to}.": "Τα mods της για Minecraft {from} μένουν στον φάκελό της· κάποια ίσως δεν φορτώνουν στο {to}.",
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Οι κόσμοι της αποθηκεύτηκαν από το Minecraft {from}: το {to} τους αναβαθμίζει οριστικά και δεν μπορεί να τους ανοίξει αν είναι παλαιότερο."
|
||||
"Its worlds were saved by Minecraft {from}: {to} upgrades them for good, and can't open them if it is older.": "Οι κόσμοι της αποθηκεύτηκαν από το Minecraft {from}: το {to} τους αναβαθμίζει οριστικά και δεν μπορεί να τους ανοίξει αν είναι παλαιότερο.",
|
||||
"1 day": "1 ημέρα",
|
||||
"1 day {h} h": "1 ημέρα {h} ώ",
|
||||
"April": "Απριλίου",
|
||||
"August": "Αυγούστου",
|
||||
"Back to the wardrobe": "Πίσω στη ντουλάπα",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Δεμένο με τον λογαριασμό σας στο Minecraft, σε κάθε υπολογιστή όπου συνδέεστε.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Δεμένο με τον λογαριασμό σας στο Minecraft. Τίποτα για πληρωμή, δεν ζητείται κάρτα.",
|
||||
"Changes waiting to sync: {n}": "Αλλαγές που περιμένουν συγχρονισμό: {n}",
|
||||
"Claimed · Open the wardrobe": "Έχει διεκδικηθεί · Άνοιγμα της ντουλάπας",
|
||||
"Claiming…": "Διεκδίκηση…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Τα καλλυντικά φαίνονται μόνο στους παίκτες του Horizon. Τίποτα δεν αλλάζει τον τρόπο που παίζεται το παιχνίδι.",
|
||||
"December": "Δεκεμβρίου",
|
||||
"February": "Φεβρουαρίου",
|
||||
"First Light": "Πρώτο Φως",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Για τους παίκτες που ήταν εδώ πρώτοι. Δωρεάν διεκδίκηση από τις {date}· μόλις το διεκδικήσετε, είναι δικό σας για πάντα.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Για τους παίκτες που ήταν εδώ πρώτοι. Δωρεάν διεκδίκηση έως τις {date}· μόλις το διεκδικήσετε, είναι δικό σας για πάντα.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Για τους παίκτες που ήταν εδώ πρώτοι. Δωρεάν διεκδίκηση· μόλις το διεκδικήσετε, είναι δικό σας για πάντα.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Για τους παίκτες που ήταν εδώ πρώτοι. Οι ημερομηνίες διεκδίκησης δεν έχουν οριστεί ακόμη.",
|
||||
"Free": "Δωρεάν",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Δωρεάν για όλους. Το Πρώτο Φως ζωγραφίζει τον ουρανό λίγο πριν την ανατολή πίσω από τη φωτεινή εμφάνιση, με μπλε τόνο.",
|
||||
"Free for founders": "Δωρεάν για ιδρυτές",
|
||||
"Free for founders, until {date}": "Δωρεάν για ιδρυτές, έως τις {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Δεν προσφέρεται πια και δεν θα πωληθεί. Οι παίκτες που το διεκδίκησαν το κρατούν.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Ήταν δωρεάν για διεκδίκηση έως τις {date}. Δεν προσφέρεται πια και δεν θα πωληθεί· το δικό σας μένει δικό σας.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Είναι στη ντουλάπα σας, δικό σας για πάντα. Φορέστε το από την καρτέλα Ντουλάπα.",
|
||||
"January": "Ιανουαρίου",
|
||||
"July": "Ιουλίου",
|
||||
"June": "Ιουνίου",
|
||||
"March": "Μαρτίου",
|
||||
"May": "Μαΐου",
|
||||
"Not open yet": "Δεν έχει ανοίξει ακόμη",
|
||||
"November": "Νοεμβρίου",
|
||||
"October": "Οκτωβρίου",
|
||||
"Open the wardrobe": "Άνοιγμα της ντουλάπας",
|
||||
"Opens in {time}": "Ανοίγει σε {time}",
|
||||
"Opens {date}": "Ανοίγει στις {date}",
|
||||
"Owned": "Στην κατοχή σας",
|
||||
"Preview": "Προεπισκόπηση",
|
||||
"September": "Σεπτεμβρίου",
|
||||
"Shirt": "Πουκάμισο",
|
||||
"Shop": "Κατάστημα",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Συνδεθείτε για να το διεκδικήσετε. Τα καλλυντικά ανήκουν στον λογαριασμό Microsoft που κατέχει το Minecraft: Java Edition, οπότε σας ακολουθούν σε κάθε υπολογιστή.",
|
||||
"Soon": "Σύντομα",
|
||||
"The claim didn't go through: {why}": "Η διεκδίκηση δεν ολοκληρώθηκε: {why}",
|
||||
"The founder set": "Το σετ ιδρυτών",
|
||||
"The founder set isn't offered any more.": "Το σετ ιδρυτών δεν προσφέρεται πια.",
|
||||
"The founder set was free to claim until {date}.": "Το σετ ιδρυτών ήταν δωρεάν για διεκδίκηση έως τις {date}.",
|
||||
"The shop didn't load.": "Το κατάστημα δεν φόρτωσε.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "Το κατάστημα χρειάζεται σύνδεση. Μπορείτε ακόμη να αλλάξετε τι φοράτε στην Ντουλάπα: αποθηκεύεται σε αυτόν τον υπολογιστή και συγχρονίζεται όταν επανέλθετε στο διαδίκτυο.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Πράγματα για να φορέσει ο παίκτης σας, ορατά σε όλους όσοι παίζουν στο Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Ό,τι φοράτε φαίνεται ακόμη στο παιχνίδι: το πρόγραμμα εκκίνησης κράτησε την τελευταία σας εμφάνιση. Δοκιμάστε ξανά σε λίγο.",
|
||||
"You're offline.": "Είστε εκτός σύνδεσης.",
|
||||
"Your name": "Το όνομά σας",
|
||||
"less than a minute": "λιγότερο από ένα λεπτό",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} ημέρες",
|
||||
"{d} days {h} h": "{d} ημέρες {h} ώ",
|
||||
"{h} h {m} min": "{h} ώ {m} λ",
|
||||
"{m} min": "{m} λ",
|
||||
"{time} left to claim it": "απομένουν {time} για να το διεκδικήσετε"
|
||||
}
|
||||
+62
-1
@@ -1091,5 +1091,66 @@
|
||||
"{count} on the official SMP": "{count} en la oficiala SMP",
|
||||
"{mode} · stopped": "{mode} · haltigita",
|
||||
"{online} of {max}": "{online} el {max}",
|
||||
"{support} in Horizon": "{support} en Horizon"
|
||||
"{support} in Horizon": "{support} en Horizon",
|
||||
"1 day": "1 tago",
|
||||
"1 day {h} h": "1 tago {h} h",
|
||||
"April": "aprilo",
|
||||
"August": "aŭgusto",
|
||||
"Back to the wardrobe": "Reen al la vestoŝranko",
|
||||
"Bound to your Minecraft account, on every computer you sign in on.": "Ligita al via Minecraft-konto, sur ĉiu komputilo, kie vi ensalutas.",
|
||||
"Bound to your Minecraft account. Nothing to pay, no card asked.": "Ligita al via Minecraft-konto. Nenio pagenda, neniu karto petata.",
|
||||
"Changes waiting to sync: {n}": "Ŝanĝoj atendantaj sinkronigon: {n}",
|
||||
"Claimed · Open the wardrobe": "Postulita · Malfermi la vestoŝrankon",
|
||||
"Claiming…": "Postulante…",
|
||||
"Cosmetics show only to Horizon players. Nothing changes how the game plays.": "Kosmetikaĵoj videblas nur al Horizon-ludantoj. Nenio ŝanĝas la manieron ludi.",
|
||||
"December": "decembro",
|
||||
"February": "februaro",
|
||||
"First Light": "Unua Lumo",
|
||||
"For the players who were here first. Free to claim from {date}; once claimed, it's yours to keep.": "Por la ludantoj, kiuj estis ĉi tie unue. Senpage postuleblas ekde {date}; post la postulo ĝi estas via por ĉiam.",
|
||||
"For the players who were here first. Free to claim until {date}; once claimed, it's yours to keep.": "Por la ludantoj, kiuj estis ĉi tie unue. Senpage postuleblas ĝis {date}; post la postulo ĝi estas via por ĉiam.",
|
||||
"For the players who were here first. Free to claim; once claimed, it's yours to keep.": "Por la ludantoj, kiuj estis ĉi tie unue. Senpage postuleblas; post la postulo ĝi estas via por ĉiam.",
|
||||
"For the players who were here first. The dates to claim it aren't set yet.": "Por la ludantoj, kiuj estis ĉi tie unue. La datoj por postuli ĝin ankoraŭ ne estas fiksitaj.",
|
||||
"Free": "Senpaga",
|
||||
"Free for everyone. First Light paints the sky just before sunrise behind the light appearance, with a blue accent.": "Senpaga por ĉiuj. Unua Lumo pentras la ĉielon ĝuste antaŭ la sunleviĝo malantaŭ la hela aspekto, kun blua emfazo.",
|
||||
"Free for founders": "Senpaga por fondintoj",
|
||||
"Free for founders, until {date}": "Senpaga por fondintoj, ĝis {date}",
|
||||
"It isn't offered any more, and it won't be sold. Players who claimed it keep it.": "Ĝi ne plu estas proponata kaj ne estos vendata. Ludantoj, kiuj ĝin postulis, konservas ĝin.",
|
||||
"It was free to claim until {date}. It isn't offered any more and won't be sold; yours stays yours.": "Ĝi estis senpage postulebla ĝis {date}. Ĝi ne plu estas proponata kaj ne estos vendata; via restas via.",
|
||||
"It's in your wardrobe, yours to keep. Wear it from the Wardrobe tab.": "Ĝi estas en via vestoŝranko, via por ĉiam. Portu ĝin el la langeto Vestoŝranko.",
|
||||
"January": "januaro",
|
||||
"July": "julio",
|
||||
"June": "junio",
|
||||
"March": "marto",
|
||||
"May": "majo",
|
||||
"Not open yet": "Ankoraŭ ne malfermita",
|
||||
"November": "novembro",
|
||||
"October": "oktobro",
|
||||
"Open the wardrobe": "Malfermi la vestoŝrankon",
|
||||
"Opens in {time}": "Malfermiĝas post {time}",
|
||||
"Opens {date}": "Malfermiĝas la {date}",
|
||||
"Owned": "Posedata",
|
||||
"Preview": "Antaŭvido",
|
||||
"September": "septembro",
|
||||
"Shirt": "Ĉemizo",
|
||||
"Shop": "Vendejo",
|
||||
"Sign in to claim it. Cosmetics belong to the Microsoft account that owns Minecraft: Java Edition, so they follow you to every computer.": "Ensalutu por postuli ĝin. Kosmetikaĵoj apartenas al la Microsoft-konto, kiu posedas Minecraft: Java Edition, do ili sekvas vin al ĉiu komputilo.",
|
||||
"Soon": "Baldaŭ",
|
||||
"The claim didn't go through: {why}": "La postulo ne sukcesis: {why}",
|
||||
"The founder set": "La fondinta aro",
|
||||
"The founder set isn't offered any more.": "La fondinta aro ne plu estas proponata.",
|
||||
"The founder set was free to claim until {date}.": "La fondinta aro estis senpage postulebla ĝis {date}.",
|
||||
"The shop didn't load.": "La vendejo ne ŝargiĝis.",
|
||||
"The shop needs a connection. You can still change what you wear in the Wardrobe: it saves on this computer and syncs when you're back online.": "La vendejo bezonas konekton. Vi ankoraŭ povas ŝanĝi, kion vi portas, en la Vestoŝranko: ĝi konserviĝas sur ĉi tiu komputilo kaj sinkroniĝas kiam vi revenos enrete.",
|
||||
"Things to wear for your player, seen by everyone who plays on Horizon.": "Aĵoj por porti sur via ludanto, videblaj al ĉiuj, kiuj ludas en Horizon.",
|
||||
"What you wear still shows in the game: the launcher kept your last outfit. Try again in a moment.": "Tio, kion vi portas, ankoraŭ montriĝas en la ludo: la lanĉilo konservis vian lastan vestaĵaron. Reprovu post momento.",
|
||||
"You're offline.": "Vi estas senrete.",
|
||||
"Your name": "Via nomo",
|
||||
"less than a minute": "malpli ol minuto",
|
||||
"{date}, {time}": "{date}, {time}",
|
||||
"{day} {month} {year}": "{day} {month} {year}",
|
||||
"{d} days": "{d} tagoj",
|
||||
"{d} days {h} h": "{d} tagoj {h} h",
|
||||
"{h} h {m} min": "{h} h {m} min",
|
||||
"{m} min": "{m} min",
|
||||
"{time} left to claim it": "restas {time} por postuli ĝin"
|
||||
}
|
||||
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user