Merge main into live transfer management branch

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
This commit is contained in:
rdbtCVSandCodex gpt-6.1-sol committed 2026-11-04 14:05:27 +00:00
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(),
+120 -33
View File
@@ -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", &macros);
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, &macros, &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();
}
+270 -109
View File
@@ -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, &macros, &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, &macros, &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, &macros, &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, &macros, &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, &macros, &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,
}
}
+5 -2
View File
@@ -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(&region, 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() {
+17 -1
View File
@@ -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);
+96 -3
View File
@@ -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 {
+137 -51
View File
@@ -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();
}
}
+36 -14
View File
@@ -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())
)])))
+60 -12
View File
@@ -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));
+176 -6
View File
@@ -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));
}
}
+69 -55
View File
@@ -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,
})
}
+11 -6
View File
@@ -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);
}
+1 -1
View File
@@ -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(
+21
View File
@@ -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())
+7 -3
View File
@@ -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,
+84 -5
View File
@@ -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(), &macros),
"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 {
+158 -25
View File
@@ -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"),
+96
View File
@@ -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")
+56 -13
View File
@@ -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(),
+12 -4
View File
@@ -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")
}
+51
View File
@@ -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());
}
}
+14 -10
View File
@@ -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.
+65
View File
@@ -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();
}
}
+191 -1
View File
@@ -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 =
+65
View File
@@ -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"}
}
}
+69
View File
@@ -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}
}
]
+139 -48
View File
@@ -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
]
);
}
}
+193 -10
View File
@@ -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);
}
}
+1 -1
View File
@@ -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::{
+72 -3
View File
@@ -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);
+81 -5
View File
@@ -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);
}
}
}
+165 -9
View File
@@ -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(), &macros, &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(),
&macros,
&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");
+2 -20
View File
@@ -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);
}
}
+20 -8
View File
@@ -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(())
}
+363 -8
View File
@@ -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"] {
+52 -2
View File
@@ -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"
));
}
+6 -1
View File
@@ -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());
}
+124 -5
View File
@@ -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 {
+92 -9
View File
@@ -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()))?;
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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