mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Compare commits
262
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fb6e4f20c | ||
|
|
7dfbd05ac3 | ||
|
|
4ece2302d4 | ||
|
|
8453750c94 | ||
|
|
2f29699b0a | ||
|
|
0073f3bb30 | ||
|
|
31fac68df4 | ||
|
|
07a1a4af76 | ||
|
|
1077a75e5f | ||
|
|
c77c40b021 | ||
|
|
12336db449 | ||
|
|
f010091162 | ||
|
|
c42b5b17f0 | ||
|
|
ab3eceeeb9 | ||
|
|
14868bea32 | ||
|
|
7950c14bba | ||
|
|
2c90c298c1 | ||
|
|
5dc270d3f9 | ||
|
|
482d31394c | ||
|
|
3a2bc05ae2 | ||
|
|
81d1cf35d1 | ||
|
|
e1597349dd | ||
|
|
6d43a3593b | ||
|
|
6475f62f3c | ||
|
|
48684a9b1b | ||
|
|
e1fca55972 | ||
|
|
4d6f1253d2 | ||
|
|
f521bd9eb1 | ||
|
|
971ded7bc7 | ||
|
|
c6734d6acb | ||
|
|
b85e07a5de | ||
|
|
f95da0d5e7 | ||
|
|
837e0498c2 | ||
|
|
89d08be7cf | ||
|
|
398aa7aa09 | ||
|
|
5c1f65bb9c | ||
|
|
7bc9b358dc | ||
|
|
abfb7aee9e | ||
|
|
a13845ca22 | ||
|
|
99f0a14b40 | ||
|
|
38df83828d | ||
|
|
c700853616 | ||
|
|
9eac9f79b1 | ||
|
|
af6e2b1295 | ||
|
|
67992e9fed | ||
|
|
bc6ac0ab30 | ||
|
|
0e010734d9 | ||
|
|
c2b104827c | ||
|
|
7481e652fc | ||
|
|
2d4f8380f2 | ||
|
|
9a223ef014 | ||
|
|
a55b9a2e52 | ||
|
|
8350e76e2a | ||
|
|
e4fe4e8122 | ||
|
|
f28f5b6d50 | ||
|
|
01bf400ff7 | ||
|
|
ea06a53506 | ||
|
|
d938f6a1dd | ||
|
|
c37a6c6543 | ||
|
|
26a79956fd | ||
|
|
e2cf10bbd0 | ||
|
|
c0df7b1959 | ||
|
|
dab0067925 | ||
|
|
40c4441550 | ||
|
|
09022c09b9 | ||
|
|
e221e9d786 | ||
|
|
f5243832bb | ||
|
|
9b6841e04e | ||
|
|
1161302d27 | ||
|
|
b3ec6f2d35 | ||
|
|
73d3ee3d20 | ||
|
|
04b8b27a3b | ||
|
|
638c0044f0 | ||
|
|
797ca1fab9 | ||
|
|
4b1157a697 | ||
|
|
c60bb5a341 | ||
|
|
e927a62b23 | ||
|
|
440d486f39 | ||
|
|
3c9dd1fb50 | ||
|
|
ee5ef77630 | ||
|
|
9dc66afd4d | ||
|
|
37aba3abe5 | ||
|
|
7d8f45fedd | ||
|
|
f897927efa | ||
|
|
0efb67fc12 | ||
|
|
435b2837cd | ||
|
|
b12f0ad465 | ||
|
|
c38354c637 | ||
|
|
6ea3787844 | ||
|
|
9d5e59e762 | ||
|
|
247d9941f2 | ||
|
|
acd184c1d9 | ||
|
|
4ceaf4a8c2 | ||
|
|
7d41c97f80 | ||
|
|
78f8d86785 | ||
|
|
a04888e69e | ||
|
|
88ee593627 | ||
|
|
056d097a9a | ||
|
|
d234d6daaa | ||
|
|
1294fca5e5 | ||
|
|
23261d97b3 | ||
|
|
422d9b6bbb | ||
|
|
6e9a91f185 | ||
|
|
8737bbfe7e | ||
|
|
7332139da4 | ||
|
|
7e7336db35 | ||
|
|
641cba9b30 | ||
|
|
26ae1d2991 | ||
|
|
be63efabcc | ||
|
|
1b3e7c0f7f | ||
|
|
9dcad939f7 | ||
|
|
f74b20d999 | ||
|
|
725a5dcfa4 | ||
|
|
269718fac1 | ||
|
|
23ae9e7f82 | ||
|
|
3319108619 | ||
|
|
7a969a6122 | ||
|
|
9dfb3149fb | ||
|
|
c6efd15f0c | ||
|
|
99725aa079 | ||
|
|
0b831b1dca | ||
|
|
81e9973c62 | ||
|
|
e584b02ce2 | ||
|
|
9cab156727 | ||
|
|
a61d9f473d | ||
|
|
a6ab89c89b | ||
|
|
47222e152f | ||
|
|
3fb4c3138e | ||
|
|
c94eb80064 | ||
|
|
17d4944fa9 | ||
|
|
e4b7c5723f | ||
|
|
57dba96d66 | ||
|
|
83d38f5735 | ||
|
|
201026e91e | ||
|
|
ad7e2b3594 | ||
|
|
6c597549a9 | ||
|
|
0b15d4a0d5 | ||
|
|
e2639c66d6 | ||
|
|
aba52d49b6 | ||
|
|
02f481fe07 | ||
|
|
9f7e16a7b6 | ||
|
|
ac67a24940 | ||
|
|
57c08c432b | ||
|
|
fd381c7ad7 | ||
|
|
a53ae76d3c | ||
|
|
3faaa3f304 | ||
|
|
e81cf7e35c | ||
|
|
09d7b3625e | ||
|
|
f6e559c186 | ||
|
|
5bea3820c1 | ||
|
|
efb950a10d | ||
|
|
8bd408af30 | ||
|
|
425145eb50 | ||
|
|
1fea8858dd | ||
|
|
b500705d23 | ||
|
|
6581318519 | ||
|
|
e132eeba6c | ||
|
|
9c92abadf9 | ||
|
|
eb03a89c7c | ||
|
|
d237664ed1 | ||
|
|
c0d5806f45 | ||
|
|
04104943fb | ||
|
|
b711d2336d | ||
|
|
0625049299 | ||
|
|
67191f028a | ||
|
|
b9fdf62763 | ||
|
|
5b58cd736e | ||
|
|
47b400625f | ||
|
|
e317145573 | ||
|
|
c4e0dd1600 | ||
|
|
ab7164bba1 | ||
|
|
72ca9db476 | ||
|
|
6d40db2be5 | ||
|
|
c218bbe9a0 | ||
|
|
a64ff0e803 | ||
|
|
0127a87ccf | ||
|
|
08187d1377 | ||
|
|
f32999331f | ||
|
|
e8794c917c | ||
|
|
f77fd4fbed | ||
|
|
0d4e0a1b1d | ||
|
|
e96694c5e8 | ||
|
|
f11f2f3736 | ||
|
|
9df8b23636 | ||
|
|
332f192412 | ||
|
|
19942359aa | ||
|
|
5470844f14 | ||
|
|
c57982648b | ||
|
|
9ad64d72c3 | ||
|
|
726cd34fb2 | ||
|
|
a5f384f89c | ||
|
|
66c3284171 | ||
|
|
4a4ec9a5ff | ||
|
|
5657fabce7 | ||
|
|
46387fb392 | ||
|
|
d6700da628 | ||
|
|
50888d15bc | ||
|
|
31ce9b1787 | ||
|
|
4f6a3fc20c | ||
|
|
c726e412a1 | ||
|
|
d934726850 | ||
|
|
ec5b8b4c31 | ||
|
|
c1ab6def52 | ||
|
|
f23ab6c91e | ||
|
|
0daeda2021 | ||
|
|
ee8c5b7b13 | ||
|
|
6ea9e9608f | ||
|
|
9841d6bffd | ||
|
|
dd3b316fc9 | ||
|
|
11121fc59c | ||
|
|
d9692d81b1 | ||
|
|
e4b9dfa9a4 | ||
|
|
5d6752f7bd | ||
|
|
797d6d0ae9 | ||
|
|
06f5846948 | ||
|
|
02205a31e0 | ||
|
|
6833688d8a | ||
|
|
85e7b3bfb5 | ||
|
|
2fb4d8f7a3 | ||
|
|
0e98e286f7 | ||
|
|
b87aee2af6 | ||
|
|
dd762d48ff | ||
|
|
36b6b3e935 | ||
|
|
e4db5de23c | ||
|
|
8e935b200d | ||
|
|
26261d9643 | ||
|
|
c4bc3a29d1 | ||
|
|
9cda105a19 | ||
|
|
9a99293888 | ||
|
|
3a0132d80e | ||
|
|
ce0d76008a | ||
|
|
d056f6d157 | ||
|
|
db726a1c49 | ||
|
|
29d8afbf25 | ||
|
|
d7bf2ad63c | ||
|
|
01d6c3d88b | ||
|
|
ab9032a188 | ||
|
|
ec6054c5c0 | ||
|
|
881a8b8be6 | ||
|
|
b227d63fe8 | ||
|
|
a900d9ad88 | ||
|
|
49d6e26bc2 | ||
|
|
5ca8699133 | ||
|
|
2d0f159464 | ||
|
|
ef9d8b67d5 | ||
|
|
9315b5a43f | ||
|
|
76ae2acc14 | ||
|
|
420ef24de8 | ||
|
|
ce776dff98 | ||
|
|
acc6d017c4 | ||
|
|
63554a33a7 | ||
|
|
685d0b1e8c | ||
|
|
ee480fe577 | ||
|
|
54a8a4e47c | ||
|
|
209f2fd34d | ||
|
|
b40b532a67 | ||
|
|
679b9f53be | ||
|
|
f90f7aac52 | ||
|
|
514bada684 | ||
|
|
8a529538f2 | ||
|
|
c5adff2477 | ||
|
|
709fa73ee3 |
No files matched your search
@@ -173,6 +173,16 @@ impl Registries {
|
||||
fill(&mut self.templates, "templates");
|
||||
fill(&mut self.persistent_components, "persistent_components");
|
||||
fill(&mut self.learnable_recipes, "learnable_recipes");
|
||||
if self.summonable.is_empty() {
|
||||
self.summonable = strings(&r["summonable"]);
|
||||
self.summonable.sort();
|
||||
self.summonable.dedup();
|
||||
}
|
||||
if self.entity_types.is_empty() {
|
||||
self.entity_types = strings(&r["entity_types"]);
|
||||
self.entity_types.sort();
|
||||
self.entity_types.dedup();
|
||||
}
|
||||
const RELOADED: [&str; 5] = [
|
||||
"minecraft:advancement",
|
||||
"minecraft:recipe",
|
||||
@@ -236,6 +246,16 @@ impl Registries {
|
||||
fill(&mut self.templates, &e.templates);
|
||||
fill(&mut self.persistent_components, &e.persistent_components);
|
||||
fill(&mut self.learnable_recipes, &e.learnable_recipes);
|
||||
if self.summonable.is_empty() {
|
||||
self.summonable.clone_from(&e.summonable);
|
||||
self.summonable.sort();
|
||||
self.summonable.dedup();
|
||||
}
|
||||
if self.entity_types.is_empty() {
|
||||
self.entity_types.clone_from(&e.entity_types);
|
||||
self.entity_types.sort();
|
||||
self.entity_types.dedup();
|
||||
}
|
||||
for (name, ids) in &e.by_registry {
|
||||
if !self.by_registry.contains_key(name) {
|
||||
self.by_registry.insert(name.clone(), ids.clone());
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -11,6 +11,7 @@ use crate::{
|
||||
block_entity_anim::{self, EnchantingBook},
|
||||
breaking::entity_geometry,
|
||||
fast_hash::FxHashMap,
|
||||
font::Glyph,
|
||||
gfx::mark_oit,
|
||||
hud::Rect,
|
||||
mesher::{CULL_BACK, Vertex},
|
||||
@@ -660,7 +661,7 @@ fn sign_text_calls(
|
||||
} else {
|
||||
(dark, 0, vertex_light(packed))
|
||||
};
|
||||
let advance = |c: char| (scene.glyph)(c).1;
|
||||
let advance = |c: char| (scene.glyph)(c).advance;
|
||||
let width = |s: &str| s.chars().map(advance).sum::<f32>().ceil();
|
||||
text.lines
|
||||
.iter()
|
||||
@@ -699,10 +700,19 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
let mut x = call.x + dx;
|
||||
let y0 = call.y + dy;
|
||||
for ch in call.text.chars() {
|
||||
let (cell, advance) = (scene.glyph)(ch);
|
||||
if let Some(r) = cell {
|
||||
let corners = [(x, y0 + 8.0), (x + 8.0, y0 + 8.0), (x + 8.0, y0), (x, y0)];
|
||||
let uvs = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
|
||||
let g = (scene.glyph)(ch);
|
||||
if let Some(cell) = g.rect {
|
||||
// BakedSheetGlyph: the glyph's own box from the pen, not a
|
||||
// fixed 8x8 cell (a Unifont glyph is 0.5 units per texel).
|
||||
let (x0, x1) = (x + g.left, x + g.right);
|
||||
let (top, bottom) = (y0 + g.top, y0 + g.bottom);
|
||||
let corners = [(x0, bottom), (x1, bottom), (x1, top), (x0, top)];
|
||||
let uvs = [
|
||||
[cell.u0, cell.v1],
|
||||
[cell.u1, cell.v1],
|
||||
[cell.u1, cell.v0],
|
||||
[cell.u0, cell.v0],
|
||||
];
|
||||
let c =
|
||||
0xFF00_0000 | (argb & 255) << 16 | (argb >> 8 & 255) << 8 | (argb >> 16 & 255);
|
||||
for ((cx, cy), uv) in corners.into_iter().zip(uvs) {
|
||||
@@ -721,7 +731,7 @@ fn draw_text(scene: &Scene, call: &TextCall, out: &mut Vec<Vertex>) {
|
||||
});
|
||||
}
|
||||
}
|
||||
x += advance;
|
||||
x += g.advance;
|
||||
}
|
||||
};
|
||||
if call.outline != 0 {
|
||||
@@ -818,8 +828,8 @@ pub struct Scene<'a> {
|
||||
/// profile → skin): a downloaded `skins/<sha1>` slot or the profile's
|
||||
/// `DefaultPlayerSkin` fallback; None keeps `SKIN_BY_TYPE` (`DefaultPlayerSkin.getDefaultTexture`, slim steve).
|
||||
pub skull: &'a dyn Fn([i32; 3]) -> Option<String>,
|
||||
/// Font glyph cell (none for blank glyphs such as the space) and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> (Option<Rect>, f32),
|
||||
/// Font glyph: atlas cell (none for blank glyphs such as the space), box and advance.
|
||||
pub glyph: &'a dyn Fn(char) -> Glyph,
|
||||
/// Changes whenever `glyph` may answer differently (`Font::epoch`): sign
|
||||
/// text laid out under another value is laid out again.
|
||||
pub glyphs: u64,
|
||||
@@ -3342,7 +3352,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Compare the cached world path with the original model renderer,
|
||||
@@ -3610,7 +3620,13 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
(Some(rect), 6.0)
|
||||
Glyph {
|
||||
rect: Some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
}
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
@@ -3660,6 +3676,78 @@ mod tests {
|
||||
assert!(out.iter().all(|v| v.pos[2] == 0.0));
|
||||
}
|
||||
|
||||
/// BakedSheetGlyph draws a glyph over its own box from the pen
|
||||
/// (x + left .. x + right, y + up .. y + down), not a fixed 8x8 cell. With
|
||||
/// Force Unicode Font a Latin letter is an 8x16 Unifont glyph at half
|
||||
/// scale: 4 units wide and 8 tall, advancing 5 (#8699).
|
||||
#[test]
|
||||
fn sign_text_draws_each_glyph_over_its_own_box() {
|
||||
let world = World::new(BlockStateId(0), 1);
|
||||
let sprites = HashMap::new();
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.125,
|
||||
w: 8,
|
||||
h: 16,
|
||||
}),
|
||||
left: 0.0,
|
||||
top: 0.0,
|
||||
right: 4.0,
|
||||
bottom: 8.0,
|
||||
advance: 5.0,
|
||||
offset: 0.5,
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::ZERO,
|
||||
orientation: Quat::IDENTITY,
|
||||
anim: &|_| BlockAnim::default(),
|
||||
texts: None,
|
||||
partial: 0.0,
|
||||
game_time: 0,
|
||||
max_y: 320,
|
||||
world: &world,
|
||||
sprites: &sprites,
|
||||
emissive: &Default::default(),
|
||||
translucent_sprites: &HashSet::new(),
|
||||
atlas_revision: 1,
|
||||
nether: false,
|
||||
lid: &|_| 0.0,
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &glyph,
|
||||
glyphs: 0,
|
||||
};
|
||||
let call = TextCall {
|
||||
matrix: Mat4::IDENTITY,
|
||||
x: 10.0,
|
||||
y: 2.0,
|
||||
text: "ab".into(),
|
||||
color: 0xFF00_0000,
|
||||
outline: 0,
|
||||
light: 240 | 240 << 8,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
draw_text(&scene, &call, &mut out);
|
||||
let boxes: Vec<_> = out
|
||||
.chunks(4)
|
||||
.map(|q| {
|
||||
let xs = q.iter().map(|v| v.pos[0]);
|
||||
let ys = q.iter().map(|v| v.pos[1]);
|
||||
[
|
||||
xs.clone().fold(f32::MAX, f32::min),
|
||||
xs.fold(f32::MIN, f32::max),
|
||||
ys.clone().fold(f32::MAX, f32::min),
|
||||
ys.fold(f32::MIN, f32::max),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(boxes, [[10.0, 14.0, 2.0, 10.0], [15.0, 19.0, 2.0, 10.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_sign_text_matches_and_follows_changes() {
|
||||
let renderer = BlockEntityRenderer {
|
||||
@@ -3714,7 +3802,13 @@ mod tests {
|
||||
w: 8,
|
||||
h: 8,
|
||||
};
|
||||
((c != ' ').then_some(rect), wide.get())
|
||||
Glyph {
|
||||
rect: (c != ' ').then_some(rect),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: wide.get(),
|
||||
..Glyph::default()
|
||||
}
|
||||
};
|
||||
let (first_sign, changed_sign, glowing_sign) = (
|
||||
|_| Some(&first_text),
|
||||
@@ -3879,7 +3973,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let mut verts = Vec::new();
|
||||
@@ -3964,7 +4058,7 @@ mod tests {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::{
|
||||
ModelPart, SIGN_TEXT_KEEP, Scene, StaticSectionRevision, classify,
|
||||
},
|
||||
chunk::Chunk,
|
||||
font::Glyph,
|
||||
hud::Rect,
|
||||
mesher::Vertex,
|
||||
signs::{SignData, SignText},
|
||||
@@ -355,7 +356,7 @@ fn batched_callbacks_preserve_entity_ranges_and_allow_immediate_culling() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
// Reverse local-position order to check that cache indexing does not
|
||||
@@ -443,7 +444,7 @@ fn static_cache_qualifies_local_positions_by_section_and_drops_old_entries() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -497,7 +498,7 @@ fn gpu_static_cache_uses_section_local_chests_and_keeps_visibility_separate() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([100, 70, -32], 0)];
|
||||
@@ -651,18 +652,19 @@ fn sign_text_cache_can_be_disabled_and_reenabled_without_changing_output() {
|
||||
};
|
||||
let sign_data = &data;
|
||||
let sign = move |_: [i32; 3]| Some(sign_data);
|
||||
let glyph = |_: char| {
|
||||
(
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
6.0,
|
||||
)
|
||||
let glyph = |_: char| Glyph {
|
||||
rect: Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 0.0625,
|
||||
v1: 0.0625,
|
||||
w: 8,
|
||||
h: 8,
|
||||
}),
|
||||
right: 8.0,
|
||||
bottom: 8.0,
|
||||
advance: 6.0,
|
||||
..Glyph::default()
|
||||
};
|
||||
let scene = Scene {
|
||||
camera: DVec3::new(2.25, 64.5, -3.75),
|
||||
@@ -735,7 +737,7 @@ fn shulker_cache_tracks_settled_poses_and_does_not_freeze_opening_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -836,7 +838,7 @@ fn shulker_cache_covers_all_colors_and_facing_directions() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -890,7 +892,7 @@ fn cached_bells_keep_shaking_until_the_animation_ends() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -951,7 +953,7 @@ fn cached_closed_chests_match_the_direct_model_and_rebuild_when_opening() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1046,7 +1048,7 @@ fn closed_chest_cache_covers_all_materials_and_atlas_reloads() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1134,7 +1136,7 @@ fn double_chest_cache_bypasses_both_halves_while_either_lid_is_open() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pair = [([0, 64, 0], 0), ([1, 64, 0], 1)];
|
||||
@@ -1209,7 +1211,7 @@ fn decorated_pot_cache_tracks_sherds_and_bypasses_wobble_animation() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1287,7 +1289,7 @@ fn banner_cache_keeps_the_pole_static_while_the_flag_and_patterns_update() {
|
||||
sign: &|_| None,
|
||||
banner: &banner_patterns,
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let blocks = [([0, 64, 0], 0)];
|
||||
@@ -1353,7 +1355,7 @@ fn cached_wall_banner_poles_use_the_wall_model() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let pos = [0, 64, 0];
|
||||
@@ -1400,7 +1402,7 @@ fn copper_statue_cache_rebuilds_when_its_pose_state_changes() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
@@ -1505,7 +1507,7 @@ fn copper_statue_cache_covers_weathering_waxing_and_all_poses() {
|
||||
sign: &|_| None,
|
||||
banner: &|_| Vec::new(),
|
||||
skull: &|_| None,
|
||||
glyph: &|_| (None, 0.0),
|
||||
glyph: &|_| Glyph::default(),
|
||||
glyphs: 0,
|
||||
};
|
||||
let options = CacheOptions {
|
||||
|
||||
@@ -58,6 +58,24 @@ impl DataPack {
|
||||
}
|
||||
out
|
||||
}
|
||||
/// The files of `data/<namespace>/<folder>/` in every namespace, as
|
||||
/// `list` names them; a folder pack walks only those folders.
|
||||
pub fn list_folder(&self, folder: &str) -> Vec<String> {
|
||||
let files = self.list_kinds(&[folder]);
|
||||
let Source::Zip(_) = &self.source else {
|
||||
return files;
|
||||
};
|
||||
files
|
||||
.into_iter()
|
||||
.filter(|f| {
|
||||
f.strip_prefix("data/")
|
||||
.and_then(|r| r.split_once('/'))
|
||||
.is_some_and(|(_, r)| {
|
||||
r.strip_prefix(folder).is_some_and(|r| r.starts_with('/'))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
|
||||
match &self.source {
|
||||
Source::Zip(z) => z.read(path).ok(),
|
||||
|
||||
@@ -24,7 +24,7 @@ pub const DEFAULT: &str = "minecraft:default";
|
||||
|
||||
/// A glyph ready to draw: atlas UVs and its box relative to the pen
|
||||
/// position (GlyphBitmap left/top/right/bottom), in GUI pixels.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct Glyph {
|
||||
pub rect: Option<Rect>,
|
||||
pub left: f32,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -885,6 +885,7 @@ mod tests {
|
||||
sorted_indices: None,
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1081,6 +1082,7 @@ mod tests {
|
||||
sorted_indices: None,
|
||||
flat_id: Some(id),
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
@@ -1307,6 +1309,7 @@ mod tests {
|
||||
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
|
||||
flat_id: None,
|
||||
gpu_id: None,
|
||||
gpu_fading: false,
|
||||
fading: None,
|
||||
leaving: None,
|
||||
animated_layers: Box::new([]),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
//! each in one draw (the pipelines cull the faces turned away), with the origin
|
||||
//! the walk would give it: `resident::origin`'s f32 bits, copied, never
|
||||
//! computed on the GPU. Within the render distance the CPU's walk then lists
|
||||
//! only the sections whose translucent quads it draws, or that have no record
|
||||
//! (`Resident::walk`); past it, it lists every section as before.
|
||||
//! Unlike the walk, the pass draws sections fading in or out whole.
|
||||
//! only the sections whose translucent quads it draws, that have no record, or
|
||||
//! that fade (`Resident::walk`); past it, it lists every section as before. A
|
||||
//! section whose instance carries a fade, fading in or out, has its record
|
||||
//! flagged (`FADING`, gfx.rs `mark_fades`) until it is whole: the pass leaves
|
||||
//! it out, and the CPU draws it with its fade.
|
||||
//!
|
||||
//! The graph is the one the walk reads that frame (`occlusion::Reach`). Its
|
||||
//! search keeps the cells it reached as bits too, and `Occlusion` a generation
|
||||
@@ -59,6 +61,7 @@ use super::{
|
||||
upload::Uploads,
|
||||
};
|
||||
use glam::{DVec3, DVec4, Vec3};
|
||||
use std::{cmp::Reverse, collections::BinaryHeap, time::Instant};
|
||||
|
||||
/// One section (`gpu_culling.wgsl`'s `Section`).
|
||||
#[repr(C)]
|
||||
@@ -68,10 +71,14 @@ pub(super) struct Record {
|
||||
key: [i32; 4],
|
||||
/// The first vertex of its solid quads (a draw's base vertex, an i32: a
|
||||
/// page holds fewer vertices), its solid quads, its box (`rounded_box`),
|
||||
/// then a word kept at 0.
|
||||
/// then its flags (`FADING`).
|
||||
a: [u32; 4],
|
||||
}
|
||||
|
||||
/// In `Record::a[3]`: the CPU draws the section's solid quads while it fades
|
||||
/// (gfx.rs `SectionGpu::gpu_fading`), and the pass leaves it out.
|
||||
const FADING: u32 = 1;
|
||||
|
||||
impl Record {
|
||||
const FREE: Self = Self {
|
||||
key: [0, 0, 0, -1],
|
||||
@@ -86,9 +93,19 @@ impl Record {
|
||||
a: [start as u32, quads, boxed, 0],
|
||||
}
|
||||
}
|
||||
/// The record, `FADING` when `on`.
|
||||
pub fn fading(mut self, on: bool) -> Self {
|
||||
if on {
|
||||
self.a[3] |= FADING;
|
||||
}
|
||||
self
|
||||
}
|
||||
fn page(&self) -> Option<u32> {
|
||||
u32::try_from(self.key[3]).ok()
|
||||
}
|
||||
fn is_fading(&self) -> bool {
|
||||
(self.a[3] & FADING) != 0
|
||||
}
|
||||
}
|
||||
|
||||
/// `rounded_box` for bounds past what a box's codes hold, or not finite: no
|
||||
@@ -419,6 +436,8 @@ pub(super) struct Records {
|
||||
all_dirty: bool,
|
||||
/// Per arena page, the records on it.
|
||||
page_sections: Vec<u32>,
|
||||
/// The records flagged `FADING`.
|
||||
fading: u32,
|
||||
}
|
||||
|
||||
impl Records {
|
||||
@@ -435,6 +454,7 @@ impl Records {
|
||||
};
|
||||
self.records[id as usize] = record;
|
||||
self.add_to(record.key[3] as u32);
|
||||
self.fading += u32::from(record.is_fading());
|
||||
self.mark(id);
|
||||
id
|
||||
}
|
||||
@@ -455,6 +475,7 @@ impl Records {
|
||||
continue;
|
||||
};
|
||||
self.page_sections[page as usize] -= 1;
|
||||
self.fading -= u32::from(self.records[id as usize].is_fading());
|
||||
self.records[id as usize] = Record::FREE;
|
||||
// shortcut: a teleport frees more than `DIRTY_LIMIT`, so `prepare` writes the
|
||||
// whole table (0.6-2.6 ms at render distance 64); upgrade to a list of freed ids
|
||||
@@ -502,6 +523,22 @@ impl Records {
|
||||
self.add_to(page);
|
||||
self.mark(id);
|
||||
}
|
||||
/// The section with `id` began to draw faded, or stopped (gfx.rs
|
||||
/// `SectionGpu::gpu_fading`): its record is `FADING` when `on`.
|
||||
pub fn set_fading(&mut self, id: u32, on: bool) {
|
||||
let page = self.records.get(id as usize).and_then(Record::page);
|
||||
debug_assert!(page.is_some(), "no record {id} to flag");
|
||||
if page.is_none() || self.records[id as usize].is_fading() == on {
|
||||
return;
|
||||
}
|
||||
self.records[id as usize].a[3] ^= FADING;
|
||||
if on {
|
||||
self.fading += 1;
|
||||
} else {
|
||||
self.fading -= 1;
|
||||
}
|
||||
self.mark(id);
|
||||
}
|
||||
/// Every section went.
|
||||
pub fn clear(&mut self) {
|
||||
self.records.clear();
|
||||
@@ -510,12 +547,17 @@ impl Records {
|
||||
self.dirty = Vec::new();
|
||||
self.all_dirty = false;
|
||||
self.page_sections.clear();
|
||||
self.fading = 0;
|
||||
}
|
||||
/// The sections with a record.
|
||||
pub fn live(&self) -> usize {
|
||||
let free: u32 = self.free.iter().map(|word| word.count_ones()).sum();
|
||||
self.records.len() - free as usize
|
||||
}
|
||||
/// The sections whose record is `FADING`, which the CPU draws.
|
||||
pub fn fading(&self) -> u32 {
|
||||
self.fading
|
||||
}
|
||||
/// The record of the section with `id`; `None` for a free id.
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn at(&self, id: u32) -> Option<&Record> {
|
||||
@@ -549,6 +591,42 @@ impl Records {
|
||||
}
|
||||
}
|
||||
|
||||
/// The flagged sections by when their fade is over at the latest (gfx.rs
|
||||
/// `SectionGpu::fade_end`), for gfx.rs `mark_fades` to look at them again
|
||||
/// then, and the chunk fade-in time their flags were decided with: `None`
|
||||
/// until a frame has decided them, just after GPU culling was switched on.
|
||||
/// The ends of sections that went or were handed back are skipped as they come.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Fades {
|
||||
due: BinaryHeap<Reverse<(Instant, SectionKey)>>,
|
||||
pub fade_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl Fades {
|
||||
/// The section at `key` is looked at again at `end`.
|
||||
pub fn push(&mut self, end: Instant, key: SectionKey) {
|
||||
self.due.push(Reverse((end, key)));
|
||||
}
|
||||
/// The section of the earliest end at or before `now`, taken out.
|
||||
pub fn pop_due(&mut self, now: Instant) -> Option<SectionKey> {
|
||||
let &Reverse((end, key)) = self.due.peek()?;
|
||||
if end > now {
|
||||
return None;
|
||||
}
|
||||
self.due.pop();
|
||||
Some(key)
|
||||
}
|
||||
/// Every end goes; `fade_ms` stays.
|
||||
pub fn clear(&mut self) {
|
||||
self.due.clear();
|
||||
}
|
||||
/// The sections an end is waited for.
|
||||
#[cfg(debug_assertions)]
|
||||
pub fn pending(&self) -> impl Iterator<Item = SectionKey> {
|
||||
self.due.iter().map(|r| r.0.1)
|
||||
}
|
||||
}
|
||||
|
||||
/// The pass, its buffers and the records it draws from: made when GPU culling
|
||||
/// is switched on, dropped when it is switched off.
|
||||
pub(super) struct GpuCulling {
|
||||
@@ -574,6 +652,9 @@ pub(super) struct GpuCulling {
|
||||
/// that the arena's compaction moves records while it borrows the
|
||||
/// renderer's other fields.
|
||||
pub records: Records,
|
||||
/// When the flagged records' fades end, a field of its own as `records`
|
||||
/// is: gfx.rs `mark_fades` takes both beside the renderer's sections.
|
||||
pub fades: Fades,
|
||||
/// This frame's pages with records: (page, first draw of its region, the
|
||||
/// region's size).
|
||||
pages: Vec<(u32, u32, u32)>,
|
||||
@@ -700,6 +781,7 @@ impl GpuCulling {
|
||||
reach_generation: None,
|
||||
group: None,
|
||||
records: Records::default(),
|
||||
fades: Fades::default(),
|
||||
pages: Vec::new(),
|
||||
ids: 0,
|
||||
n_updates: 0,
|
||||
@@ -975,8 +1057,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Where a section's solid quads are: key, arena page, first vertex, quads,
|
||||
/// and its box.
|
||||
type Placed = (SectionKey, u32, u64, u32, u32);
|
||||
/// its box, and whether it fades (`FADING`).
|
||||
type Placed = (SectionKey, u32, u64, u32, u32, bool);
|
||||
|
||||
/// `gpu_culling.wgsl`'s `near`, on the CPU.
|
||||
fn near(params: &Params, x: i32, z: i32) -> bool {
|
||||
@@ -1025,7 +1107,7 @@ mod tests {
|
||||
/// (and the pages `prepare` gives them).
|
||||
fn keeps(params: &Params, words: &[u32], record: &Record) -> bool {
|
||||
let (key, a) = (record.key, record.a);
|
||||
if key[3] < 0 || a[1] == 0 || !near(params, key[0], key[2]) {
|
||||
if key[3] < 0 || a[1] == 0 || (a[3] & FADING) != 0 || !near(params, key[0], key[2]) {
|
||||
return false;
|
||||
}
|
||||
let c: [u32; 3] = std::array::from_fn(|i| key[i].wrapping_sub(params.first[i]) as u32);
|
||||
@@ -1099,10 +1181,13 @@ mod tests {
|
||||
Some([min, max])
|
||||
}
|
||||
|
||||
/// Ids, page counts and changes against a plain list of what each id holds:
|
||||
/// the lowest free id comes back, free ids at the end leave, counts follow
|
||||
/// moves (to pages past the last too), a move keeps the box, a free is a
|
||||
/// change, and more changes than updates take mark the whole table.
|
||||
/// Ids, page counts, flags and changes against a plain list of what each id
|
||||
/// holds: the lowest free id comes back, flagged only if inserted so, free
|
||||
/// ids at the end leave, counts follow moves (to pages past the last too), a
|
||||
/// move keeps the box and the flag, a free is a change, a flag set changes
|
||||
/// its record only when it differs, the flagged ones are counted through
|
||||
/// inserts, flags, removals and clears, and more changes than updates take
|
||||
/// mark the whole table.
|
||||
#[test]
|
||||
fn records_keep_ids_page_counts_and_moves() {
|
||||
let mut records = Records::default();
|
||||
@@ -1140,7 +1225,7 @@ mod tests {
|
||||
};
|
||||
let mut rng = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
// What each id holds; `None`: a free id.
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11))];
|
||||
let mut model: Vec<Option<Placed>> = vec![Some(((2, 0, 0), 1, 0, 3, 11, false))];
|
||||
for step in 0..4_000 {
|
||||
let live: Vec<u32> = (0..model.len() as u32)
|
||||
.filter(|&id| model[id as usize].is_some())
|
||||
@@ -1149,20 +1234,22 @@ mod tests {
|
||||
rng.below(4 + step / 500),
|
||||
u64::from(rng.below(1 << 20)) * 256,
|
||||
);
|
||||
match rng.below(5) {
|
||||
let dirty = records.dirty.len();
|
||||
match rng.below(6) {
|
||||
op if op < 2 || live.is_empty() => {
|
||||
let key = (rng.range(-9, 9), rng.range(0, 23), rng.range(-9, 9));
|
||||
let (quads, boxed) = (1 + rng.below(500), rng.below(1 << 30));
|
||||
let fading = rng.below(4) == 0;
|
||||
let lowest = model
|
||||
.iter()
|
||||
.position(Option::is_none)
|
||||
.unwrap_or(model.len());
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let record = Record::new(key, page, start, quads, boxed).fading(fading);
|
||||
assert_eq!(records.insert(record) as usize, lowest);
|
||||
if lowest == model.len() {
|
||||
model.push(None);
|
||||
}
|
||||
model[lowest] = Some((key, page, start, quads, boxed));
|
||||
model[lowest] = Some((key, page, start, quads, boxed, fading));
|
||||
}
|
||||
op => {
|
||||
let id = live[rng.below(live.len() as u32) as usize];
|
||||
@@ -1170,6 +1257,12 @@ mod tests {
|
||||
if op < 4 {
|
||||
records.relocate(id, page, start);
|
||||
(held.1, held.2) = (page, start);
|
||||
} else if op == 4 {
|
||||
let on = rng.below(2) == 0;
|
||||
records.set_fading(id, on);
|
||||
let changed = usize::from(held.5 != on);
|
||||
assert_eq!(records.dirty.len(), dirty + changed, "step {step}");
|
||||
held.5 = on;
|
||||
} else {
|
||||
records.remove(id);
|
||||
// Marked: the GPU must have it free before the pass culls.
|
||||
@@ -1183,11 +1276,13 @@ mod tests {
|
||||
}
|
||||
assert_eq!(records.ids() as usize, model.len(), "step {step}");
|
||||
assert_eq!(records.live(), model.iter().flatten().count());
|
||||
let flagged = model.iter().flatten().filter(|held| held.5).count();
|
||||
assert_eq!(records.fading() as usize, flagged, "step {step}");
|
||||
let mut pages = vec![0; records.page_sections().len()];
|
||||
for (id, held) in model.iter().enumerate() {
|
||||
let record = held.map(|(key, page, start, quads, boxed)| {
|
||||
let record = held.map(|(key, page, start, quads, boxed, fading)| {
|
||||
pages[page as usize] += 1;
|
||||
Record::new(key, page, start, quads, boxed)
|
||||
Record::new(key, page, start, quads, boxed).fading(fading)
|
||||
});
|
||||
assert_eq!(
|
||||
records.at(id as u32),
|
||||
@@ -1204,16 +1299,17 @@ mod tests {
|
||||
let (mut each, mut all) = (Records::default(), Records::default());
|
||||
for n in 0..1_000 {
|
||||
let (key, page, start) = ((n % 31, n % 24, n / 31), n as u32 % 3, n as u64 * 256);
|
||||
let record = Record::new(key, page, start, 1, 0);
|
||||
let record = Record::new(key, page, start, 1, 0).fading(n % 7 == 0);
|
||||
assert_eq!(each.insert(record), all.insert(record));
|
||||
}
|
||||
let gone: Vec<u32> = (0..1_000).filter(|id| id % 3 != 1 || *id >= 900).collect();
|
||||
let counts = |r: &Records| (r.ids(), r.live(), r.fading());
|
||||
for ids in [&[1u32, 4, 898][..], &gone[..]] {
|
||||
for &id in ids {
|
||||
each.remove(id);
|
||||
}
|
||||
all.remove_all(ids.iter().copied());
|
||||
assert_eq!((all.ids(), all.live()), (each.ids(), each.live()));
|
||||
assert_eq!(counts(&all), counts(&each));
|
||||
assert_eq!(all.page_sections(), each.page_sections());
|
||||
assert_eq!(free_ids(&all), free_ids(&each));
|
||||
assert_eq!(all.dirty, each.dirty);
|
||||
@@ -1228,8 +1324,9 @@ mod tests {
|
||||
assert_eq!((all.ids(), all.live(), all.free.len()), (0, 0, 0));
|
||||
assert!(all.page_sections().iter().all(|&n| n == 0));
|
||||
|
||||
assert!(records.fading() > 0);
|
||||
records.clear();
|
||||
assert_eq!((records.ids(), records.live()), (0, 0));
|
||||
assert_eq!((records.ids(), records.live(), records.fading()), (0, 0, 0));
|
||||
assert!(records.page_sections().is_empty() && records.dirty.is_empty());
|
||||
for x in 0..DIRTY_LIMIT as i32 {
|
||||
records.insert(Record::new((x, 0, 0), 0, 0, 1, 0));
|
||||
@@ -1360,6 +1457,7 @@ mod tests {
|
||||
("UNBOUNDED", u64::from(UNBOUNDED)),
|
||||
("REACH", u64::from(REACH)),
|
||||
("SKY", u64::from(SKY)),
|
||||
("FADING", u64::from(FADING)),
|
||||
] {
|
||||
assert_eq!(constant(name), value, "{name}");
|
||||
}
|
||||
@@ -1784,7 +1882,9 @@ mod tests {
|
||||
/// reached or not: the pass reads the copy it holds when a frame brings the
|
||||
/// same graph (once through a bind group made anew), sends a new one after
|
||||
/// a frame the CPU draws, and makes its copy anew for one too large for its
|
||||
/// room.
|
||||
/// room. A fifth of the sections fade, flagged anew before each case
|
||||
/// (`FADING`, sent as updates or with the whole table): the pass leaves
|
||||
/// them to the CPU.
|
||||
#[test]
|
||||
fn the_gpu_draws_what_the_integer_test_keeps() {
|
||||
on_each_device(draws_match);
|
||||
@@ -1792,11 +1892,14 @@ mod tests {
|
||||
|
||||
fn draws_match(device: &wgpu::Device, queue: &Queue) {
|
||||
let mut rng = Rng(0x2545_F491_4F6C_DD1D);
|
||||
// The flags have their own sequence: the cases are otherwise as before.
|
||||
let mut flags = Rng(0x9E37_79B9_7F4A_7C15);
|
||||
let (min_y, height) = (-64, 24);
|
||||
let mut gpu = GpuCulling::new(device).expect("the pass");
|
||||
let mut uploads = Uploads::new(device);
|
||||
let mut held: BTreeMap<u32, Placed> = BTreeMap::new();
|
||||
let (mut camera, mut compared, mut culled) = (DVec3::ZERO, 0, 0);
|
||||
// Fading sections the frustum and the graph keep, left to the CPU.
|
||||
let (mut camera, mut compared, mut culled, mut faded) = (DVec3::ZERO, 0, 0, 0);
|
||||
let mut words = Vec::new();
|
||||
// Cave culling's graph while a case has one (its generation, from x and
|
||||
// z, radius, height and whether its search reached the sky) and its bits.
|
||||
@@ -1897,7 +2000,8 @@ mod tests {
|
||||
.records
|
||||
.insert(Record::new(key, page, start, quads, boxed));
|
||||
assert!(
|
||||
held.insert(id, (key, page, start, quads, boxed)).is_none(),
|
||||
held.insert(id, (key, page, start, quads, boxed, false))
|
||||
.is_none(),
|
||||
"id {id} twice"
|
||||
);
|
||||
};
|
||||
@@ -1912,6 +2016,13 @@ mod tests {
|
||||
place(&mut gpu, &mut held, &mut rng);
|
||||
}
|
||||
}
|
||||
// A fifth of the sections fade: the flags that change go up as
|
||||
// updates, or with the whole table (cases 4 and 18), and through
|
||||
// case 7's frame the CPU draws.
|
||||
for (&id, placed) in held.iter_mut() {
|
||||
placed.5 = flags.below(5) == 0;
|
||||
gpu.records.set_fading(id, placed.5);
|
||||
}
|
||||
let whole = gpu.records.all_dirty;
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let input = FrameInputs {
|
||||
@@ -1947,10 +2058,12 @@ mod tests {
|
||||
let mut params = tables(&input, &mut words).expect("the planes fit");
|
||||
params.pages = gpu.pass_pages;
|
||||
let mut expected = Vec::new();
|
||||
for &(key, page, start, quads, boxed) in held.values() {
|
||||
for &(key, page, start, quads, boxed, fading) in held.values() {
|
||||
let record = Record::new(key, page, start, quads, boxed);
|
||||
let kept = keeps(¶ms, &words, &record);
|
||||
let kept = keeps(¶ms, &words, &record.fading(fading));
|
||||
let reached = reach.is_none_or(|r| r.contains(key));
|
||||
// In view, but fading: the CPU's.
|
||||
faded += usize::from(fading && reached && keeps(¶ms, &words, &record));
|
||||
if kept && let Some(r) = reach {
|
||||
graphed += 1;
|
||||
unreached += usize::from(!reached);
|
||||
@@ -1965,7 +2078,7 @@ mod tests {
|
||||
let origin = resident::origin(key, min_y, camera).as_vec3();
|
||||
let instance = super::super::section_instance(origin.to_array(), 0, 1.0);
|
||||
expected.push((page, start as u32, quads * 6, instance.map(f32::to_bits)));
|
||||
} else if in_view_distance((cx, cz), radius, key.0, key.2) {
|
||||
} else if !fading && in_view_distance((cx, cz), radius, key.0, key.2) {
|
||||
culled += 1;
|
||||
}
|
||||
}
|
||||
@@ -2012,8 +2125,10 @@ mod tests {
|
||||
// Not a comparison of empty lists, nor of a test that keeps everything.
|
||||
// The graphs leave out a real share of what the frustum keeps, and their
|
||||
// sky band keeps sections above a box in some cases and not in others.
|
||||
// The flags leave out about a fifth of what they would keep.
|
||||
assert!(compared > 2_000, "{compared} draws");
|
||||
assert!(culled > 2_000, "{culled} within the distance left out");
|
||||
assert!(faded * 10 > compared, "{faded} fading of {compared} drawn");
|
||||
assert!(
|
||||
unreached * 10 > graphed && unreached < graphed,
|
||||
"{unreached} of {graphed} in view unreached"
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
// them as the GPU schedules, and where faces of two sections meet at the same
|
||||
// depth the later draw keeps the pixel). Nothing here computes with floats: the
|
||||
// origins are the CPU's bits, copied, and the frustum test adds integers the
|
||||
// CPU computed.
|
||||
// CPU computed. `cull` leaves sections fading in or out to the CPU (`FADING`).
|
||||
|
||||
struct Params {
|
||||
// Ids to test, records `apply` writes, the arena pages the counts and
|
||||
@@ -45,7 +45,7 @@ struct Section {
|
||||
// free id).
|
||||
key: vec4<i32>,
|
||||
// The first vertex of its solid quads, its solid quads, its box
|
||||
// (gpu_culling.rs `rounded_box`), then a word unused here.
|
||||
// (gpu_culling.rs `rounded_box`), then its flags (`FADING`).
|
||||
a: vec4<u32>,
|
||||
}
|
||||
|
||||
@@ -91,6 +91,8 @@ const UNBOUNDED: u32 = 0x80000000u;
|
||||
// reached the box's top layer (the sky band).
|
||||
const REACH: u32 = 1u;
|
||||
const SKY: u32 = 2u;
|
||||
// In a section's `a.w`: it fades in or out, and the CPU draws it with its fade.
|
||||
const FADING: u32 = 1u;
|
||||
|
||||
// The count of `block`'s draws in `page` (`cull`), then where they start (`scan`).
|
||||
fn block_count(block: u32, page: u32) -> u32 {
|
||||
@@ -186,7 +188,8 @@ fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
}
|
||||
runs[id] = 0u;
|
||||
let s = sections[id];
|
||||
if s.key.w < 0 || s.a.y == 0u || !near(s.key.x, s.key.z) || !reached(s.key.xyz) {
|
||||
if s.key.w < 0 || s.a.y == 0u || (s.a.w & FADING) != 0u || !near(s.key.x, s.key.z)
|
||||
|| !reached(s.key.xyz) {
|
||||
return;
|
||||
}
|
||||
let page = u32(s.key.w);
|
||||
|
||||
@@ -42,18 +42,32 @@ impl Output {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HORIZON_HDR_OUTPUT`'s names, in order of preference.
|
||||
const NAMES: [(Output, &str); 2] = [(Output::Pq, "pq"), (Output::ScRgb, "scrgb")];
|
||||
|
||||
/// The HDR output `caps` can present: HDR10 where offered, else scRGB.
|
||||
/// `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only that one.
|
||||
pub fn output(caps: &wgpu::SurfaceCapabilities) -> Option<Output> {
|
||||
pick(caps, std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn pick(caps: &wgpu::SurfaceCapabilities, only: Option<&str>) -> Option<Output> {
|
||||
[(Output::Pq, "pq"), (Output::ScRgb, "scrgb")]
|
||||
NAMES
|
||||
.into_iter()
|
||||
.filter(|(_, name)| only.is_none_or(|o| o == *name))
|
||||
.map(|(output, _)| output)
|
||||
.find(|o| caps.color_spaces(o.format()).contains(o.space()))
|
||||
}
|
||||
/// The HDR output an offscreen renderer (`--offscreen`) offers: it has no display to ask, so
|
||||
/// only the one `HORIZON_HDR_OUTPUT` names, for HDR screenshots without an HDR display.
|
||||
pub fn offscreen_output() -> Option<Output> {
|
||||
named(std::env::var("HORIZON_HDR_OUTPUT").ok().as_deref())
|
||||
}
|
||||
fn named(name: Option<&str>) -> Option<Output> {
|
||||
NAMES
|
||||
.into_iter()
|
||||
.find(|(_, n)| name == Some(*n))
|
||||
.map(|(output, _)| output)
|
||||
}
|
||||
|
||||
struct Targets {
|
||||
size: [u32; 2],
|
||||
@@ -637,7 +651,7 @@ fn scene_gain(peak: f32, paper_white: f32) -> f32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Output, pick};
|
||||
use super::{Output, named, pick};
|
||||
use wgpu::{SurfaceColorSpaces as S, TextureFormat as F};
|
||||
|
||||
#[test]
|
||||
@@ -687,6 +701,14 @@ mod tests {
|
||||
assert_eq!(pick(&scrgb, Some("pq")), None);
|
||||
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offscreen_offers_only_the_named_output() {
|
||||
assert_eq!(named(Some("pq")), Some(Output::Pq));
|
||||
assert_eq!(named(Some("scrgb")), Some(Output::ScRgb));
|
||||
assert_eq!(named(Some("hdr10")), None);
|
||||
assert_eq!(named(None), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -76,14 +76,14 @@ pub struct WorldDrawn<'a> {
|
||||
/// the run's first vertex and the entity, `(1 << 32) | id` for a mob or a player and
|
||||
/// `(2 << 32) | id` for a dropped item (their entity ids), 0 for anything else. A run lasts
|
||||
/// until the next. Unlike `spans`, whose ids name a type, these follow one entity from
|
||||
/// frame to frame (API @API:world_drawn_motion@).
|
||||
/// frame to frame (API 371).
|
||||
pub motion: &'a [(u32, u64)],
|
||||
/// The shared atlas entity UVs point into.
|
||||
pub atlas: Option<&'a wgpu::Texture>,
|
||||
/// Quad indices (0 1 2 0 2 3 for each 4 vertices), for as many quads as `entities` holds.
|
||||
pub quad_indices: &'a wgpu::Buffer,
|
||||
/// The block textures the terrain samples, for drawing blocks with the player's own
|
||||
/// textures (`native_block_sprites` says where a block's are). API @API:native_block_sprites@.
|
||||
/// textures (`native_block_sprites` says where a block's are). API 380.
|
||||
pub block_sprites: BlockSpriteArrays<'a>,
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
|
||||
}
|
||||
|
||||
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
|
||||
/// runs, with no hand too. API @API:hand_drawn@.
|
||||
/// runs, with no hand too. API 364.
|
||||
#[inline(never)]
|
||||
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
|
||||
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
|
||||
|
||||
@@ -26,6 +26,37 @@ impl PackAtlas {
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(
|
||||
device,
|
||||
encoder,
|
||||
arrays,
|
||||
split,
|
||||
layers,
|
||||
sprite,
|
||||
arrays[0].mip_level_count(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Encoded material channels must select an existing texel, including at distance.
|
||||
pub fn material(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
) -> Option<Self> {
|
||||
Self::with_levels(device, encoder, arrays, split, layers, arrays[0].width(), 1)
|
||||
}
|
||||
|
||||
fn with_levels(
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
arrays: &[wgpu::Texture],
|
||||
split: u32,
|
||||
layers: u32,
|
||||
sprite: u32,
|
||||
levels: u32,
|
||||
) -> Option<Self> {
|
||||
let layers = layers.max(1);
|
||||
let columns = (f64::from(layers).sqrt().ceil() as u32).max(1);
|
||||
@@ -35,7 +66,7 @@ impl PackAtlas {
|
||||
return None;
|
||||
}
|
||||
// Levels past a sprite's single pixel would mix neighbours: stop there.
|
||||
let levels = arrays[0].mip_level_count().min(sprite.ilog2() + 1);
|
||||
let levels = levels.min(sprite.ilog2() + 1);
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("Shader pack block atlas"),
|
||||
size: wgpu::Extent3d {
|
||||
@@ -252,6 +283,7 @@ impl PackAtlas {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ impl EntitySpan {
|
||||
}
|
||||
}
|
||||
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, a pad, `entityColor`.
|
||||
/// `HzDraw`'s size: `renderStage`, `entityId`, `blockEntityId`, `currentRenderedItemId`,
|
||||
/// `entityColor`.
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
pub(super) struct DrawUniforms {
|
||||
@@ -73,8 +74,10 @@ impl DrawUniforms {
|
||||
let mut contents = vec![0u8; stride as usize * RENDER_STAGES.len()];
|
||||
for (i, slot) in contents.chunks_mut(stride as usize).enumerate() {
|
||||
slot[..4].copy_from_slice(&(i as i32).to_le_bytes());
|
||||
// `blockEntityId` outside block entities, as Iris has it.
|
||||
// `blockEntityId` outside block entities, as Iris has it; `currentRenderedItemId`
|
||||
// outside items, as Iris 1.11.7 gives it to terrain, water and the sky (observed).
|
||||
slot[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
slot[12..16].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
}
|
||||
let buffer = wgpu::util::DeviceExt::create_buffer_init(
|
||||
device,
|
||||
|
||||
@@ -101,6 +101,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +163,7 @@ pub fn cloud_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,7 +428,9 @@ impl PackEntities {
|
||||
}),
|
||||
};
|
||||
let pack = program.is_some_and(|p| drawn[p]) && !span.hidden;
|
||||
let casts = part == Part::Plain && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
// Portal faces cast too (a pack's shadow pass sees block entities whole, as Iris
|
||||
// draws them); beams do not.
|
||||
let casts = part != Part::Beam && program.is_some_and(|p| shadow.cast(&span, p));
|
||||
if !pack && !span.hidden {
|
||||
extend_without_portals(rest, run);
|
||||
}
|
||||
@@ -1032,6 +1036,57 @@ mod gpu_span_tests {
|
||||
assert_eq!(skin.spans.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_portal_faces_reach_the_shadow_pass_with_their_block_entity() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
|
||||
return;
|
||||
};
|
||||
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let layout = super::super::pack_draw::DrawUniforms::new(&device);
|
||||
let mut geometry = PackEntities::new(&device, &layout.layout);
|
||||
// One block entity: a plain face, then an End portal face (overlay layer 9).
|
||||
let cpu: Vec<Vertex> = [0, 0, 0, 0, 9, 9, 9, 9]
|
||||
.into_iter()
|
||||
.map(|layer| Vertex {
|
||||
layer,
|
||||
..bytemuck::Zeroable::zeroed()
|
||||
})
|
||||
.collect();
|
||||
let spans = [EntitySpan {
|
||||
start: 0,
|
||||
id: 99,
|
||||
stage: render_stage("BLOCK_ENTITIES"),
|
||||
..Default::default()
|
||||
}];
|
||||
let mut rest = Vec::new();
|
||||
geometry.prepare(
|
||||
&device,
|
||||
&queue,
|
||||
&layout.layout,
|
||||
&cpu,
|
||||
&spans,
|
||||
[true; PROGRAMS],
|
||||
([0.; 4], [1; 2]),
|
||||
Casters {
|
||||
blocks: true,
|
||||
..Default::default()
|
||||
},
|
||||
&mut rest,
|
||||
None,
|
||||
);
|
||||
assert_eq!(
|
||||
(geometry.draws.iter())
|
||||
.map(|d| (d.portal, d.shadow))
|
||||
.collect::<Vec<_>>(),
|
||||
[(false, true), (true, true)],
|
||||
"the portal face casts as its block entity does"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn posed_and_cpu_draws_keep_offsets_uniforms_and_special_fallbacks() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
|
||||
@@ -13,9 +13,14 @@ const HAND_DEPTH: f32 = 0.125;
|
||||
/// `HzHand`: the hand's projection and its inverse.
|
||||
const UNIFORM: u64 = 2 * 64;
|
||||
|
||||
/// `HzDraw`'s size (pack_draw.rs).
|
||||
const SLOT: u64 = 32;
|
||||
|
||||
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
|
||||
pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
let mut inputs = super::pack_entities::inputs();
|
||||
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
|
||||
inputs.item_id_declared = true;
|
||||
inputs.declarations.push_str(
|
||||
"layout(set = 1, binding = 1) uniform HzHand { mat4 hz_hand_projection; mat4 hz_hand_projection_inverse; };\n",
|
||||
);
|
||||
@@ -45,10 +50,17 @@ pub struct PackHand {
|
||||
capacity: u32,
|
||||
/// Quads uploaded this frame.
|
||||
pub quads: u32,
|
||||
/// `HzDraw` (set 2) of each run: the arm or one held item, and its `currentRenderedItemId`.
|
||||
slots: wgpu::Buffer,
|
||||
pub slot_group: wgpu::BindGroup,
|
||||
stride: u64,
|
||||
/// This frame's runs: first quad, quads, and the dynamic offset of their slot.
|
||||
pub runs: Vec<(u32, u32, u32)>,
|
||||
}
|
||||
|
||||
impl PackHand {
|
||||
pub fn new(device: &wgpu::Device) -> Self {
|
||||
/// `draws`: the layout of `HzDraw` (pack_draw.rs).
|
||||
pub fn new(device: &wgpu::Device, draws: &wgpu::BindGroupLayout) -> Self {
|
||||
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::VERTEX,
|
||||
@@ -74,6 +86,28 @@ impl PackHand {
|
||||
});
|
||||
let capacity = 256;
|
||||
let (vertices, group) = Self::vertex_buffer(device, &layout, &uniform, capacity);
|
||||
let stride = SLOT.max(u64::from(
|
||||
device.limits().min_uniform_buffer_offset_alignment,
|
||||
));
|
||||
// The arm and the two hands' items: a few runs a frame.
|
||||
let slots = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
size: SLOTS * stride,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let slot_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack hand draws"),
|
||||
layout: draws,
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
|
||||
buffer: &slots,
|
||||
offset: 0,
|
||||
size: wgpu::BufferSize::new(SLOT),
|
||||
}),
|
||||
}],
|
||||
});
|
||||
Self {
|
||||
indices: super::pack_entities::quad_indices(device, capacity),
|
||||
layout,
|
||||
@@ -82,6 +116,10 @@ impl PackHand {
|
||||
group,
|
||||
capacity,
|
||||
quads: 0,
|
||||
slots,
|
||||
slot_group,
|
||||
stride,
|
||||
runs: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,18 +154,31 @@ impl PackHand {
|
||||
(buffer, group)
|
||||
}
|
||||
|
||||
/// Uploads this frame's hand and its projection.
|
||||
/// Uploads this frame's hand, its projection, and the `HzDraw` of each run `items` starts
|
||||
/// (first vertex, `currentRenderedItemId`; the slots past [`SLOTS`] share the last).
|
||||
pub fn prepare(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
hand: &[Vertex],
|
||||
items: &[(u32, i32)],
|
||||
projection: Mat4,
|
||||
) {
|
||||
self.quads = (hand.len() / 4) as u32;
|
||||
self.runs.clear();
|
||||
if self.quads == 0 {
|
||||
return;
|
||||
}
|
||||
let mut contents = Vec::new();
|
||||
for (start, quads, id) in runs(self.quads, items) {
|
||||
let slot = (self.runs.len() as u64).min(SLOTS - 1);
|
||||
if slot as usize == self.runs.len() {
|
||||
contents.extend_from_slice(&draw_slot(id));
|
||||
contents.resize(((slot + 1) * self.stride) as usize, 0);
|
||||
}
|
||||
self.runs.push((start, quads, (slot * self.stride) as u32));
|
||||
}
|
||||
queue.write_buffer(&self.slots, 0, &contents);
|
||||
if self.quads > self.capacity {
|
||||
self.capacity = self.quads.next_power_of_two();
|
||||
(self.vertices, self.group) =
|
||||
@@ -144,10 +195,54 @@ impl PackHand {
|
||||
}
|
||||
}
|
||||
|
||||
/// The hand's runs a frame keeps slots for.
|
||||
const SLOTS: u64 = 8;
|
||||
|
||||
/// `HzDraw` of a hand run: `renderStage` the solid hand's, `blockEntityId` -1 as outside block
|
||||
/// entities, and `currentRenderedItemId`.
|
||||
fn draw_slot(id: i32) -> [u8; SLOT as usize] {
|
||||
let mut bytes = [0u8; SLOT as usize];
|
||||
let stage = horizon_shaders::render_stage("HAND_SOLID") as i32;
|
||||
bytes[0..4].copy_from_slice(&stage.to_le_bytes());
|
||||
bytes[8..12].copy_from_slice(&(-1i32).to_le_bytes());
|
||||
bytes[12..16].copy_from_slice(&id.to_le_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
/// The runs of `quads` quads that `items` starts (first vertex, id), as first quad, quads and
|
||||
/// id; geometry before the first start, or with no starts at all, reads 0, as the arm does.
|
||||
fn runs(quads: u32, items: &[(u32, i32)]) -> Vec<(u32, u32, i32)> {
|
||||
let mut starts: Vec<(u32, i32)> = vec![(0, 0)];
|
||||
for &(start, id) in items {
|
||||
let q = (start / 4).min(quads);
|
||||
match starts.last_mut() {
|
||||
Some(last) if last.0 == q => last.1 = id,
|
||||
_ => starts.push((q, id)),
|
||||
}
|
||||
}
|
||||
(0..starts.len())
|
||||
.map(|i| {
|
||||
let end = starts.get(i + 1).map_or(quads, |s| s.0);
|
||||
(starts[i].0, end - starts[i].0, starts[i].1)
|
||||
})
|
||||
.filter(|r| r.1 > 0)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Each item's quads draw with its id; geometry before the first item is the arm's (0).
|
||||
#[test]
|
||||
fn runs_split_the_hand_by_item() {
|
||||
assert_eq!(runs(3, &[]), [(0, 3, 0)]);
|
||||
assert_eq!(runs(5, &[(0, 11), (8, 65535)]), [(0, 2, 11), (2, 3, 65535)]);
|
||||
assert_eq!(runs(4, &[(4, 21)]), [(0, 1, 0), (1, 3, 21)]);
|
||||
// An item that drew nothing gives way to the next.
|
||||
assert_eq!(runs(2, &[(0, 11), (0, 22)]), [(0, 2, 22)]);
|
||||
}
|
||||
|
||||
/// The hand's window depth lies within `MC_HAND_DEPTH` of the middle (packs tell the hand
|
||||
/// apart by it), and x, y match the plain projection.
|
||||
#[test]
|
||||
|
||||
@@ -50,6 +50,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
.collect(),
|
||||
after,
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! Original synthetic material transport oracle; no external shader algorithms or assets.
|
||||
use super::*;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; missing adapter is a failure"]
|
||||
fn terrain_material_samplers_preserve_surface_and_encoded_bytes() {
|
||||
let instance = crate::gfx::test_gpu::instance();
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Material transport GPU: {:?}", adapter.get_info());
|
||||
assert_ne!(adapter.get_info().device_type, wgpu::DeviceType::Cpu);
|
||||
let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
required_limits: adapter.limits(),
|
||||
..Default::default()
|
||||
}))
|
||||
.unwrap();
|
||||
let root = std::env::temp_dir().join(format!("horizon-material-pack-{}", std::process::id()));
|
||||
std::fs::create_dir_all(root.join("shaders")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.vsh"),
|
||||
"#version 120\nvoid main(){gl_Position=gl_Vertex;}",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
root.join("shaders/gbuffers_terrain.fsh"),
|
||||
r#"#version 120
|
||||
uniform sampler2D gtexture;
|
||||
uniform sampler2D normals;
|
||||
uniform sampler2D specular;
|
||||
void main() {
|
||||
int x = int(floor(gl_FragCoord.x));
|
||||
int layer = x - (x / 5) * 5;
|
||||
// Source coordinate (1.25,1.25): inside a texel, away from both its centre and boundary.
|
||||
vec2 uv = (vec2(float(layer - (layer / 3) * 3), float(layer / 3)) + 0.3125) / vec2(3.0, 2.0);
|
||||
if (x < 5) gl_FragData[0] = texture2DLod(specular, uv, 12.0);
|
||||
else if (x < 10) gl_FragData[0] = texture2DLod(normals, uv, 12.0);
|
||||
else gl_FragData[0] = texture2DLod(gtexture, uv, 0.0);
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (split, layers) = (2u32, 5u32);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let atlases: Vec<_> = (0..3)
|
||||
.map(|kind| {
|
||||
let side = if kind == 0 { 4 } else { 2 };
|
||||
let levels = if kind == 0 { 3 } else { 1 };
|
||||
let arrays: Vec<_> = (0..layers.div_ceil(split))
|
||||
.map(|array| {
|
||||
let count = (layers - array * split).min(split);
|
||||
let pixels: Vec<u8> = (0..count)
|
||||
.flat_map(|l| {
|
||||
let layer = (array * split + l) as u8;
|
||||
(0..levels).flat_map(move |level| {
|
||||
let edge = side >> level;
|
||||
(0..edge * edge).flat_map(move |pixel| {
|
||||
if level != 0 {
|
||||
// Sampling unused source mip levels must fail the oracle.
|
||||
[211, 212, 213, 214]
|
||||
} else {
|
||||
match kind {
|
||||
0 if layer == 3 => crate::sprites::NO_SPECULAR,
|
||||
0 => [
|
||||
17 + layer,
|
||||
if pixel % 2 == 0 { 0 } else { 240 },
|
||||
65,
|
||||
if pixel % 2 == 0 { 0 } else { 255 },
|
||||
],
|
||||
1 if layer == 2 => crate::sprites::FLAT_NORMAL,
|
||||
1 => [128 + layer, 126, 255, 19 + layer],
|
||||
_ => [40 + layer, 50, 60, 255],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
device.create_texture_with_data(
|
||||
&queue,
|
||||
&wgpu::TextureDescriptor {
|
||||
label: Some("Original fixture companion array"),
|
||||
size: wgpu::Extent3d {
|
||||
width: side,
|
||||
height: side,
|
||||
depth_or_array_layers: count,
|
||||
},
|
||||
mip_level_count: levels,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING
|
||||
| wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
},
|
||||
wgpu::util::TextureDataOrder::LayerMajor,
|
||||
&pixels,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
super::super::pack_atlas::PackAtlas::material(
|
||||
&device,
|
||||
&mut encoder,
|
||||
&arrays,
|
||||
split,
|
||||
layers,
|
||||
)
|
||||
.unwrap()
|
||||
})
|
||||
.collect();
|
||||
queue.submit([encoder.finish()]);
|
||||
let pack = horizon_shaders::Pack::open(&root).unwrap();
|
||||
let macros = horizon_shaders::Macros::standard();
|
||||
// Encoded zero alpha is data here and must remain observable in readback.
|
||||
let properties = horizon_shaders::Properties::parse("alphaTest.gbuffers_terrain=off", ¯os);
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
body: "vec2 p = vec2(float((gl_VertexIndex << 1) & 2), float(gl_VertexIndex & 2)); hz_Vertex=vec4(p*2.0-1.0,0.0,1.0);".into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &inputs);
|
||||
assert!(post.terrain.is_some(), "{:?}", post.notes);
|
||||
let world = World {
|
||||
atlas: Some((atlases[2].view.clone(), atlases[2].size())),
|
||||
material_maps: Some([atlases[1].view.clone(), atlases[0].view.clone()]),
|
||||
lightmap: atlases[2].view.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &[],
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: true,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: [1, 1],
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
};
|
||||
let mut post = PackPost::new(
|
||||
&device,
|
||||
&queue,
|
||||
&adapter,
|
||||
post,
|
||||
wgpu::TextureFormat::Rgba8Unorm,
|
||||
world,
|
||||
);
|
||||
let mut pass = post
|
||||
.terrain
|
||||
.take()
|
||||
.unwrap_or_else(|| panic!("terrain pipeline must compile: {:?}", post.notes));
|
||||
// Request the existing copy usage for the fixture's colour readback.
|
||||
post.copied |= 1;
|
||||
// First draw preserves production Geometry -> Pass scope, so a routing regression fails.
|
||||
for case in 0..3 {
|
||||
if case == 1 {
|
||||
pass.material_maps = false;
|
||||
}
|
||||
if case == 2 {
|
||||
pass.material_maps = true;
|
||||
post.material_maps = None;
|
||||
}
|
||||
let maps_expected = case == 0;
|
||||
pass.groups.clear();
|
||||
post.resize(&device, [15, 1]);
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Initialize fixture depth"),
|
||||
color_attachments: &[],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &post.targets.as_ref().unwrap().depth,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
let group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
{
|
||||
let mut render = post.begin(&mut encoder, &pass);
|
||||
render.set_pipeline(pass.render());
|
||||
render.set_bind_group(0, &group, &[]);
|
||||
if pass.draw {
|
||||
post.draws.bind(&mut render, "TERRAIN_SOLID");
|
||||
}
|
||||
render.draw(0..3, 0..1);
|
||||
}
|
||||
let output = post.targets.as_ref().unwrap().color[0].as_ref().unwrap()[0].texture();
|
||||
encoder.copy_texture_to_buffer(
|
||||
output.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
bytes_per_row: Some(256),
|
||||
..Default::default()
|
||||
},
|
||||
},
|
||||
wgpu::Extent3d {
|
||||
width: 15,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let data = read.slice(..).get_mapped_range().unwrap();
|
||||
for i in 0..15usize {
|
||||
let layer = (i % 5) as u8;
|
||||
let expected = if i >= 10 {
|
||||
[40 + layer, 50, 60, 255]
|
||||
} else if i >= 5 && (!maps_expected || layer == 2) {
|
||||
crate::sprites::FLAT_NORMAL
|
||||
} else if i >= 5 {
|
||||
[128 + layer, 126, 255, 19 + layer]
|
||||
} else if !maps_expected || layer == 3 {
|
||||
crate::sprites::NO_SPECULAR
|
||||
} else {
|
||||
[17 + layer, 240, 65, 255]
|
||||
};
|
||||
assert_eq!(&data[i * 4..i * 4 + 4], &expected, "case={case}, pixel={i}");
|
||||
}
|
||||
eprintln!("Material oracle case={case}: 15 exact RGBA pixels pass");
|
||||
}
|
||||
// Fault injection: the same production binding must refuse a filtering sampler.
|
||||
// If a regression changed both the binding and sampler, the fractional positive
|
||||
// probes above would instead mix 0.75*240=180 and 0.75*255=191.25, not 240/255.
|
||||
pass.material_maps = true;
|
||||
post.material_maps = Some([atlases[1].view.clone(), atlases[0].view.clone()]);
|
||||
post.nearest_repeat = sampler(&device, wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat);
|
||||
pass.groups.clear();
|
||||
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
let _invalid_group = post.bind(&device, &queue, &mut encoder, &mut pass, [15, 1]);
|
||||
let error = pollster::block_on(validation.pop())
|
||||
.expect("production material binding must reject a filtering sampler");
|
||||
assert!(
|
||||
error.to_string().to_ascii_lowercase().contains("filtering"),
|
||||
"{error}"
|
||||
);
|
||||
eprintln!("Material oracle negative filtering control: rejected by production binding");
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
@@ -5,12 +5,13 @@
|
||||
//! Gbuffers programs come later; until then the pack's composites read Horizon's picture.
|
||||
//!
|
||||
//! Every colortex buffer a program reads or writes has two textures, A and B. Programs read
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). The
|
||||
//! mapping carries over to the next frame, so a buffer that is not cleared reads back what was
|
||||
//! written last (OQ-8). A cleared buffer has both textures cleared at the start of the frame:
|
||||
//! with an odd number of flips a frame, the texture a program writes was "main" the frame before
|
||||
//! and may still hold what was stored into it after that frame's clear (Iris 1.11.7 blends onto
|
||||
//! the clear value there, probed with a pack of our own).
|
||||
//! "main" and write "alt", and each buffer a program wrote flips afterwards (6.1.6). Every
|
||||
//! frame starts with A as "main". A cleared buffer has both textures cleared then (Iris 1.11.7
|
||||
//! reads and blends onto the clear value where a program leaves pixels, probed with a pack of our
|
||||
//! own). A buffer that is not cleared reads back what was written last (OQ-8): when the last
|
||||
//! frame ended on B, B is copied to A, and B keeps what was last written into it, which is what
|
||||
//! a write leaves alone or blends onto there (a model that fits Iris 1.11.7's values in probe
|
||||
//! packs of our own).
|
||||
//! Pack buffers store rows as GL does (row 0 at the bottom: the translator negates clip y), so
|
||||
//! the world is copied in upside down, and its reverse-Z depth becomes GL window depth.
|
||||
|
||||
@@ -34,6 +35,9 @@ mod world_target_tests;
|
||||
#[cfg(test)]
|
||||
mod higher_shadowcolor_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod forced_flip_tests;
|
||||
|
||||
/// The texture behind a pack sampler name.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Source {
|
||||
@@ -96,22 +100,19 @@ enum SamplerKind {
|
||||
impl SamplerKind {
|
||||
fn of(source: Source, filtering: bool) -> Self {
|
||||
match source {
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
if !filtering =>
|
||||
{
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
|
||||
Self::Nearest
|
||||
}
|
||||
// shadowtex0/1 through a plain sampler: linear, as Iris 1.11.7 reads them, when the
|
||||
// device filters their copies (`ShadowTargets::linear`).
|
||||
Source::ShadowDepth(_, false) if filtering => Self::Linear,
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
|
||||
Self::Nearest
|
||||
}
|
||||
Source::Noise => Self::Repeat,
|
||||
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
|
||||
// Blocks look as the game draws them: nearest texels.
|
||||
Source::Atlas => Self::NearestRepeat,
|
||||
Source::Atlas | Source::Normals | Source::Specular => Self::NearestRepeat,
|
||||
Source::Image(_) if filtering => Self::Linear,
|
||||
Source::Image(_) => Self::Nearest,
|
||||
Source::Custom { blur, clamp, .. } => match (blur && filtering, clamp) {
|
||||
@@ -120,12 +121,9 @@ impl SamplerKind {
|
||||
(true, false) => Self::Repeat,
|
||||
(false, false) => Self::NearestRepeat,
|
||||
},
|
||||
Source::Color(_)
|
||||
| Source::ShadowColor(_)
|
||||
| Source::Dummy
|
||||
| Source::Normals
|
||||
| Source::Specular
|
||||
| Source::Lightmap => Self::Linear,
|
||||
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
|
||||
Self::Linear
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +364,7 @@ struct Pass {
|
||||
draw: bool,
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: bool,
|
||||
/// Reads `mc_Entity`'s table (`PackPost::block_ids`).
|
||||
ids: bool,
|
||||
/// Bind groups by the sides read (bits 0–15), the buffers whose mip chain is current (bits
|
||||
@@ -437,6 +436,7 @@ struct Targets {
|
||||
pub(super) struct World<'a> {
|
||||
/// The block atlas and its size.
|
||||
pub atlas: Option<(wgpu::TextureView, [u32; 2])>,
|
||||
pub material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
pub lightmap: wgpu::TextureView,
|
||||
/// Terrain's geometry, when the pack draws it.
|
||||
pub terrain: Option<Geometry<'a>>,
|
||||
@@ -522,8 +522,9 @@ pub struct PackPost {
|
||||
lines: Option<Pass>,
|
||||
line_draw: Option<Lines>,
|
||||
cloud_draw: Option<Clouds>,
|
||||
/// The pack has `gbuffers_clouds`, even one that writes nothing (a pack drawing its own
|
||||
/// clouds discards vanilla's), or `clouds=off`: the game's clouds are not drawn.
|
||||
/// The pack draws the clouds (`gbuffers_clouds` or its fallback), even with a program that
|
||||
/// writes nothing (a pack drawing its own clouds discards vanilla's), or `clouds=off`: the
|
||||
/// game's clouds are not drawn.
|
||||
hides_clouds: bool,
|
||||
/// The pack has a sky (`gbuffers_skybasic`) or sun and moon (`gbuffers_skytextured`)
|
||||
/// program: vanilla's stays hidden even when the program writes nothing.
|
||||
@@ -573,6 +574,7 @@ pub struct PackPost {
|
||||
/// `shaders.properties`' custom uniforms, after the frame's in `uniforms.values`.
|
||||
custom: horizon_shaders::Custom,
|
||||
atlas: Option<wgpu::TextureView>,
|
||||
material_maps: Option<[wgpu::TextureView; 2]>,
|
||||
lightmap: wgpu::TextureView,
|
||||
deferred_pre: u16,
|
||||
composite_pre: u16,
|
||||
@@ -617,7 +619,9 @@ pub struct PackPost {
|
||||
nearest: wgpu::Sampler,
|
||||
repeat: wgpu::Sampler,
|
||||
nearest_repeat: wgpu::Sampler,
|
||||
blit: (wgpu::RenderPipeline, wgpu::BindGroupLayout),
|
||||
/// None when it would write nothing: colortex0 holds integers and there is no depth to copy
|
||||
/// nor buffer to clear.
|
||||
blit: Option<(wgpu::RenderPipeline, wgpu::BindGroupLayout)>,
|
||||
/// The blit also writes depthtex0 (some program reads it) and clears these buffers.
|
||||
blit_depth: bool,
|
||||
blit_clears: Vec<usize>,
|
||||
@@ -939,6 +943,14 @@ fn blend_state(b: horizon_shaders::Blend) -> wgpu::BlendState {
|
||||
}
|
||||
}
|
||||
|
||||
/// A buffer whose texels are floats (normalized or not), not integers.
|
||||
fn holds_floats(format: wgpu::TextureFormat) -> bool {
|
||||
matches!(
|
||||
format.sample_type(None, None),
|
||||
Some(wgpu::TextureSampleType::Float { .. })
|
||||
)
|
||||
}
|
||||
|
||||
fn target(format: wgpu::TextureFormat) -> Option<wgpu::ColorTargetState> {
|
||||
Some(wgpu::ColorTargetState {
|
||||
format,
|
||||
@@ -1060,6 +1072,8 @@ pub(super) struct Geometry<'a> {
|
||||
pub depth_write: bool,
|
||||
/// What `texture` reads instead of the block atlas.
|
||||
pub atlas: Option<wgpu::TextureView>,
|
||||
/// This draw uses the block atlas's surface identities.
|
||||
pub material_maps: bool,
|
||||
/// `mc_Entity`'s table at set 3 (flat terrain).
|
||||
pub ids: Option<&'a wgpu::BindGroupLayout>,
|
||||
/// Back faces are culled, as for the game's own terrain.
|
||||
@@ -1089,6 +1103,9 @@ struct Resources<'a> {
|
||||
released: std::cell::Cell<(&'static str, u16)>,
|
||||
/// The formats and dimensions of the images no `image.*` gives, for their stand-ins.
|
||||
stand_ins: &'a std::cell::RefCell<Vec<(wgpu::TextureFormat, wgpu::TextureViewDimension)>>,
|
||||
/// Bit n: shadowtex n read through a plain sampler filters linearly (`shadowtexNNearest`
|
||||
/// unset, and the device filters `R32Float`).
|
||||
shadow_linear: u8,
|
||||
}
|
||||
|
||||
/// A program's bind group layout (its uniform block, samplers and images) and how each sampler
|
||||
@@ -1213,6 +1230,7 @@ fn bindings(
|
||||
Source::ShadowColor(n) => shadow
|
||||
.and_then(|f| f[n].sample_type(None, Some(features)))
|
||||
.unwrap_or(float),
|
||||
Source::ShadowDepth(n, false) if pack.shadow_linear & 1 << n != 0 => float,
|
||||
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
|
||||
wgpu::TextureSampleType::Float { filterable: false }
|
||||
}
|
||||
@@ -1531,11 +1549,12 @@ fn build_pass(
|
||||
.iter()
|
||||
.filter(|f| !f.1)
|
||||
.fold(0u16, |m, f| m | 1 << f.0);
|
||||
// Gbuffers programs write in place (6.1.6).
|
||||
// Gbuffers programs write in place (6.1.6). A forced flip of a buffer the program writes
|
||||
// cancels the write's own flip, as in Iris 1.11.7 (probed with packs of our own).
|
||||
let flip = if final_pass || geometry.is_some() {
|
||||
0
|
||||
} else {
|
||||
(written & !kept) | mask(&p.flips)
|
||||
(written & !kept) ^ mask(&p.flips)
|
||||
};
|
||||
let created = checked(device, || {
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
@@ -1624,7 +1643,11 @@ fn build_pass(
|
||||
.map(|u| u.name.as_str())
|
||||
.collect();
|
||||
if !missing.is_empty() {
|
||||
notes.push(format!("{}: zero for now: {}", p.name, missing.join(" ")));
|
||||
notes.push(format!(
|
||||
"{}: shader defaults or zero: {}",
|
||||
p.name,
|
||||
missing.join(" ")
|
||||
));
|
||||
}
|
||||
let image_copies = image_copies(&samplers, &images);
|
||||
Some(Pass {
|
||||
@@ -1634,7 +1657,7 @@ fn build_pass(
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -1652,6 +1675,7 @@ fn build_pass(
|
||||
mipmaps: p.mipmaps,
|
||||
draw,
|
||||
atlas: geometry.and_then(|g| g.atlas.clone()),
|
||||
material_maps: geometry.is_some_and(|g| g.material_maps),
|
||||
ids: geometry.is_some_and(|g| g.ids.is_some()),
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -1762,7 +1786,7 @@ fn build_compute(
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
}),
|
||||
block: vec![0; interface.uniform_size as usize],
|
||||
block: interface.initial_values.clone(),
|
||||
uniforms: interface
|
||||
.uniforms
|
||||
.iter()
|
||||
@@ -1787,6 +1811,7 @@ fn build_compute(
|
||||
mipmaps: 0,
|
||||
draw: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: false,
|
||||
groups: HashMap::new(),
|
||||
portal_groups: HashMap::new(),
|
||||
@@ -1940,6 +1965,13 @@ impl PackPost {
|
||||
rgb,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: if features.contains(wgpu::Features::FLOAT32_FILTERABLE) {
|
||||
(0..2)
|
||||
.filter(|&n| !shadow_config.nearest[n])
|
||||
.fold(0, |m, n| m | 1 << n)
|
||||
} else {
|
||||
0
|
||||
},
|
||||
};
|
||||
let mut build = |mut p: horizon_shaders::PostProgram, geometry: Option<&Geometry<'_>>| {
|
||||
let in_shadow = p.group.shadow();
|
||||
@@ -2032,6 +2064,7 @@ impl PackPost {
|
||||
table: g.table,
|
||||
depth_write: g.depth_write,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: block_ids.as_ref().map(|i| &i.0),
|
||||
cull: g.cull,
|
||||
});
|
||||
@@ -2059,6 +2092,7 @@ impl PackPost {
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2068,6 +2102,7 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: false,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2106,6 +2141,7 @@ impl PackPost {
|
||||
table: Some(&e.table),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
@@ -2117,6 +2153,7 @@ impl PackPost {
|
||||
table: g.table,
|
||||
depth_write: false,
|
||||
atlas: g.atlas.clone(),
|
||||
material_maps: g.material_maps,
|
||||
ids: g.ids,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2144,6 +2181,7 @@ impl PackPost {
|
||||
table: Some(layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
@@ -2153,16 +2191,20 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
});
|
||||
let hand_geometry = world.hand.then(|| super::pack_hand::PackHand::new(device));
|
||||
let hand_geometry = world
|
||||
.hand
|
||||
.then(|| super::pack_hand::PackHand::new(device, &draws.layout));
|
||||
let hand = post.hand.zip(hand_geometry).and_then(|(p, geometry)| {
|
||||
let g = Geometry {
|
||||
buffers: &entity_buffers,
|
||||
table: Some(&geometry.layout),
|
||||
depth_write: true,
|
||||
atlas: world.celestial.clone(),
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2187,6 +2229,7 @@ impl PackPost {
|
||||
table: None,
|
||||
depth_write: false,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
};
|
||||
@@ -2313,8 +2356,21 @@ impl PackPost {
|
||||
let depth1 = every_pass()
|
||||
.flat_map(|p| &p.samplers)
|
||||
.any(|s| matches!(s.source, Source::ShadowDepth(1, _)));
|
||||
let t =
|
||||
super::pack_shadow::ShadowTargets::new(device, &shadow_config, f, colors, depth1);
|
||||
let linear = every_pass()
|
||||
.flat_map(|p| &p.samplers)
|
||||
.filter(|s| s.filtering)
|
||||
.fold(0u8, |m, s| match s.source {
|
||||
Source::ShadowDepth(n, false) => m | 1 << n,
|
||||
_ => m,
|
||||
});
|
||||
let t = super::pack_shadow::ShadowTargets::new(
|
||||
device,
|
||||
&shadow_config,
|
||||
f,
|
||||
colors,
|
||||
depth1,
|
||||
linear,
|
||||
);
|
||||
notes.push(format!(
|
||||
"shadow map {0}x{0} over {2} blocks, sunPathRotation {3}, shadowcolor buffers {colors:#b}{1}",
|
||||
t.size,
|
||||
@@ -2385,7 +2441,13 @@ impl PackPost {
|
||||
}
|
||||
});
|
||||
let float = wgpu::TextureSampleType::Float { filterable: true };
|
||||
let mut world_target = target(formats[0]);
|
||||
// The world's colour is floats: an integer colortex0 (`RGBA32UI`...) gets only what the
|
||||
// pack writes, and is cleared in a pass of its own.
|
||||
let world_color = holds_floats(formats[0]);
|
||||
if !world_color {
|
||||
other_clears.push(0);
|
||||
}
|
||||
let mut world_target = world_color.then(|| target(formats[0])).flatten();
|
||||
if rgb & 1 != 0
|
||||
&& let Some(t) = &mut world_target
|
||||
{
|
||||
@@ -2402,28 +2464,30 @@ impl PackPost {
|
||||
})
|
||||
}))
|
||||
.collect();
|
||||
let blit = helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
// GL's window depth, written for depthtex0 and the terrain's depth test.
|
||||
blit_depth.then_some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
);
|
||||
let blit = (world_color || blit_depth || !cleared.is_empty()).then(|| {
|
||||
helper(
|
||||
device,
|
||||
"vs_full",
|
||||
if blit_depth {
|
||||
"fs_blit"
|
||||
} else {
|
||||
"fs_blit_color"
|
||||
},
|
||||
&[
|
||||
texture_entry(0, float),
|
||||
texture_entry(1, wgpu::TextureSampleType::Depth),
|
||||
],
|
||||
&blit_targets,
|
||||
// GL's window depth, written for depthtex0 and the terrain's depth test.
|
||||
blit_depth.then_some(wgpu::DepthStencilState {
|
||||
format: wgpu::TextureFormat::Depth32Float,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: Default::default(),
|
||||
bias: Default::default(),
|
||||
}),
|
||||
)
|
||||
});
|
||||
let copy = helper(
|
||||
device,
|
||||
"vs_uv",
|
||||
@@ -2513,6 +2577,7 @@ impl PackPost {
|
||||
center_depth: None,
|
||||
custom,
|
||||
atlas: world.atlas.as_ref().map(|a| a.0.clone()),
|
||||
material_maps: world.material_maps,
|
||||
lightmap: world.lightmap,
|
||||
deferred_pre: mask(&post.deferred_pre),
|
||||
composite_pre: mask(&post.composite_pre),
|
||||
@@ -2724,6 +2789,12 @@ impl PackPost {
|
||||
shadow_color(i, usize::from(key & 1 << (32 + i) != 0))
|
||||
.unwrap_or(&self.dummy)
|
||||
}
|
||||
Source::ShadowDepth(i, false) if s.filtering => {
|
||||
match shadow.and_then(|t| t.linear[i].as_ref()) {
|
||||
Some(v) => v,
|
||||
None => &self.dummy,
|
||||
}
|
||||
}
|
||||
Source::ShadowDepth(i, _) => match shadow {
|
||||
Some(t) => match (i, &t.depth1) {
|
||||
(1, Some(d)) => &d.1,
|
||||
@@ -2792,8 +2863,16 @@ impl PackPost {
|
||||
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
|
||||
&self.dummy_1d
|
||||
}
|
||||
Source::Normals => &self.dummy_normal,
|
||||
Source::Specular => &self.dummy_black,
|
||||
Source::Normals => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
.filter(|_| pass.material_maps)
|
||||
.map_or(&self.dummy_normal, |maps| &maps[0]),
|
||||
Source::Specular => self
|
||||
.material_maps
|
||||
.as_ref()
|
||||
.filter(|_| pass.material_maps)
|
||||
.map_or(&self.dummy_black, |maps| &maps[1]),
|
||||
Source::Dummy => &self.dummy,
|
||||
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
|
||||
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
|
||||
@@ -3104,42 +3183,17 @@ impl PackPost {
|
||||
continue;
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs
|
||||
// that wrap it with their own coordinates sample the portal sprite only.
|
||||
let portal_group = if geometry
|
||||
.draws
|
||||
.iter()
|
||||
.any(|d| d.pack && d.program == program && d.portal)
|
||||
&& let Some(atlas) = pass.atlas.clone().or_else(|| self.atlas.clone())
|
||||
&& self.portal_rect[2] > 0.0
|
||||
{
|
||||
if self
|
||||
.portal_texture
|
||||
.as_ref()
|
||||
.is_none_or(|(rect, _)| *rect != self.portal_rect)
|
||||
{
|
||||
pass.portal_groups.clear();
|
||||
let view = super::pack_weather::copy_sprite(
|
||||
device,
|
||||
encoder,
|
||||
atlas.texture(),
|
||||
self.portal_rect,
|
||||
);
|
||||
self.portal_texture = Some((self.portal_rect, view));
|
||||
}
|
||||
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
|
||||
// `bind` caches groups by a key without the atlas: the portal groups are kept
|
||||
// in a cache of their own, swapped in with the texture.
|
||||
let saved = std::mem::replace(&mut pass.atlas, view);
|
||||
let groups =
|
||||
std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
|
||||
let g = self.bind(device, queue, encoder, pass, size);
|
||||
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
|
||||
pass.atlas = saved;
|
||||
Some(g)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let portal_group = self.portal_group(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
geometry
|
||||
.draws
|
||||
.iter()
|
||||
.any(|d| d.pack && d.program == program && d.portal),
|
||||
);
|
||||
{
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
@@ -3171,6 +3225,47 @@ impl PackPost {
|
||||
self.entities = Some(entities);
|
||||
}
|
||||
|
||||
/// End portal faces bind `end_portal.png` as the atlas, a texture of its own, so packs that
|
||||
/// wrap it with their own coordinates sample the portal sprite only. `None` when no portal
|
||||
/// face draws in this pass.
|
||||
fn portal_group(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
pass: &mut Pass,
|
||||
size: [u32; 2],
|
||||
wanted: bool,
|
||||
) -> Option<wgpu::BindGroup> {
|
||||
let atlas = pass.atlas.clone().or_else(|| self.atlas.clone())?;
|
||||
if !wanted || self.portal_rect[2] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
if self
|
||||
.portal_texture
|
||||
.as_ref()
|
||||
.is_none_or(|(rect, _)| *rect != self.portal_rect)
|
||||
{
|
||||
let view = super::pack_weather::copy_sprite(
|
||||
device,
|
||||
encoder,
|
||||
atlas.texture(),
|
||||
self.portal_rect,
|
||||
);
|
||||
self.portal_texture = Some((self.portal_rect, view));
|
||||
pass.portal_groups.clear();
|
||||
}
|
||||
// `bind` caches groups by a key without the atlas: the portal groups are kept in a cache
|
||||
// of their own, swapped in with the texture.
|
||||
let view = self.portal_texture.as_ref().map(|p| p.1.clone());
|
||||
let saved = std::mem::replace(&mut pass.atlas, view);
|
||||
let groups = std::mem::replace(&mut pass.groups, std::mem::take(&mut pass.portal_groups));
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
pass.portal_groups = std::mem::replace(&mut pass.groups, groups);
|
||||
pass.atlas = saved;
|
||||
Some(group)
|
||||
}
|
||||
|
||||
/// Entities and block entities into the shadow map, each run with the per-draw uniforms
|
||||
/// of its gbuffers draw (2.4): opaque, so before the `shadowtex1` copy.
|
||||
fn draw_shadow_entities(
|
||||
@@ -3195,6 +3290,15 @@ impl PackPost {
|
||||
continue;
|
||||
}
|
||||
let group = self.bind(device, queue, encoder, pass, size);
|
||||
let portal_group = self.portal_group(
|
||||
device,
|
||||
queue,
|
||||
encoder,
|
||||
pass,
|
||||
size,
|
||||
(geometry.draws.iter()).any(|d| d.shadow && d.program == program && d.portal),
|
||||
);
|
||||
let mut on_portal = false;
|
||||
let mut rp = self.begin(encoder, pass);
|
||||
rp.set_pipeline(pass.render());
|
||||
rp.set_bind_group(0, &group, &[]);
|
||||
@@ -3207,6 +3311,12 @@ impl PackPost {
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for d in draws {
|
||||
if let Some(portal) = &portal_group
|
||||
&& d.portal != on_portal
|
||||
{
|
||||
rp.set_bind_group(0, if d.portal { portal } else { &group }, &[]);
|
||||
on_portal = d.portal;
|
||||
}
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.group, &[geometry.offset(d.slot)]);
|
||||
}
|
||||
@@ -3359,7 +3469,7 @@ impl PackPost {
|
||||
size[0] as f32 / size[1].max(1) as f32,
|
||||
frame.depth_far,
|
||||
) * unspin;
|
||||
geometry.prepare(device, queue, frame.hand, projection);
|
||||
geometry.prepare(device, queue, frame.hand, frame.hand_items, projection);
|
||||
if geometry.quads > 0 {
|
||||
let group = self.bind(device, queue, encoder, &mut pass, size);
|
||||
{
|
||||
@@ -3372,12 +3482,15 @@ impl PackPost {
|
||||
rp.set_bind_group(4, g, &[]);
|
||||
}
|
||||
rp.set_bind_group(1, &geometry.group, &[]);
|
||||
if pass.draw {
|
||||
self.draws.bind(&mut rp, "HAND_SOLID");
|
||||
}
|
||||
rp.set_vertex_buffer(0, geometry.vertices());
|
||||
rp.set_index_buffer(geometry.indices.slice(..), wgpu::IndexFormat::Uint32);
|
||||
rp.draw_indexed(0..geometry.quads * 6, 0, 0..1);
|
||||
// The arm and each held item: their own `currentRenderedItemId`.
|
||||
for &(first, quads, offset) in &geometry.runs {
|
||||
if pass.draw {
|
||||
rp.set_bind_group(2, &geometry.slot_group, &[offset]);
|
||||
}
|
||||
rp.draw_indexed(first * 6..(first + quads) * 6, 0, 0..1);
|
||||
}
|
||||
}
|
||||
self.mips_valid &= !pass.written;
|
||||
}
|
||||
@@ -3743,6 +3856,10 @@ impl PackPost {
|
||||
if self.copied & 1 << i != 0 {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC;
|
||||
}
|
||||
// A buffer that isn't cleared starts each frame with B copied back to A.
|
||||
if i != 0 && self.clears[i].is_none() {
|
||||
attachment |= wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST;
|
||||
}
|
||||
let sides = [0, 1]
|
||||
.map(|_| texture_sized(&label, self.formats[i], attachment, levels, sizes[i]));
|
||||
if mipped && let Some((_, layout)) = self.down.get(&self.formats[i]) {
|
||||
@@ -3907,6 +4024,9 @@ impl PackPost {
|
||||
TerrainList::ShadowTranslucent,
|
||||
terrain,
|
||||
);
|
||||
if let Some(t) = &self.shadow_targets {
|
||||
t.copy_linear(encoder);
|
||||
}
|
||||
let mut comp = std::mem::take(&mut self.shadow_comp);
|
||||
for pass in &mut comp {
|
||||
let mut compute = std::mem::take(&mut pass.compute);
|
||||
@@ -4128,6 +4248,20 @@ impl PackPost {
|
||||
&mut self.zeros,
|
||||
);
|
||||
let Some(targets) = &self.targets else { return };
|
||||
// Every frame starts on A. A buffer that isn't cleared and ended the last frame on B has B
|
||||
// copied back to A, and B keeps what was last written into it: a model that fits Iris 1.11.7's
|
||||
// values in probe packs of our own (#9009), not a documented Iris rule. colortex0's A is
|
||||
// overwritten by the world below.
|
||||
for b in (1..BUFFERS).filter(|&b| self.main & 1 << b != 0 && self.clears[b].is_none()) {
|
||||
if let Some([a, b_side]) = &targets.color[b] {
|
||||
encoder.copy_texture_to_texture(
|
||||
b_side.texture().as_image_copy(),
|
||||
a.texture().as_image_copy(),
|
||||
a.texture().size(),
|
||||
);
|
||||
}
|
||||
}
|
||||
self.main = 0;
|
||||
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
|
||||
let view = |m: u16, i: usize, alt: bool| {
|
||||
targets.color[i]
|
||||
@@ -4229,14 +4363,15 @@ impl PackPost {
|
||||
}
|
||||
|
||||
// The world into colortex0 and depthtex0, flipped to GL's rows, and the clears.
|
||||
if self
|
||||
.blit_group
|
||||
.as_ref()
|
||||
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
|
||||
if let Some(blit) = &self.blit
|
||||
&& self
|
||||
.blit_group
|
||||
.as_ref()
|
||||
.is_none_or(|g| g.0[0] != *world || g.0[1] != *world_depth)
|
||||
{
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack world"),
|
||||
layout: &self.blit.1,
|
||||
layout: &blit.1,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
@@ -4250,7 +4385,7 @@ impl PackPost {
|
||||
});
|
||||
self.blit_group = Some(([world.clone(), world_depth.clone()], group));
|
||||
}
|
||||
{
|
||||
if let Some(blit) = &self.blit {
|
||||
// The blit leaves a three-channel colortex0's alpha at the 1 it starts with.
|
||||
let world_ops = if self.rgb & 1 != 0 {
|
||||
wgpu::Operations {
|
||||
@@ -4260,10 +4395,11 @@ impl PackPost {
|
||||
} else {
|
||||
overwrite
|
||||
};
|
||||
let mut attachments = vec![attach(
|
||||
view(self.main, 0, false).expect("colortex0"),
|
||||
world_ops,
|
||||
)];
|
||||
let mut attachments = vec![if holds_floats(self.formats[0]) {
|
||||
attach(view(self.main, 0, false).expect("colortex0"), world_ops)
|
||||
} else {
|
||||
None
|
||||
}];
|
||||
for &b in &self.blit_clears {
|
||||
attachments.push(attach(
|
||||
view(self.main, b, false).expect("used buffer"),
|
||||
@@ -4286,7 +4422,7 @@ impl PackPost {
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&self.blit.0);
|
||||
pass.set_pipeline(&blit.0);
|
||||
pass.set_bind_group(0, self.blit_group.as_ref().map(|g| &g.1), &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
@@ -4435,7 +4571,14 @@ impl PackPost {
|
||||
.as_ref()
|
||||
.map(|t| &t[main(m, i) ^ usize::from(alt)])
|
||||
};
|
||||
if !shown {
|
||||
// An integer colortex0 has no colour to show: the screen is only cleared.
|
||||
if !shown && !holds_floats(self.formats[0]) {
|
||||
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
color_attachments: &[attach(screen, overwrite)],
|
||||
..Default::default()
|
||||
});
|
||||
} else if !shown {
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Shader pack colortex0"),
|
||||
layout: &self.copy.1,
|
||||
@@ -4587,6 +4730,7 @@ mod tests {
|
||||
}];
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
initial_values: vec![0; 48],
|
||||
uniforms: Vec::new(),
|
||||
samplers: vec![horizon_shaders::Sampler {
|
||||
name: "colortex0".into(),
|
||||
@@ -4616,6 +4760,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let reads = |group: Group| {
|
||||
let (_, samplers, ..) = bindings(
|
||||
@@ -4665,6 +4810,7 @@ mod tests {
|
||||
};
|
||||
let interface = horizon_shaders::Interface {
|
||||
uniform_size: 48,
|
||||
initial_values: vec![0; 48],
|
||||
uniforms: Vec::new(),
|
||||
samplers: Vec::new(),
|
||||
outputs: vec![0],
|
||||
@@ -4694,6 +4840,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let mut notes = Vec::new();
|
||||
let (.., images) = bindings(
|
||||
@@ -4834,6 +4981,7 @@ mod tests {
|
||||
rgb: 0,
|
||||
released: std::cell::Cell::new(("", 0)),
|
||||
stand_ins: &stand_ins,
|
||||
shadow_linear: 0,
|
||||
};
|
||||
let draws = DrawUniforms::new(&device);
|
||||
let mut notes = Vec::new();
|
||||
@@ -4996,5 +5144,18 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
mod destination_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "pack_material_tests.rs"]
|
||||
mod material_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rgb_alpha_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod trinary_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod const_offset_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod integer_colortex0_tests;
|
||||
@@ -0,0 +1,111 @@
|
||||
//! A buffer that isn't cleared, in passes that leave pixels alone or blend: what is under them.
|
||||
//! The expected values are Iris 1.11.7's, measured with probe packs of our own (#9009).
|
||||
use super::*;
|
||||
|
||||
/// colortex6 is written three times a frame, the first write leaving the right half alone:
|
||||
/// there it reads the previous frame's last write, 0.125 (Iris 1.11.7), not the 0.25 written into
|
||||
/// that texture before. colortex8 is written twice a frame the same way: its right half was never
|
||||
/// written and reads 0 (Iris 1.11.7). colortex10 is only blended onto (`ONE ONE`), 0.125 a frame:
|
||||
/// it grows by 0.125 every frame (Iris 1.11.7 adds once per frame).
|
||||
#[test]
|
||||
fn a_buffer_that_is_not_cleared_starts_the_frame_with_its_last_write() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
let fill = |v: f32| format!("vec4({v},{v},{v},1)");
|
||||
let programs = [
|
||||
(
|
||||
"composite",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:6 */\n\
|
||||
/*\nconst int colortex6Format=RGBA16F;\nconst int colortex7Format=RGBA16F;\n\
|
||||
const int colortex8Format=RGBA16F;\nconst int colortex9Format=RGBA16F;\n\
|
||||
const int colortex10Format=RGBA16F;\nconst bool colortex6Clear=false;\n\
|
||||
const bool colortex8Clear=false;\nconst bool colortex10Clear=false;\n*/\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex6;\nvarying vec2 uv;\n/* RENDERTARGETS:7,6 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex6,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.25)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
format!(
|
||||
"#version 120\n/* RENDERTARGETS:6 */\nvoid main() {{ gl_FragData[0]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite3",
|
||||
format!(
|
||||
"#version 120\nvarying vec2 uv;\n/* RENDERTARGETS:8 */\n\
|
||||
void main() {{ if(uv.x>0.5) discard; gl_FragData[0]={}; }}\n",
|
||||
fill(0.5)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite4",
|
||||
format!(
|
||||
"#version 120\nuniform sampler2D colortex8;\nvarying vec2 uv;\n/* RENDERTARGETS:9,8 */\n\
|
||||
void main() {{ gl_FragData[0]=texture2D(colortex8,uv); gl_FragData[1]={}; }}\n",
|
||||
fill(0.125)
|
||||
),
|
||||
),
|
||||
(
|
||||
"composite5",
|
||||
"#version 120\n/* RENDERTARGETS:10 */\nvoid main() { gl_FragData[0]=vec4(0.125,0.125,0.125,0); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex7;\nuniform sampler2D colortex9;\n\
|
||||
uniform sampler2D colortex10;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex7,uv).r,texture2D(colortex9,uv).r,texture2D(colortex10,uv).r,1); }\n"
|
||||
.to_owned(),
|
||||
),
|
||||
];
|
||||
for (program, fragment) in &programs {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add("shaders.properties", "blend.composite5=ONE ONE ONE ONE\n");
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"clear-false-history",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..4u8 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let sum = 32 * (frame + 1);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(n, p)| {
|
||||
let expected = match (n % 16 < 8, frame) {
|
||||
(true, _) => [128, 128, sum, 255],
|
||||
(false, 0) => [0, 0, sum, 255],
|
||||
(false, _) => [32, 0, sum, 255],
|
||||
};
|
||||
p.iter().zip(expected).any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A texel offset given by a `const` local reads the texel that far away, as a literal offset
|
||||
//! does (GLSL 4.60, 4.3.3 and 8.9.2, as #8462 quotes them). #8462's probe.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// Each texel of colortex1 holds its own column and row, in eighths.
|
||||
add(
|
||||
"composite.fsh",
|
||||
"#version 120\n/* RENDERTARGETS: 1 */\n\
|
||||
void main() { gl_FragData[0] = vec4(floor(gl_FragCoord.xy) / 8.0, 0.0, 1.0); }\n",
|
||||
);
|
||||
add("final.vsh", VERTEX);
|
||||
// Red and green: what the texel two columns right and one row down holds; blue: this row.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform sampler2D colortex1;\nin vec2 uv;\n\
|
||||
vec4 f(vec2 p) { const ivec2 offset = ivec2(2, -1); return textureLodOffset(colortex1, p, 0.0, offset); }\n\
|
||||
void main() { gl_FragColor = vec4(f(uv).rg, floor(gl_FragCoord.y) / 8.0, 1.0); }\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-const-offset",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 2, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_const_local_offset_reads_the_offset_texel() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let eighth = |n: u8| (f32::from(n) * 255.0 / 8.0).round() as u8;
|
||||
let mut checked = 0;
|
||||
for (n, p) in pixels.iter().enumerate() {
|
||||
let x = (n % 8) as u8;
|
||||
// The bottom row (GL's row 0) and the two right columns read past the edge.
|
||||
if x >= 6 || p[2] == 0 {
|
||||
continue;
|
||||
}
|
||||
let row = (0..8)
|
||||
.find(|&r| eighth(r).abs_diff(p[2]) <= 1)
|
||||
.expect("row in blue");
|
||||
let want = [eighth(x + 2), eighth(row - 1)];
|
||||
assert!(
|
||||
p[0].abs_diff(want[0]) <= 1 && p[1].abs_diff(want[1]) <= 1,
|
||||
"pixel {n} (column {x}, GL row {row}): {p:?}, expected red and green {want:?}"
|
||||
);
|
||||
checked += 1;
|
||||
}
|
||||
assert_eq!(checked, 6 * 7);
|
||||
}
|
||||
@@ -315,19 +315,21 @@ fn check(sampler: &str, stage: Stage, scene: Scene, expected: [u8; 4]) {
|
||||
let hand = matches!(scene, Scene::Terrain { hand: true, .. });
|
||||
let water = matches!(scene, Scene::Terrain { water: true, .. } | Scene::WaterOnly);
|
||||
let inputs=horizon_shaders::GameInputs {
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None }),
|
||||
terrain:terrain.then(|| horizon_shaders::GbuffersInputs { declarations:"layout(location=0)in vec3 fixture_position;\n".into(),body:"hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n".into(),attributes:vec![],after:String::new(),alpha_test:None,item_id_declared:false }),
|
||||
hand:hand.then(crate::gfx::pack_hand::inputs),..Default::default()
|
||||
};
|
||||
let mut post = gpu.load(
|
||||
shaders(sampler, stage, scene),
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: terrain.then_some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
material_maps: false,
|
||||
ids: None,
|
||||
cull: false,
|
||||
}),
|
||||
@@ -617,6 +619,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -635,3 +638,143 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
post_effects: crate::post_chain::Request::none(),
|
||||
}
|
||||
}
|
||||
|
||||
/// shadowtex0 and shadowtex1 read through a plain sampler a quarter texel past a texel's centre,
|
||||
/// over a shadow map whose depth rises a sixteenth a texel: red and green are how far each
|
||||
/// moved toward the next texel, times four (0 nearest, 255 linear). `nearest` is a line for
|
||||
/// both stage files.
|
||||
fn shadow_depth_filtering(nearest: &str) -> Option<Vec<[u8; 4]>> {
|
||||
let gpu = Gpu::new()?;
|
||||
if !gpu
|
||||
.device
|
||||
.features()
|
||||
.contains(wgpu::Features::FLOAT32_FILTERABLE)
|
||||
{
|
||||
eprintln!("DEPTH_FIXTURE_NO_FLOAT32_FILTERABLE");
|
||||
return None;
|
||||
}
|
||||
let color = gpu.texture(T::Rgba8Unorm);
|
||||
let depth = gpu.texture(T::Depth32Float);
|
||||
gpu.clear(&color, &depth, 0.0);
|
||||
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
|
||||
let layouts = [Some(wgpu::VertexBufferLayout {
|
||||
array_stride: 12,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &ATTRS,
|
||||
})];
|
||||
let inputs = horizon_shaders::GameInputs {
|
||||
terrain: Some(horizon_shaders::GbuffersInputs {
|
||||
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
|
||||
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
|
||||
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
|
||||
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
|
||||
.into(),
|
||||
attributes: vec![],
|
||||
after: String::new(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let settings = format!("const int shadowMapResolution = 16;\n{nearest}\n");
|
||||
let files = vec![
|
||||
("final.vsh".to_string(), VERTEX.to_string()),
|
||||
(
|
||||
"final.fsh".into(),
|
||||
format!(
|
||||
"#version 330 compatibility\n{settings}\
|
||||
uniform sampler2D shadowtex0;\nuniform sampler2D shadowtex1;\n\
|
||||
float moved(sampler2D s, ivec2 t) {{\n\
|
||||
float a = texelFetch(s, t, 0).r;\n\
|
||||
return (texture(s, (vec2(t) + vec2(0.75, 0.5)) / 16.0).r - a) * 16.0;\n\
|
||||
}}\n\
|
||||
void main() {{\n\
|
||||
ivec2 t = ivec2(int(gl_FragCoord.x) % 15, 8);\n\
|
||||
gl_FragColor = vec4(moved(shadowtex0, t) * 4.0, moved(shadowtex1, t) * 4.0, 0.0, 1.0);\n\
|
||||
}}\n"
|
||||
),
|
||||
),
|
||||
// The shadow map's depth runs from 0 at its left edge to 1 at its right.
|
||||
(
|
||||
"shadow.vsh".into(),
|
||||
"#version 120\nvoid main(){gl_Position=vec4(gl_Vertex.xy,gl_Vertex.x,1.0);}".into(),
|
||||
),
|
||||
(
|
||||
"shadow.fsh".into(),
|
||||
format!("#version 120\n{settings}void main(){{gl_FragData[0]=vec4(1.0);}}"),
|
||||
),
|
||||
];
|
||||
let mut post = gpu.load(
|
||||
files,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: color.clone(),
|
||||
terrain: Some(Geometry {
|
||||
buffers: &layouts,
|
||||
table: None,
|
||||
depth_write: true,
|
||||
atlas: None,
|
||||
ids: None,
|
||||
cull: false,
|
||||
material_maps: false,
|
||||
}),
|
||||
block_ids: &[],
|
||||
celestial: None,
|
||||
gui_size: SIZE,
|
||||
clouds: None,
|
||||
weather: false,
|
||||
hand: false,
|
||||
},
|
||||
inputs,
|
||||
);
|
||||
assert!(post.has_shadow(), "{:?}", post.notes);
|
||||
let positions: [[f32; 3]; 6] = [
|
||||
[-1., -1., 0.],
|
||||
[1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., -1., 0.],
|
||||
[1., 1., 0.],
|
||||
[-1., 1., 0.],
|
||||
];
|
||||
let vertices = gpu
|
||||
.device
|
||||
.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Original shadow depth ramp"),
|
||||
contents: bytemuck::cast_slice(&positions),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let hud = HudBatch::new(SIZE);
|
||||
let frame = fixture_frame(&hud);
|
||||
Some(
|
||||
gpu.render(&mut post, &color, &depth, &frame, &mut |rp, list, _, _| {
|
||||
if list == TerrainList::ShadowSolid {
|
||||
rp.set_vertex_buffer(0, vertices.slice(..));
|
||||
rp.draw(0..6, 0..1);
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_depth_is_read_linearly_through_a_plain_sampler() {
|
||||
// Iris 1.11.7 filters both (probe packs of our own; the PR has the readings).
|
||||
if let Some(pixels) = shadow_depth_filtering("") {
|
||||
all_pixels(&pixels, [255, 255, 0, 255], "shadowtex linear");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowtex_nearest_settings_read_the_nearest_texel() {
|
||||
for (line, expected) in [
|
||||
("const bool shadowtex0Nearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtexNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadow0MinMagNearest = true;", [0, 255, 0, 255]),
|
||||
("const bool shadowtex1Nearest = true;", [255, 0, 0, 255]),
|
||||
("const bool shadow1MinMagNearest = true;", [255, 0, 0, 255]),
|
||||
] {
|
||||
if let Some(pixels) = shadow_depth_filtering(line) {
|
||||
all_pixels(&pixels, expected, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -208,6 +208,7 @@ impl Fixture {
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap,
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
@@ -346,8 +347,8 @@ fn frame_clear_and_retained_history_apply_before_partial_post_writes() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for clear in [true, false] {
|
||||
let mut post = gpu.build(pack(Coverage::Full, false, false, Some(clear), false));
|
||||
// Every frame changes which physical image is main. The other image is fully
|
||||
// seeded red before the partial write, independent of its initial clearing.
|
||||
// The other image is fully seeded red before the partial write, independent of its
|
||||
// initial clearing.
|
||||
for frame in 0..3 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
assert!(
|
||||
@@ -433,6 +434,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -521,3 +523,6 @@ fn a_cleared_buffer_starts_every_frame_cleared_on_both_textures() {
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "clear_false_tests.rs"]
|
||||
mod clear_false_tests;
|
||||
@@ -0,0 +1,73 @@
|
||||
//! `flip.<program>.<buffer> = true` on a buffer the program writes (#9010). The expected values
|
||||
//! are Iris 1.11.7's, measured with probe packs of our own.
|
||||
use super::destination_tests::Fixture;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `composite` writes 0.25 to colortex2 and colortex4 and forces both to flip: the forced flip
|
||||
/// cancels the write's own, so `composite1` samples colortex2's cleared side (Iris 1.11.7: 0),
|
||||
/// and `composite2`, blended `ONE ONE`, adds 0.5 onto colortex4's 0.25 (Iris 1.11.7: 0.749).
|
||||
#[test]
|
||||
fn a_forced_flip_of_a_written_buffer_leaves_it_unflipped() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
for (program, fragment) in [
|
||||
(
|
||||
"composite",
|
||||
"#version 120\n/* RENDERTARGETS:2,4 */\n\
|
||||
void main() { gl_FragData[0]=vec4(0.25,0.25,0.25,1); gl_FragData[1]=vec4(0.25,0.25,0.25,1); }\n",
|
||||
),
|
||||
(
|
||||
"composite1",
|
||||
"#version 120\nuniform sampler2D colortex2;\nvarying vec2 uv;\n/* RENDERTARGETS:3 */\n\
|
||||
void main() { gl_FragData[0]=texture2D(colortex2,uv); }\n",
|
||||
),
|
||||
(
|
||||
"composite2",
|
||||
"#version 120\n/* RENDERTARGETS:4 */\nvoid main() { gl_FragData[0]=vec4(0.5,0.5,0.5,0); }\n",
|
||||
),
|
||||
(
|
||||
"final",
|
||||
"#version 120\nuniform sampler2D colortex3;\nuniform sampler2D colortex4;\nvarying vec2 uv;\n\
|
||||
void main() { gl_FragColor=vec4(texture2D(colortex4,uv).r,texture2D(colortex3,uv).r,0,1); }\n",
|
||||
),
|
||||
] {
|
||||
add(&format!("{program}.vsh"), VERTEX);
|
||||
add(&format!("{program}.fsh"), fragment);
|
||||
}
|
||||
add(
|
||||
"shaders.properties",
|
||||
"flip.composite.colortex2=true\nflip.composite.colortex4=true\nblend.composite2=ONE ONE ONE ONE\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"forced-flip-of-a-written-buffer",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
let mut post = gpu.build(post);
|
||||
for frame in 0..2 {
|
||||
let actual = gpu.render(&mut post, [16, 16]);
|
||||
let wrong: Vec<_> = (actual.iter().enumerate())
|
||||
.filter(|(_, p)| {
|
||||
p.iter()
|
||||
.zip([191, 0, 0, 255])
|
||||
.any(|(a, b)| a.abs_diff(b) > 1)
|
||||
})
|
||||
.map(|(n, p)| (n, *p))
|
||||
.take(4)
|
||||
.collect();
|
||||
assert!(wrong.is_empty(), "frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! A pack may give colortex0 an integer format (OptiFine's `shaders.txt`, "Texture Formats",
|
||||
//! lists `RGBA32UI`, as #8459 quotes it): the pack still loads, and reads back what it wrote
|
||||
//! there. #8459's probe; #8459 reports Iris 1.11.7 drawing (0, 200, 0) at every point
|
||||
//! sampled from it.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
/// `depth`: the composite reads depthtex0, so the blit copies the world's depth; `last`: the
|
||||
/// pack has a `final` that shows colortex0 (without, Horizon shows colortex0 itself).
|
||||
fn pack(depth: bool, last: bool) -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("composite.vsh", VERTEX);
|
||||
// With depthtex0: green where it holds the empty world's far plane (1), red elsewhere.
|
||||
let value = if depth {
|
||||
"texelFetch(depthtex0, ivec2(gl_FragCoord.xy), 0).r == 1.0 ? uvec4(0u, 200u, 0u, 255u) : uvec4(200u, 0u, 0u, 255u)"
|
||||
} else {
|
||||
"uvec4(0u, 200u, 0u, 255u)"
|
||||
};
|
||||
add(
|
||||
"composite.fsh",
|
||||
&format!(
|
||||
"#version 330 compatibility\n/* RENDERTARGETS: 0 */\n/*\nconst int colortex0Format = RGBA32UI;\n*/\n\
|
||||
uniform sampler2D depthtex0;\nlayout(location = 0) out uvec4 o;\nvoid main() {{ o = {value}; }}\n"
|
||||
),
|
||||
);
|
||||
if last {
|
||||
add("final.vsh", VERTEX);
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\nuniform usampler2D colortex0;\n\
|
||||
void main() { gl_FragColor = vec4(texelFetch(colortex0, ivec2(gl_FragCoord.xy), 0)) / 255.0; }\n",
|
||||
);
|
||||
}
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-integer-colortex0",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(
|
||||
post.programs.len(),
|
||||
1 + usize::from(last),
|
||||
"{:?}",
|
||||
post.notes
|
||||
);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_integer_colortex0_keeps_what_the_pack_wrote() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
for depth in [false, true] {
|
||||
let mut post = gpu.build(pack(depth, true));
|
||||
// Twice: the second frame's world blit runs over a colortex0 the first frame wrote.
|
||||
for frame in 0..2 {
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
let wrong: Vec<_> = pixels.iter().filter(|p| **p != [0, 200, 0, 255]).collect();
|
||||
assert!(wrong.is_empty(), "depth {depth}, frame {frame}: {wrong:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Without a `final`, there are no colours to show from an integer colortex0; what Iris shows
|
||||
/// then was not measured. The frame must still run.
|
||||
#[test]
|
||||
fn an_integer_colortex0_without_final_still_runs() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack(false, false));
|
||||
for _ in 0..2 {
|
||||
gpu.render(&mut post, [8, 8]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! `min3`, `max3` and `mid3` of GL_AMD_shader_trinary_minmax: the per-component minimum, maximum
|
||||
//! and median of three values, for float, int and uint vectors (the extension's additions to
|
||||
//! GLSL 4.30 Section 8.3). An original probe; the expected values are worked out by hand.
|
||||
use super::destination_tests::Fixture;
|
||||
use super::*;
|
||||
use std::io::{Cursor, Write};
|
||||
|
||||
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
|
||||
|
||||
fn pack() -> Post {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut add = |name: &str, source: &str| {
|
||||
zip.start_file(
|
||||
format!("shaders/{name}"),
|
||||
zip::write::SimpleFileOptions::default(),
|
||||
)
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
};
|
||||
add("final.vsh", VERTEX);
|
||||
// Pixel column x shows case x % 8 in red, green and blue.
|
||||
add(
|
||||
"final.fsh",
|
||||
"#version 330 compatibility\n#extension GL_AMD_shader_trinary_minmax : require\n\
|
||||
void main() {\n int x = int(gl_FragCoord.x) % 8;\n\
|
||||
vec3 a = vec3(0.1, 0.9, 0.4), b = vec3(0.6, 0.2, 0.8), c = vec3(0.3, 0.5, 0.0);\n\
|
||||
ivec3 i = ivec3(-3, 7, 2), j = ivec3(5, -1, 9), k = ivec3(0, 4, -6);\n\
|
||||
uvec3 u = uvec3(1u, 8u, 3u), v = uvec3(6u, 2u, 7u), w = uvec3(4u, 5u, 0u);\n\
|
||||
vec3 o = vec3(0.0);\n\
|
||||
switch (x) {\n\
|
||||
case 0: o = min3(a, b, c); break;\n\
|
||||
case 1: o = max3(a, b, c); break;\n\
|
||||
case 2: o = mid3(a, b, c); break;\n\
|
||||
case 3: o = vec3(mid3(i, j, k)) / 10.0; break;\n\
|
||||
case 4: o = vec3(max3(u, v, w)) / 10.0; break;\n\
|
||||
case 5: o = vec3(min3(i, j, k) + 6) / 10.0; break;\n\
|
||||
case 6: o = vec3(mid3(u.x, v.x, w.x), mid3(u.y, v.y, w.y), mid3(u.z, v.z, w.z)) / 10.0; break;\n\
|
||||
case 7: o = vec3(max3(a.x, b.x, c.x), min3(a.yz, b.yz, c.yz)); break;\n\
|
||||
}\n gl_FragColor = vec4(o, 1.0);\n}\n",
|
||||
);
|
||||
let pack = horizon_shaders::Pack::from_zip_bytes(
|
||||
"original-trinary",
|
||||
zip.finish().unwrap().into_inner(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut macros = horizon_shaders::Macros::standard();
|
||||
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
|
||||
let post = horizon_shaders::load_post(&pack, "", &properties, ¯os, &Default::default());
|
||||
assert_eq!(post.programs.len(), 1, "{:?}", post.notes);
|
||||
post
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trinary_min_max_and_median_per_component() {
|
||||
let Some(gpu) = Fixture::new() else { return };
|
||||
let mut post = gpu.build(pack());
|
||||
let pixels = gpu.render(&mut post, [8, 8]);
|
||||
// Tenths, by hand: a = (1, 9, 4), b = (6, 2, 8), c = (3, 5, 0); i = (-3, 7, 2),
|
||||
// j = (5, -1, 9), k = (0, 4, -6); u = (1, 8, 3), v = (6, 2, 7), w = (4, 5, 0).
|
||||
let tenths: [[u8; 3]; 8] = [
|
||||
[1, 2, 0],
|
||||
[6, 9, 8],
|
||||
[3, 5, 4],
|
||||
[0, 4, 2],
|
||||
[6, 8, 7],
|
||||
[3, 5, 0],
|
||||
[4, 5, 3],
|
||||
[6, 2, 0],
|
||||
];
|
||||
let row: Vec<[u8; 3]> = pixels[..8].iter().map(|p| [p[0], p[1], p[2]]).collect();
|
||||
for (case, (got, want)) in row.iter().zip(tenths).enumerate() {
|
||||
let want = want.map(|t| (f32::from(t) * 25.5).round() as u8);
|
||||
assert!(
|
||||
got.iter().zip(want).all(|(g, w)| g.abs_diff(w) <= 1),
|
||||
"case {case}: {got:?}, expected {want:?}; row {row:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -215,6 +215,7 @@ fn pack(
|
||||
T::Rgba8Unorm,
|
||||
World {
|
||||
atlas: None,
|
||||
material_maps: None,
|
||||
lightmap: lightmap.clone(),
|
||||
terrain: None,
|
||||
block_ids: &[],
|
||||
@@ -351,6 +352,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -35,6 +35,13 @@ pub(super) struct ShadowTargets {
|
||||
pub depth: (wgpu::Texture, wgpu::TextureView),
|
||||
/// `shadowtex1`: a copy of `shadowtex0` taken before translucent terrain, when read.
|
||||
pub depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
|
||||
/// `shadowtex0`/`1` as a plain sampler filters them: Iris 1.11.7 reads them linearly unless
|
||||
/// a pack asks for the nearest texel (measured). wgpu filters a depth format only for a
|
||||
/// comparison, so these are `R32Float` copies of the final depths, when read that way.
|
||||
pub linear: [Option<wgpu::TextureView>; 2],
|
||||
/// The rows a depth copy goes through to `linear` (a depth format copies only to a buffer),
|
||||
/// and their stride in bytes.
|
||||
staging: Option<(wgpu::Buffer, u32)>,
|
||||
/// Both sides of each shadowcolor buffer used.
|
||||
pub color: [Option<[wgpu::TextureView; 2]>; SHADOW_COLORS],
|
||||
/// The clear colour of each buffer cleared every frame.
|
||||
@@ -77,13 +84,15 @@ fn target(
|
||||
|
||||
impl ShadowTargets {
|
||||
/// The buffers of `shadow` the pack uses: `colors` (bit n: shadowcolor n), `depth1` when
|
||||
/// some program reads `shadowtex1`. The resolution is kept within the device's limit.
|
||||
/// some program reads `shadowtex1`, `linear` (bit n: shadowtex n read through a filtering
|
||||
/// sampler). The resolution is kept within the device's limit.
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
shadow: &horizon_shaders::Shadow,
|
||||
formats: [wgpu::TextureFormat; SHADOW_COLORS],
|
||||
colors: u16,
|
||||
depth1: bool,
|
||||
linear: u8,
|
||||
) -> Self {
|
||||
let size = shadow
|
||||
.resolution
|
||||
@@ -105,10 +114,43 @@ impl ShadowTargets {
|
||||
})
|
||||
})
|
||||
});
|
||||
let linear = [0, 1].map(|n| {
|
||||
(linear & 1 << n != 0).then(|| {
|
||||
let label = format!("Shader pack shadowtex{n} (filtered)");
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some(&label),
|
||||
size: wgpu::Extent3d {
|
||||
width: size,
|
||||
height: size,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::R32Float,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
})
|
||||
});
|
||||
let staging = linear.iter().any(Option::is_some).then(|| {
|
||||
let stride = (size * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
|
||||
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Shader pack shadow depth rows"),
|
||||
size: u64::from(stride) * u64::from(size),
|
||||
usage: wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
(buffer, stride)
|
||||
});
|
||||
Self {
|
||||
size,
|
||||
depth: target(device, "Shader pack shadowtex0", size, depth, copy),
|
||||
depth1: depth1.then(|| target(device, "Shader pack shadowtex1", size, depth, copy)),
|
||||
linear,
|
||||
staging,
|
||||
color,
|
||||
clears: std::array::from_fn(|n| {
|
||||
let b = &shadow.colors[n];
|
||||
@@ -214,4 +256,38 @@ impl ShadowTargets {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `linear`: the filtered copies of the depths the shadow pass left (`shadowtex1` from its
|
||||
/// copy before translucent geometry).
|
||||
pub fn copy_linear(&self, encoder: &mut wgpu::CommandEncoder) {
|
||||
let Some((buffer, stride)) = &self.staging else {
|
||||
return;
|
||||
};
|
||||
for (n, linear) in self.linear.iter().enumerate() {
|
||||
let Some(linear) = linear else {
|
||||
continue;
|
||||
};
|
||||
let depth = match (n, &self.depth1) {
|
||||
(1, Some(d)) => &d.0,
|
||||
_ => &self.depth.0,
|
||||
};
|
||||
let rows = wgpu::TexelCopyBufferInfo {
|
||||
buffer,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(*stride),
|
||||
rows_per_image: None,
|
||||
},
|
||||
};
|
||||
encoder.copy_texture_to_buffer(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
aspect: wgpu::TextureAspect::DepthOnly,
|
||||
..depth.as_image_copy()
|
||||
},
|
||||
rows,
|
||||
depth.size(),
|
||||
);
|
||||
encoder.copy_buffer_to_texture(rows, linear.texture().as_image_copy(), depth.size());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
// At the far plane: the sky shows only where nothing else drew, and it writes no depth.
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ pub fn celestial_inputs() -> horizon_shaders::GbuffersInputs {
|
||||
],
|
||||
after: " gl_Position.z = gl_Position.w;\n".into(),
|
||||
alpha_test: None,
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1388,6 +1388,7 @@ mod initial_history_tests {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
|
||||
op: ">",
|
||||
reference: 0.1,
|
||||
}),
|
||||
item_id_declared: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ impl Readback {
|
||||
let Some(texture) = self.texture.clone() else {
|
||||
return;
|
||||
};
|
||||
self.draw_onto(device, encoder, &texture, surface);
|
||||
}
|
||||
|
||||
/// Draws `frame` onto the swapchain image (or offscreen target) `surface`.
|
||||
pub fn draw_onto(
|
||||
&mut self,
|
||||
device: &wgpu::Device,
|
||||
encoder: &mut wgpu::CommandEncoder,
|
||||
frame: &wgpu::Texture,
|
||||
surface: &wgpu::Texture,
|
||||
) {
|
||||
// The frame's passes write the swapchain through its non-sRGB view
|
||||
// (`view_formats`), so the copy goes there unconverted too.
|
||||
let format = surface.format().remove_srgb_suffix();
|
||||
@@ -74,7 +85,7 @@ impl Readback {
|
||||
format: Some(format),
|
||||
..Default::default()
|
||||
});
|
||||
self.blit(device, encoder, &texture, &target, format);
|
||||
self.blit(device, encoder, frame, &target, format);
|
||||
}
|
||||
|
||||
/// `frame` (a 16-bit frame) in an 8-bit texture of `format`, clamped to 0..1
|
||||
|
||||
@@ -92,7 +92,7 @@ impl<T> Resident<T> {
|
||||
self.items.iter_mut().map(|(_, v)| v)
|
||||
}
|
||||
/// Every section's key, value and bounds.
|
||||
#[cfg(debug_assertions)]
|
||||
#[cfg(any(test, debug_assertions))]
|
||||
pub fn iter(&self) -> impl Iterator<Item = (SectionKey, &T, Option<[Vec3; 2]>)> {
|
||||
let items = self.items.iter().zip(&self.bounds);
|
||||
items.map(|((key, value), bounds)| (*key, value, *bounds))
|
||||
@@ -194,6 +194,12 @@ impl<T> Resident<T> {
|
||||
column.low = column.low.min(key.1);
|
||||
column.high = column.high.max(key.1);
|
||||
}
|
||||
/// Marks or unmarks the section at `key`, if it is resident.
|
||||
pub fn set_marked(&mut self, key: SectionKey, marked: bool) {
|
||||
if let Some(&slot) = self.slots.get(&key) {
|
||||
self.mark_slot(slot, marked);
|
||||
}
|
||||
}
|
||||
/// Marks or unmarks the section in `slot`, in its column's list too.
|
||||
fn mark_slot(&mut self, slot: u32, marked: bool) {
|
||||
let was = std::mem::replace(&mut self.marked[slot as usize], marked);
|
||||
@@ -657,7 +663,8 @@ mod tests {
|
||||
(!value.is_multiple_of(3)).then(|| [Vec3::splat((value % 5) as f32), Vec3::splat(16.0)])
|
||||
};
|
||||
// A deterministic mix of inserts, replacements and removals, marked at
|
||||
// random: a replacement can mark or unmark its section.
|
||||
// random: a replacement can mark or unmark its section, and so can a
|
||||
// mark set alone (`set_marked`), which leaves a key not resident alone.
|
||||
let mut seed = 0x2545_f491_u32;
|
||||
for step in 0..4000u32 {
|
||||
seed ^= seed << 13;
|
||||
@@ -694,6 +701,12 @@ mod tests {
|
||||
assert_eq!(removed, expected);
|
||||
} else if (seed >> 12).is_multiple_of(3) {
|
||||
assert_eq!(r.remove(key), model.remove(&key).map(|(value, _)| value));
|
||||
} else if (seed >> 12).is_multiple_of(5) {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.set_marked(key, marked);
|
||||
if let Some(held) = model.get_mut(&key) {
|
||||
held.1 = marked;
|
||||
}
|
||||
} else {
|
||||
let marked = (seed >> 20).is_multiple_of(2);
|
||||
r.insert_marked(key, step, bounds(step), marked);
|
||||
|
||||
@@ -1050,7 +1050,9 @@ impl Item {
|
||||
Item::Horizon(H::Hdr) => g.hdr,
|
||||
Item::Horizon(H::BatteryMode) => g.battery,
|
||||
Item::Horizon(H::ShadowCache) => g.shadow_cache,
|
||||
Item::Horizon(H::TranslucentBatching) => g.translucent_batching,
|
||||
Item::Horizon(H::TranslucentBatching) => {
|
||||
g.translucent_batching && crate::graphics::translucent_batching_offered()
|
||||
}
|
||||
Item::Horizon(H::HdrSmoothGradients) => {
|
||||
g.hdr_smooth_gradients && crate::graphics::smooth_gradients_offered()
|
||||
}
|
||||
@@ -3164,13 +3166,13 @@ mod tests {
|
||||
assert_eq!(page, Some(Page::Advanced));
|
||||
let mut s = Settings::default();
|
||||
s.horizon.preset = Preset::Balanced;
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
let mut ui = GraphicsUi::default();
|
||||
// Where the device can't, the saved On reads Off, and neither a click nor Enter
|
||||
// after it does anything: no sound, no change, no save.
|
||||
crate::graphics::offer_translucent_batching(false);
|
||||
assert!(item.blocked(&s).is_some());
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: Off");
|
||||
assert_eq!(item.switch(&s), Some(false));
|
||||
let r = row(&mut ui, &s, item);
|
||||
let point = (r[0] + 4.0, r[1] + 4.0);
|
||||
assert!(press(&mut ui, &mut s, point).is_empty());
|
||||
@@ -3181,6 +3183,7 @@ mod tests {
|
||||
// Where it can, a click switches it and keeps the preset.
|
||||
crate::graphics::offer_translucent_batching(true);
|
||||
assert_eq!(item.label(&s).plain(), "Translucent Batching: On");
|
||||
assert_eq!(item.switch(&s), Some(true));
|
||||
assert_eq!(
|
||||
press(&mut ui, &mut s, point),
|
||||
[Action::ButtonClick, Action::Changed]
|
||||
|
||||
@@ -796,10 +796,10 @@ impl HudAtlas {
|
||||
let g = self.font.glyph(ch);
|
||||
g.rect.map(|rect| (rect, g.advance))
|
||||
}
|
||||
/// A font glyph's atlas cell (none for blank glyphs such as the space) and advance.
|
||||
pub fn glyph_cell(&self, ch: char) -> (Option<Rect>, f32) {
|
||||
let g = self.font.glyph(ch);
|
||||
(g.rect, g.advance)
|
||||
/// A font glyph in the default font: its atlas cell (none for blank
|
||||
/// glyphs such as the space), its box and advance.
|
||||
pub fn glyph_cell(&self, ch: char) -> crate::font::Glyph {
|
||||
self.font.glyph(ch)
|
||||
}
|
||||
pub fn build(root: &Path) -> Result<Self> {
|
||||
Self::build_over(root, root)
|
||||
|
||||
@@ -875,7 +875,16 @@ struct MeshInput {
|
||||
}
|
||||
struct ReducedColumns {
|
||||
columns: Arc<[Column; 256]>,
|
||||
levels: [Vec<Column>; 4],
|
||||
/// The chunk reduced to cells 2, 4, 8 and 16 blocks wide, each built the
|
||||
/// first time a tile of that width asks for it.
|
||||
///
|
||||
/// One chunk is covered by one far tile, so one of the four is what gets
|
||||
/// read, and a chunk whose detail arrives while full-detail terrain still
|
||||
/// draws it reads none. Building all four up front cost 405 us of a far
|
||||
/// terrain thread for every chunk the world sent (`Column::reduced` walks
|
||||
/// every span of the footprint four times over), and held 85 columns
|
||||
/// beside the chunk's 256.
|
||||
levels: [std::sync::OnceLock<Vec<Column>>; 4],
|
||||
}
|
||||
impl MeshInput {
|
||||
/// The column of every cell: the tile's own samples unless the snapshot
|
||||
@@ -905,25 +914,26 @@ impl MeshInput {
|
||||
}
|
||||
impl ReducedColumns {
|
||||
fn new(columns: Arc<[Column; 256]>) -> Self {
|
||||
let levels = std::array::from_fn(|level| {
|
||||
let width = 2 << level;
|
||||
Self {
|
||||
columns,
|
||||
levels: std::array::from_fn(|_| std::sync::OnceLock::new()),
|
||||
}
|
||||
}
|
||||
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
|
||||
if width == 1 {
|
||||
return &self.columns[z * 16 + x];
|
||||
}
|
||||
let level = width.trailing_zeros() as usize - 1;
|
||||
let reduced = self.levels[level].get_or_init(|| {
|
||||
let columns = &self.columns;
|
||||
(0..16 / width)
|
||||
.flat_map(|z| {
|
||||
let columns = &columns;
|
||||
(0..16 / width)
|
||||
.map(move |x| Column::reduced(columns, x * width, z * width, width))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Self { columns, levels }
|
||||
}
|
||||
fn get(&self, x: usize, z: usize, width: usize) -> &Column {
|
||||
if width == 1 {
|
||||
&self.columns[z * 16 + x]
|
||||
} else {
|
||||
&self.levels[width.trailing_zeros() as usize - 1]
|
||||
[(z / width) * (16 / width) + x / width]
|
||||
}
|
||||
&reduced[(z / width) * (16 / width) + x / width]
|
||||
}
|
||||
}
|
||||
enum Job {
|
||||
@@ -3348,6 +3358,44 @@ mod tests {
|
||||
"a crown over one corner of a wide cell still sits on its trunk"
|
||||
);
|
||||
}
|
||||
/// A chunk is reduced to the one cell width that reads it, when it does.
|
||||
///
|
||||
/// Reducing to all four widths up front cost 405 us of a far terrain
|
||||
/// thread for every chunk the world sent, where at most one is read.
|
||||
#[test]
|
||||
fn a_chunk_is_reduced_only_to_the_width_that_reads_it() {
|
||||
let built = |reduced: &ReducedColumns| {
|
||||
reduced
|
||||
.levels
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, level)| level.get().is_some())
|
||||
.map(|(level, _)| 2usize << level)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let reduced = ReducedColumns::new(Arc::new(std::array::from_fn(|i| Column {
|
||||
surface: None,
|
||||
spans: vec![Span {
|
||||
min: 0,
|
||||
max: 4 + (i % 3) as i16,
|
||||
state: BlockStateId(1),
|
||||
biome: 0,
|
||||
light: 240,
|
||||
}]
|
||||
.into(),
|
||||
})));
|
||||
assert!(built(&reduced).is_empty(), "nothing is reduced unread");
|
||||
// A tile of full-detail cells reads the chunk's own columns.
|
||||
reduced.get(3, 5, 1);
|
||||
assert!(built(&reduced).is_empty());
|
||||
reduced.get(4, 8, 4);
|
||||
assert_eq!(built(&reduced), [4]);
|
||||
// Read again, and the reduction is the one already built.
|
||||
reduced.get(0, 0, 4);
|
||||
assert_eq!(built(&reduced), [4]);
|
||||
reduced.get(0, 0, 16);
|
||||
assert_eq!(built(&reduced), [4, 16]);
|
||||
}
|
||||
/// Detail read before a refresh over its chunk is dropped (asked
|
||||
/// again); elsewhere, or read after, it is kept.
|
||||
#[test]
|
||||
|
||||
@@ -1405,7 +1405,8 @@ fn pack_coords(coords: u32) -> u16 {
|
||||
pub struct Mesher<'a> {
|
||||
/// CardinalLighting.NETHER: brighter up/down faces.
|
||||
pub nether_shading: bool,
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
|
||||
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours,
|
||||
/// only the dimension's up-face brightness (`lighting::pack_flat`).
|
||||
pub shader_pack_lighting: bool,
|
||||
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
|
||||
pub separate_ao: bool,
|
||||
@@ -1660,8 +1661,10 @@ impl Mesher<'_> {
|
||||
None => ao,
|
||||
};
|
||||
let shade_face = q.shade_direction_override.unwrap_or(q.facing);
|
||||
let directional = if self.shader_pack_lighting || !q.shade {
|
||||
let directional = if !q.shade {
|
||||
1.0
|
||||
} else if self.shader_pack_lighting {
|
||||
lighting::pack_flat(self.nether_shading)
|
||||
} else {
|
||||
lighting::cardinal(self.nether_shading, shade_face)
|
||||
};
|
||||
|
||||
@@ -189,8 +189,9 @@ impl FluidFlowWorld for FlowView<'_> {
|
||||
}
|
||||
}
|
||||
/// Emits one fluid cell at section-relative `[x, y, z]`.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
|
||||
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
|
||||
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, only the up-face value
|
||||
/// when a shader pack lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure
|
||||
/// alpha for separate AO.
|
||||
pub(super) fn tesselate(
|
||||
tables: &MeshTables,
|
||||
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
|
||||
@@ -202,7 +203,7 @@ pub(super) fn tesselate(
|
||||
use horizon_assets::model::Direction as D;
|
||||
let cardinal = |d: D| {
|
||||
if shader_pack_lighting {
|
||||
1.0
|
||||
lighting::pack_flat(nether)
|
||||
} else {
|
||||
lighting::cardinal(nether, d)
|
||||
}
|
||||
@@ -447,7 +448,10 @@ pub(super) fn tesselate(
|
||||
Some(layer) if face_state.overlay => (layer, true),
|
||||
_ => (flow, false),
|
||||
};
|
||||
let side = if matches!(d, Direction::North | Direction::South) {
|
||||
// Vanilla's side is the up value times the side's; a shader pack's has no side factor.
|
||||
let side = if shader_pack_lighting {
|
||||
1.0
|
||||
} else if matches!(d, Direction::North | Direction::South) {
|
||||
cardinal(D::North)
|
||||
} else {
|
||||
cardinal(D::West)
|
||||
|
||||
@@ -19,6 +19,12 @@ pub(super) fn cardinal(nether: bool, face: D) -> f32 {
|
||||
D::West | D::East => 0.6,
|
||||
}
|
||||
}
|
||||
/// A face's brightness when a shader pack lights the faces (`Mesher::shader_pack_lighting`): no
|
||||
/// directional shading, every face at the dimension's up-face value (1.0, the Nether's 0.9).
|
||||
/// Our probe pack's Iris 1.11.7 values fit this: 0.9 on every Nether face, 1.0 in the overworld.
|
||||
pub(super) fn pack_flat(nether: bool) -> f32 {
|
||||
cardinal(nether, D::Up)
|
||||
}
|
||||
fn normal(face: D) -> [i32; 3] {
|
||||
match face {
|
||||
D::Down => [0, -1, 0],
|
||||
|
||||
@@ -159,6 +159,60 @@ fn a_shader_pack_lights_the_faces_itself() {
|
||||
assert_eq!(colours(true).len(), 1);
|
||||
}
|
||||
|
||||
/// Run explicitly with prepared assets. With a shader pack lighting the faces, every face of a
|
||||
/// block and of a lava source takes the dimension's up-face brightness, as Iris 1.11.7 gives our
|
||||
/// probe pack: 0.9 in the Nether (`CardinalLighting.NETHER`'s up, 229 of 255), 1.0 elsewhere.
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn a_shader_pack_lights_every_nether_face_like_an_up_face() {
|
||||
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client/26.3")
|
||||
});
|
||||
let assets = Assets::load(&root).unwrap();
|
||||
let facts = BlockFacts::load(&root, assets.registry).unwrap();
|
||||
let biomes = Biomes::load(&root).unwrap();
|
||||
let render = assets.reload(&root).unwrap();
|
||||
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
|
||||
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
|
||||
let state = |name| assets.registry.default_state(name).unwrap().0;
|
||||
let mut chunk = Chunk::empty(0, 0);
|
||||
chunk.set(4, MIN_Y + 8, 4, state("minecraft:netherrack"));
|
||||
chunk.set(11, MIN_Y + 8, 11, state("minecraft:lava"));
|
||||
let mut region = [[None; 3]; 3];
|
||||
region[1][1] = Some(&chunk);
|
||||
let colours = |nether_shading| {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading,
|
||||
shader_pack_lighting: true,
|
||||
separate_ao: false,
|
||||
assets: &assets,
|
||||
render: &render,
|
||||
tables: &tables,
|
||||
biomes: &biomes,
|
||||
visibility: visibility.clone(),
|
||||
zoom: BiomeZoom::new(0),
|
||||
blend_radius: 0,
|
||||
smooth_lighting: false,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
};
|
||||
let mesh = mesher.mesh(®ion, 0);
|
||||
// Both blocks' six faces: 2 × 6 quads of 4 vertices.
|
||||
let all: Vec<_> = mesh.solid.iter().chain(&mesh.translucent).collect();
|
||||
assert!(all.len() >= 48, "{} vertices", all.len());
|
||||
let mut colours: Vec<u32> = all.iter().map(|v| v.color & 0xFF_FFFF).collect();
|
||||
colours.sort_unstable();
|
||||
colours.dedup();
|
||||
colours
|
||||
};
|
||||
assert_eq!(colours(true), [0xE5_E5E5]);
|
||||
assert_eq!(colours(false), [0xFF_FFFF]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
|
||||
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
|
||||
|
||||
@@ -1344,6 +1344,14 @@ enum WKind {
|
||||
value: f32,
|
||||
name: String,
|
||||
},
|
||||
/// A float or int option `.option` of the Horizon pack `s.shader_packs[.pack]`: where its
|
||||
/// value sits between its minimum and maximum.
|
||||
PackSlider {
|
||||
label: Text,
|
||||
value: f32,
|
||||
pack: usize,
|
||||
option: usize,
|
||||
},
|
||||
Text {
|
||||
text: Text,
|
||||
color: u32,
|
||||
@@ -1372,6 +1380,8 @@ struct Widget {
|
||||
enum FocusId {
|
||||
Search,
|
||||
Slider(Opt),
|
||||
/// A Horizon pack's option slider: (pack, option).
|
||||
PackSlider(usize, usize),
|
||||
Cycle(Opt),
|
||||
Act(Act),
|
||||
Lang(String),
|
||||
@@ -1381,6 +1391,7 @@ enum FocusId {
|
||||
fn focus_id(i: usize, wd: &Widget) -> FocusId {
|
||||
match &wd.kind {
|
||||
WKind::Slider { opt, .. } => FocusId::Slider(*opt),
|
||||
WKind::PackSlider { pack, option, .. } => FocusId::PackSlider(*pack, *option),
|
||||
WKind::Button {
|
||||
act: Act::Cycle(opt),
|
||||
..
|
||||
@@ -1394,6 +1405,9 @@ fn focus_id(i: usize, wd: &Widget) -> FocusId {
|
||||
fn matches_focus(id: &FocusId, i: usize, wd: &Widget) -> bool {
|
||||
match (id, &wd.kind) {
|
||||
(FocusId::Slider(a), WKind::Slider { opt, .. }) => a == opt,
|
||||
(FocusId::PackSlider(p, o), WKind::PackSlider { pack, option, .. }) => {
|
||||
(p, o) == (pack, option)
|
||||
}
|
||||
(
|
||||
FocusId::Cycle(a),
|
||||
WKind::Button {
|
||||
@@ -1512,6 +1526,9 @@ pub struct OptionsUi {
|
||||
/// The shader pack option whose slider is held, and the handle's own position: it follows
|
||||
/// the pointer while the option takes the nearest of its values.
|
||||
shader_dragging: Option<(String, f32)>,
|
||||
/// The same for a Horizon pack's option: (pack, option, the handle's position, whether its
|
||||
/// value changed since the press). The pack saves its values, and reloads, on release.
|
||||
pack_dragging: Option<(usize, usize, f32, bool)>,
|
||||
/// Shift is held during the click (it resets a shader pack option to its default).
|
||||
pub shift: bool,
|
||||
/// The pressed slider's own position (`AbstractSliderButton.value`) and
|
||||
@@ -1602,6 +1619,7 @@ impl Default for OptionsUi {
|
||||
scroll: 0.0,
|
||||
dragging: None,
|
||||
shader_dragging: None,
|
||||
pack_dragging: None,
|
||||
shift: false,
|
||||
slider_value: None,
|
||||
scrollbar_drag_y: None,
|
||||
@@ -1746,7 +1764,23 @@ fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
|
||||
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
|
||||
let label =
|
||||
Text::option_name_value(Text::literal(option.name.clone()), Text::literal(value));
|
||||
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
|
||||
match pack.slider_position(o) {
|
||||
Some(value) => Widget {
|
||||
x: 0.0,
|
||||
y: 0.0,
|
||||
w: width,
|
||||
h: 20.0,
|
||||
kind: WKind::PackSlider {
|
||||
label,
|
||||
value,
|
||||
pack: i,
|
||||
option: o,
|
||||
},
|
||||
list: false,
|
||||
row: 0,
|
||||
},
|
||||
None => button(0.0, 0.0, width, label, Act::NativePackOption(i, o)),
|
||||
}
|
||||
}
|
||||
PageItem::Page(p) if p < pack.pages.len() => {
|
||||
let label = Text::literal(format!("{}...", pack.pages[p].name));
|
||||
@@ -1845,7 +1879,10 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
|
||||
WKind::Button {
|
||||
act: Act::NativePackOption(pack, option),
|
||||
..
|
||||
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
|
||||
}
|
||||
| WKind::PackSlider { pack, option, .. } => {
|
||||
Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description)
|
||||
}
|
||||
WKind::Button {
|
||||
act: Act::Push(Page::NativePackOptions(pack, at)),
|
||||
..
|
||||
@@ -2163,6 +2200,18 @@ impl OptionsUi {
|
||||
}
|
||||
events
|
||||
}
|
||||
/// Steps a Horizon pack's option slider one step forward or backward, and saves it.
|
||||
fn step_pack_slider(s: &mut Settings, pack: usize, option: usize, forward: bool) -> Vec<Event> {
|
||||
let stepped = s
|
||||
.shader_packs
|
||||
.get_mut(pack)
|
||||
.is_some_and(|entry| entry.nudge_option(option, !forward));
|
||||
if stepped {
|
||||
vec![Event::NativePackOptions(pack)]
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
/// Open a page whose Done/Escape returns directly to the calling screen.
|
||||
pub fn open_at(&mut self, in_world: bool, page: Page) {
|
||||
self.stack.clear();
|
||||
@@ -3716,6 +3765,34 @@ impl OptionsUi {
|
||||
dragging,
|
||||
);
|
||||
}
|
||||
WKind::PackSlider {
|
||||
label,
|
||||
value,
|
||||
pack,
|
||||
option,
|
||||
} => {
|
||||
let held = self
|
||||
.pack_dragging
|
||||
.filter(|&(p, o, ..)| (p, o) == (*pack, *option));
|
||||
let dragging = held.is_some();
|
||||
ui::slider_ext(
|
||||
batch,
|
||||
atlas,
|
||||
wd.x,
|
||||
wd.y,
|
||||
wd.w,
|
||||
label,
|
||||
held.map_or(*value, |h| h.2),
|
||||
hover || dragging,
|
||||
is_focused,
|
||||
self.can_change_value && is_focused,
|
||||
true,
|
||||
dragging,
|
||||
);
|
||||
crate::widgets::last_narratable(|n| {
|
||||
narrate_widget(n, s, wd, hover, list_key, framebuffer);
|
||||
});
|
||||
}
|
||||
WKind::Text {
|
||||
text,
|
||||
color,
|
||||
@@ -4015,6 +4092,16 @@ impl OptionsUi {
|
||||
*at = ui::slider_value(wd.x, wd.w, x);
|
||||
options.set_position(name, *at);
|
||||
}
|
||||
if let Some((pack, option, at, changed)) = &mut self.pack_dragging
|
||||
&& let Some(wd) = widgets.iter().find(|wd| {
|
||||
matches!(wd.kind, WKind::PackSlider { pack: p, option: o, .. }
|
||||
if (p, o) == (*pack, *option))
|
||||
})
|
||||
&& let Some(entry) = s.shader_packs.get_mut(*pack)
|
||||
{
|
||||
*at = ui::slider_value(wd.x, wd.w, x);
|
||||
*changed |= entry.set_slider_position(*option, *at);
|
||||
}
|
||||
if let Some(opt) = self.dragging
|
||||
&& let Some(wd) = widgets
|
||||
.iter()
|
||||
@@ -4251,6 +4338,21 @@ impl OptionsUi {
|
||||
}
|
||||
self.shader_dragging = Some((name, at));
|
||||
}
|
||||
WKind::PackSlider { pack, option, .. } => {
|
||||
self.can_change_value = true;
|
||||
let Some(entry) = s.shader_packs.get_mut(pack) else {
|
||||
break;
|
||||
};
|
||||
if self.shift {
|
||||
// Its default, as a shift-click on a pack's button does; no drag follows.
|
||||
entry.step_option(option, false, true);
|
||||
events.extend([Event::ButtonClick, Event::NativePackOptions(pack)]);
|
||||
break;
|
||||
}
|
||||
let at = ui::slider_value(wd.x, wd.w, x);
|
||||
let changed = entry.set_slider_position(option, at);
|
||||
self.pack_dragging = Some((pack, option, at, changed));
|
||||
}
|
||||
WKind::Slider { opt, .. } => {
|
||||
self.can_change_value = true;
|
||||
self.dragging = Some(opt);
|
||||
@@ -4287,12 +4389,19 @@ impl OptionsUi {
|
||||
}
|
||||
// AbstractSliderButton suppresses the press sound and plays once on
|
||||
// release, even if the pointer has left the widget or its value stayed.
|
||||
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
|
||||
let pack = self.pack_dragging.take();
|
||||
let slider =
|
||||
self.dragging.take().is_some() | self.shader_dragging.take().is_some() | pack.is_some();
|
||||
let graphics_slider = self.graphics.release();
|
||||
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
|
||||
let split_drop = self.split_setup.release();
|
||||
if slider || graphics_slider || calibration_slider {
|
||||
vec![Event::ButtonClick, Event::Changed]
|
||||
let mut events = vec![Event::ButtonClick, Event::Changed];
|
||||
// A pack's option is saved, and the pack rebuilt, once per drag.
|
||||
if let Some((pack, _, _, true)) = pack {
|
||||
events.push(Event::NativePackOptions(pack));
|
||||
}
|
||||
events
|
||||
} else if split_drop {
|
||||
vec![Event::ButtonClick]
|
||||
} else {
|
||||
@@ -4390,6 +4499,10 @@ impl OptionsUi {
|
||||
.map_or(150.0, |wd| wd.w);
|
||||
return self.step_slider_option(s, opt, amount > 0.0, width);
|
||||
}
|
||||
Some(FocusId::PackSlider(pack, option)) => {
|
||||
let (pack, option) = (*pack, *option);
|
||||
return Self::step_pack_slider(s, pack, option, amount > 0.0);
|
||||
}
|
||||
Some(FocusId::Cycle(opt)) => {
|
||||
let opt = *opt;
|
||||
let mut events = vec![Event::ButtonClick];
|
||||
@@ -4855,6 +4968,7 @@ impl OptionsUi {
|
||||
match wd.kind {
|
||||
WKind::Button { active: true, .. }
|
||||
| WKind::Slider { .. }
|
||||
| WKind::PackSlider { .. }
|
||||
| WKind::LanguageRow { .. } => {
|
||||
focusables.push((focus_id(i, wd), [wd.x, wd.y, wd.w, wd.h], wd.list));
|
||||
}
|
||||
@@ -4890,7 +5004,7 @@ impl OptionsUi {
|
||||
let (next_id, rect, in_list) = &focusables[next_idx];
|
||||
self.focus = Some(next_id.clone());
|
||||
self.focus_from_keyboard = true;
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_));
|
||||
self.can_change_value = matches!(next_id, FocusId::Slider(_) | FocusId::PackSlider(..));
|
||||
if *in_list {
|
||||
if rect[1] < top {
|
||||
self.scroll -= (top - rect[1]) + 4.0;
|
||||
@@ -4915,6 +5029,11 @@ impl OptionsUi {
|
||||
.map_or(150.0, |wd| wd.w);
|
||||
return self.step_slider_option(s, opt, forward, width);
|
||||
}
|
||||
if let Some(FocusId::PackSlider(pack, option)) = self.focus
|
||||
&& self.can_change_value
|
||||
{
|
||||
return Self::step_pack_slider(s, pack, option, forward);
|
||||
}
|
||||
// Otherwise, navigate horizontally using 2D focus stepping
|
||||
if let Some(fid) = &self.focus {
|
||||
if let Some(cur_pos) = focusables.iter().position(|(id, ..)| id == fid) {
|
||||
@@ -4992,7 +5111,7 @@ impl OptionsUi {
|
||||
// 4. Enter / Space selection keys
|
||||
if matches!(key, K::Enter | K::NumpadEnter | K::Space) {
|
||||
match &self.focus {
|
||||
Some(FocusId::Slider(_)) => {
|
||||
Some(FocusId::Slider(_) | FocusId::PackSlider(..)) => {
|
||||
self.can_change_value = !self.can_change_value;
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -5505,11 +5624,9 @@ mod tests {
|
||||
id: "a".into(),
|
||||
name: "A".into(),
|
||||
description: String::new(),
|
||||
kind: horizon_shader_api::OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
kind: horizon_shader_api::OptionKind::Choice {
|
||||
values: vec!["x".into(), "y".into(), "z".into()],
|
||||
default: 1,
|
||||
},
|
||||
}],
|
||||
values: vec![1.0],
|
||||
@@ -5550,10 +5667,10 @@ mod tests {
|
||||
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
|
||||
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values, [1.5]);
|
||||
assert_eq!(s.shader_packs[0].values, [2.0]);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
|
||||
assert_eq!(s.shader_packs[0].values, [0.5]);
|
||||
assert_eq!(s.shader_packs[0].values, [0.0]);
|
||||
// Shift-click: the option's default.
|
||||
ui.shift = true;
|
||||
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
|
||||
@@ -5567,6 +5684,116 @@ mod tests {
|
||||
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizon_packs_float_and_int_options_are_sliders_saved_on_release() {
|
||||
use horizon_shader_api::{OptionKind, PackOption};
|
||||
use winit::keyboard::KeyCode;
|
||||
let atlas = atlas();
|
||||
let size = (960.0, 540.0);
|
||||
let option = |id: &str, kind| PackOption {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
description: String::new(),
|
||||
kind,
|
||||
};
|
||||
let pack = crate::shader_pack_list::Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: crate::shader_pack_list::Kind::Horizon,
|
||||
options: vec![
|
||||
option(
|
||||
"a",
|
||||
OptionKind::Float {
|
||||
default: 1.0,
|
||||
min: 0.0,
|
||||
max: 2.0,
|
||||
step: 0.5,
|
||||
},
|
||||
),
|
||||
option(
|
||||
"b",
|
||||
OptionKind::Int {
|
||||
default: 2,
|
||||
min: 0,
|
||||
max: 4,
|
||||
},
|
||||
),
|
||||
option("c", OptionKind::Bool { default: true }),
|
||||
],
|
||||
values: vec![1.0, 2.0, 1.0],
|
||||
pages: Vec::new(),
|
||||
};
|
||||
let mut s = Settings {
|
||||
shader_packs: vec![pack],
|
||||
..Settings::default()
|
||||
};
|
||||
let mut ui = OptionsUi::default();
|
||||
ui.show(Page::NativePackOptions(0, 0));
|
||||
// A slider's track, from its left end to its right: (x of 0, x of 1, y).
|
||||
let track = |ui: &mut OptionsUi, s: &Settings, want: usize| {
|
||||
let (widgets, ..) = ui.layout(s, size.0, size.1);
|
||||
widgets
|
||||
.into_iter()
|
||||
.find(|w| matches!(w.kind, WKind::PackSlider { option, .. } if option == want))
|
||||
.map(|w| (w.x + 4.0, w.x + w.w - 4.0, w.y + w.h / 2.0))
|
||||
};
|
||||
assert!(track(&mut ui, &s, 2).is_none(), "a bool stays a button");
|
||||
let (left, right, y) = track(&mut ui, &s, 0).expect("the float's slider");
|
||||
let at = |f: f32| left + f * (right - left);
|
||||
// Pressed a fifth of the way: 0.4 of 2, the nearest step 0.5. Nothing is saved yet.
|
||||
let events = ui.click(&mut s, &atlas, at(0.2), y, size, [854, 480], false, false);
|
||||
assert!(!events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values[0], 0.5);
|
||||
// Dragged past the right end: the maximum.
|
||||
ui.click(
|
||||
&mut s,
|
||||
&atlas,
|
||||
right + 30.0,
|
||||
y,
|
||||
size,
|
||||
[854, 480],
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(s.shader_packs[0].values[0], 2.0);
|
||||
// Released: saved once, which reloads the pack.
|
||||
let events = ui.release();
|
||||
let saves = events.iter().filter(|e| **e == Event::NativePackOptions(0));
|
||||
assert_eq!(saves.count(), 1);
|
||||
// A press and release that leave the value as it was save nothing.
|
||||
ui.click(&mut s, &atlas, right, y, size, [854, 480], false, false);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
// Shift-click: the default at once.
|
||||
ui.shift = true;
|
||||
let events = ui.click(&mut s, &atlas, at(0.9), y, size, [854, 480], false, false);
|
||||
ui.shift = false;
|
||||
assert!(events.contains(&Event::NativePackOptions(0)));
|
||||
assert_eq!(s.shader_packs[0].values[0], 1.0);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
|
||||
// The int takes whole numbers: 0.6 of 0..4 is 2.4, so 2, unchanged; 0.7 is 3.
|
||||
let (left, right, y) = track(&mut ui, &s, 1).expect("the int's slider");
|
||||
let at = |f: f32| left + f * (right - left);
|
||||
ui.click(&mut s, &atlas, at(0.6), y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values[1], 2.0);
|
||||
assert!(!ui.release().contains(&Event::NativePackOptions(0)));
|
||||
ui.click(&mut s, &atlas, at(0.7), y, size, [854, 480], false, false);
|
||||
assert_eq!(s.shader_packs[0].values[1], 3.0);
|
||||
assert!(ui.release().contains(&Event::NativePackOptions(0)));
|
||||
|
||||
// From the keyboard: the arrows step a focused slider, saving each step, up to its end.
|
||||
ui.focus = Some(FocusId::PackSlider(0, 1));
|
||||
ui.focus_from_keyboard = true;
|
||||
ui.can_change_value = true;
|
||||
let events = ui.key(&mut s, &atlas, KeyCode::ArrowRight, None);
|
||||
assert_eq!(events, [Event::NativePackOptions(0)]);
|
||||
assert_eq!(s.shader_packs[0].values[1], 4.0);
|
||||
assert!(ui.key(&mut s, &atlas, KeyCode::ArrowRight, None).is_empty());
|
||||
assert_eq!(s.shader_packs[0].values[1], 4.0);
|
||||
ui.key(&mut s, &atlas, KeyCode::ArrowLeft, None);
|
||||
assert_eq!(s.shader_packs[0].values[1], 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resource_reload_error_blocks_options_until_dismissed() {
|
||||
use winit::keyboard::KeyCode;
|
||||
@@ -8039,7 +8266,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 +8277,22 @@ mod tests {
|
||||
}
|
||||
)
|
||||
}) {
|
||||
// Extras has enough rows to scroll: click the cycle button inside
|
||||
// the list's viewport, as a player would after scrolling to it.
|
||||
ui.scroll = (extra_btn.y - top).max(0.0);
|
||||
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
|
||||
let extra_btn = widgets
|
||||
.iter()
|
||||
.find(|wd| {
|
||||
matches!(
|
||||
wd.kind,
|
||||
WKind::Button {
|
||||
act: Act::Extra(crate::extras::Item::CosmeticsOthers),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap();
|
||||
let (ex, ey) = (extra_btn.x + 1.0, extra_btn.y + 1.0);
|
||||
use horizon_cosmetics::prefs::Others;
|
||||
assert_eq!(s.extras.cosmetics_others, Others::Shown);
|
||||
|
||||
@@ -170,8 +170,17 @@ fn last_pre_minor(current: Format) -> i64 {
|
||||
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
|
||||
/// non-negative ints whose second is the minor. None: it does not parse.
|
||||
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
|
||||
let non_negative =
|
||||
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
|
||||
// `Codec.INT` reads any JSON number through `Number.intValue`: a decimal such as `97.1`
|
||||
// counts as its whole part (97), as it does for the game.
|
||||
let non_negative = |v: &serde_json::Value| {
|
||||
v.as_i64()
|
||||
.or_else(|| {
|
||||
v.as_f64()
|
||||
.filter(|f| f.is_finite())
|
||||
.map(|f| f.trunc() as i64)
|
||||
})
|
||||
.filter(|n| *n >= 0 && *n <= i64::from(i32::MAX))
|
||||
};
|
||||
match v {
|
||||
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
|
||||
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
|
||||
@@ -636,7 +645,7 @@ fn build_overlay_uncached(
|
||||
}
|
||||
// FallbackResourceManager: a file's `.mcmeta` counts only from the pack that
|
||||
// supplies the file or one above it; vanilla's own sits below every pack.
|
||||
let hidden: BTreeSet<String> = files
|
||||
let mut hidden: BTreeSet<String> = files
|
||||
.iter()
|
||||
.filter(|(name, _)| !name.ends_with(".mcmeta"))
|
||||
.filter_map(|(name, (owner, _))| {
|
||||
@@ -647,6 +656,25 @@ fn build_overlay_uncached(
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
// A companion from below the winning colour would describe another surface.
|
||||
// A map-only pack above it is intentional and remains valid.
|
||||
for (name, (owner, _)) in &files {
|
||||
let Some(stem) = name.strip_suffix(".png") else {
|
||||
continue;
|
||||
};
|
||||
if stem.ends_with("_n") || stem.ends_with("_s") {
|
||||
continue;
|
||||
}
|
||||
for suffix in ["_n.png", "_s.png"] {
|
||||
let map = format!("{stem}{suffix}");
|
||||
if files
|
||||
.get(&map)
|
||||
.is_none_or(|(map_owner, _)| map_owner < owner)
|
||||
{
|
||||
hidden.insert(map);
|
||||
}
|
||||
}
|
||||
}
|
||||
for name in &hidden {
|
||||
files.remove(name);
|
||||
}
|
||||
@@ -1257,6 +1285,16 @@ mod tests {
|
||||
.unwrap_err()
|
||||
.contains("deprecated")
|
||||
);
|
||||
// A decimal reads as its whole part, in a list or alone: `[97.1]` is format 97 (a pack
|
||||
// that meant `[97, 1]` still loads and counts as compatible).
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":[97.1],"max_format":[97.1]}"#),
|
||||
Ok(((97, 0), (97, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97.9,"max_format":98.5}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
);
|
||||
assert_eq!(
|
||||
check(r#"{"min_format":97,"max_format":98}"#),
|
||||
Ok(((97, 0), (98, i64::from(i32::MAX))))
|
||||
@@ -1469,6 +1507,61 @@ mod tests {
|
||||
assert_eq!(merge_kind("assets/minecraft/models/block/stone.json"), None);
|
||||
}
|
||||
#[test]
|
||||
fn companion_maps_follow_the_winning_colour_or_a_higher_map_only_pack() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-material-owner-{}", std::process::id()));
|
||||
let vanilla = root.join("vanilla");
|
||||
let relative = "assets/minecraft/textures/block";
|
||||
fs::create_dir_all(vanilla.join(relative)).unwrap();
|
||||
let names = ["stone.png", "stone_n.png", "stone_s.png"];
|
||||
for name in names {
|
||||
fs::write(vanilla.join(relative).join(name), b"base").unwrap();
|
||||
}
|
||||
for (pack, files) in [
|
||||
("low", vec!["stone_n.png", "stone_s.png"]),
|
||||
("colour", vec!["stone.png"]),
|
||||
("maps", vec!["stone_n.png"]),
|
||||
] {
|
||||
let dir = root.join("packs").join(pack);
|
||||
fs::create_dir_all(dir.join(relative)).unwrap();
|
||||
fs::write(
|
||||
dir.join("pack.mcmeta"),
|
||||
br#"{"pack":{"description":"fixture","pack_format":97}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
for name in files {
|
||||
fs::write(dir.join(relative).join(name), pack.as_bytes()).unwrap();
|
||||
}
|
||||
}
|
||||
let all = discover(&vanilla, &root.join("packs"));
|
||||
let order = |names: &[&str]| {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| {
|
||||
all.iter()
|
||||
.find(|p| p.id == format!("file/{name}"))
|
||||
.unwrap()
|
||||
.clone()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let out = root.join("out");
|
||||
build_overlay(&vanilla, &order(&["low", "colour"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone.png")).unwrap(),
|
||||
b"colour"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_n.png").exists());
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
build_overlay(&vanilla, &order(&["low", "colour", "maps"]), &out).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(out.join(relative).join("stone_n.png")).unwrap(),
|
||||
b"maps"
|
||||
);
|
||||
assert!(!out.join(relative).join("stone_s.png").exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn overlay_layers_packs_over_vanilla() {
|
||||
// `&` would split an unquoted `cmd /C mklink` line.
|
||||
let base = std::env::temp_dir().join(format!("horizon-packs-&{}", std::process::id()));
|
||||
|
||||
@@ -13,7 +13,7 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
|
||||
// Version 5 read every vanilla file to stamp it on Windows.
|
||||
// Version 6 stamps vanilla's files by their metadata on Windows too.
|
||||
// Version 7 stacks blockstate files (every pack's definition, bottom first).
|
||||
const VERSION: u32 = 7;
|
||||
const VERSION: u32 = 8;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub(super) struct Dependencies {
|
||||
|
||||
@@ -26,6 +26,8 @@ use std::{
|
||||
|
||||
pub use horizon_client_core::particle_type::ParticleType;
|
||||
pub use horizon_items::firework::{DYE_FIREWORK_COLORS, FireworkExplosion, FireworkShape};
|
||||
mod native;
|
||||
pub use native::{ParticleController, ParticleUpdate};
|
||||
|
||||
/// `ParticleOptions` with the parameters of the option classes used here.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -212,6 +214,9 @@ enum Light {
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
enum Behaviour {
|
||||
Base,
|
||||
Controlled {
|
||||
translucent: bool,
|
||||
},
|
||||
Mod {
|
||||
size: [f32; 2],
|
||||
alpha: [f32; 2],
|
||||
@@ -505,6 +510,7 @@ pub struct Particle {
|
||||
pre_tick: bool,
|
||||
/// `Particle.random`: the particle's own generator (`RandomSource.create()`).
|
||||
random: LegacyRandom,
|
||||
controller: Option<Box<dyn native::Controller>>,
|
||||
}
|
||||
fn aabb_at(p: [f64; 3], w: f32, h: f32) -> Aabb {
|
||||
let w = f64::from(w) / 2.0;
|
||||
@@ -841,8 +847,37 @@ impl Particle {
|
||||
pre_tick: false,
|
||||
frame_ticks: 0,
|
||||
random: LegacyRandom::new(0),
|
||||
controller: None,
|
||||
}
|
||||
}
|
||||
/// The normal age/removal test and previous pose, without vanilla motion.
|
||||
/// Returns false when this tick expires the particle.
|
||||
pub fn advance_age(&mut self) -> bool {
|
||||
self.previous = self.position;
|
||||
self.o_roll = self.roll;
|
||||
let alive = self.age < self.lifetime;
|
||||
self.age = self.age.saturating_add(1);
|
||||
self.removed |= !alive;
|
||||
alive
|
||||
}
|
||||
pub fn remove(&mut self) {
|
||||
self.removed = true;
|
||||
}
|
||||
/// Hide geometry while retaining the particle's lifetime and controller.
|
||||
pub fn set_visible(&mut self, visible: bool) {
|
||||
self.no_render = !visible;
|
||||
}
|
||||
/// The particle's own random, distinct from the engine and level generators.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
}
|
||||
pub fn set_full_bright(&mut self, full_bright: bool) {
|
||||
self.light = if full_bright {
|
||||
Light::FullBright
|
||||
} else {
|
||||
Light::World
|
||||
};
|
||||
}
|
||||
/// `Particle(level, x, y, z, xa, ya, za)`: jittered, normalized initial velocity.
|
||||
fn moving(random: &mut LegacyRandom, ty: ParticleType, p: [f64; 3], v: [f64; 3]) -> Self {
|
||||
let mut particle = Self::base_moving(random, ty, p, v);
|
||||
@@ -1420,6 +1455,10 @@ impl ParticleEngine {
|
||||
atlas: &HashMap<String, Rect>,
|
||||
translucent: &HashSet<String>,
|
||||
) {
|
||||
// Controllers' numeric sprite-set handles belong to this resource
|
||||
// generation. Discard them rather than selecting another set after reload.
|
||||
self.particles.retain(|p| p.controller.is_none());
|
||||
self.pending.retain(|p| p.controller.is_none());
|
||||
self.atlas = atlas
|
||||
.iter()
|
||||
.map(|(name, &rect)| (name.clone(), (rect, translucent.contains(name))))
|
||||
@@ -1584,6 +1623,45 @@ impl ParticleEngine {
|
||||
pub fn push(&mut self, particle: Particle) {
|
||||
self.pending.push(particle);
|
||||
}
|
||||
/// Create a named billboard with custom compiled behavior and no vanilla
|
||||
/// motion. Like `add`, respects camera distance and the Particles setting.
|
||||
/// Configure its public fields, then `push` it to the ordinary engine queue.
|
||||
/// Missing sprite sets return None. The constructor has the ordinary
|
||||
/// stationary SingleQuadParticle random size/lifetime and no velocity draws.
|
||||
pub fn controlled<C: ParticleController>(
|
||||
&mut self,
|
||||
name: &str,
|
||||
position: [f64; 3],
|
||||
translucent: bool,
|
||||
controller: C,
|
||||
) -> Option<Particle> {
|
||||
let minimal = self.calculate_particle_level(false) == 2;
|
||||
let distance: f64 = position
|
||||
.iter()
|
||||
.zip(self.camera)
|
||||
.map(|(p, c)| (p - c).powi(2))
|
||||
.sum();
|
||||
if minimal || !distance.is_finite() || distance > 1024.0 {
|
||||
return None;
|
||||
}
|
||||
let (set, _) = *self.mod_sets.get(name)?;
|
||||
let mut random = LegacyRandom::new(self.seeds.next_long());
|
||||
let mut particle = Particle::at(&mut random, ParticleType::Smoke, position);
|
||||
particle.random = random;
|
||||
particle.set = Some(set);
|
||||
particle.sprite = Sprite::Index(*self.set_sprites.get(set as usize)?.first()?);
|
||||
particle.behaviour = Behaviour::Controlled { translucent };
|
||||
particle.controller = Some(Box::new(controller));
|
||||
Some(particle)
|
||||
}
|
||||
/// Choose a frame from this particle's declared sprite set.
|
||||
pub fn set_sprite_frame(&self, particle: &mut Particle, frame: usize) {
|
||||
if let Some(sprites) = particle.set.and_then(|s| self.set_sprites.get(s as usize))
|
||||
&& !sprites.is_empty()
|
||||
{
|
||||
particle.sprite = Sprite::Index(sprites[frame.min(sprites.len() - 1)]);
|
||||
}
|
||||
}
|
||||
/// Random generator shared by particle constructors and spawners.
|
||||
pub fn random(&mut self) -> &mut LegacyRandom {
|
||||
&mut self.random
|
||||
@@ -3710,6 +3788,20 @@ impl ParticleEngine {
|
||||
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
|
||||
match p.behaviour {
|
||||
Behaviour::Base => p.base_tick(level),
|
||||
Behaviour::Controlled { .. } => {
|
||||
if let Some(mut controller) = p.controller.take() {
|
||||
let update = controller.tick(p, level);
|
||||
p.controller = Some(controller);
|
||||
if let Some(frame) = update.frame {
|
||||
self.set_sprite_frame(p, frame);
|
||||
}
|
||||
for (options, pos, velocity) in update.spawned {
|
||||
self.add(&options, pos, velocity);
|
||||
}
|
||||
} else {
|
||||
p.remove();
|
||||
}
|
||||
}
|
||||
Behaviour::Mod { size, alpha } => {
|
||||
p.base_tick(level);
|
||||
p.size = mod_curve(size, p.age, p.lifetime);
|
||||
@@ -4627,6 +4719,7 @@ impl ParticleEngine {
|
||||
Sprite::Index(i) => (
|
||||
self.rects.get(*i as usize).copied().flatten(),
|
||||
p.ty.translucent_layer()
|
||||
|| matches!(p.behaviour, Behaviour::Controlled { translucent: true })
|
||||
|| matches!(p.behaviour, Behaviour::Mod { alpha, .. } if alpha != [1.0; 2]),
|
||||
),
|
||||
Sprite::Named(name) => match self.atlas.get(name) {
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Opt-in, compiled particle behavior. The ordinary engine still owns the
|
||||
//! particle, its sprite set, interpolation, rendering and total count limits.
|
||||
use super::{Particle, ParticleLevel, ParticleOptions};
|
||||
use std::fmt::Debug;
|
||||
|
||||
/// Requests made by one particle tick, submitted through normal spawn limits.
|
||||
#[derive(Default)]
|
||||
pub struct ParticleUpdate {
|
||||
/// An index in this particle's declared sprite set. Out-of-range is clamped.
|
||||
pub frame: Option<usize>,
|
||||
pub spawned: Vec<(ParticleOptions, [f64; 3], [f64; 3])>,
|
||||
}
|
||||
|
||||
/// Owned behavior for a compiled native particle. Cloning a particle clones its
|
||||
/// controller state. Do not retain world references; inspect `level` each tick.
|
||||
pub trait ParticleController: Clone + Debug + Send + Sync + 'static {
|
||||
/// Call `particle.advance_age()` for ordinary lifetime/interpolation, or
|
||||
/// explicitly maintain those fields for an overridden source tick.
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
}
|
||||
|
||||
pub(super) trait Controller: Debug + Send + Sync {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate;
|
||||
fn clone_box(&self) -> Box<dyn Controller>;
|
||||
}
|
||||
impl<T: ParticleController> Controller for T {
|
||||
fn tick(&mut self, particle: &mut Particle, level: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
ParticleController::tick(self, particle, level)
|
||||
}
|
||||
fn clone_box(&self) -> Box<dyn Controller> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
impl Clone for Box<dyn Controller> {
|
||||
fn clone(&self) -> Self {
|
||||
self.clone_box()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::*;
|
||||
use horizon_world::{
|
||||
DATA_VERSION,
|
||||
collision::CachedCollisionRegistry,
|
||||
nbt::Tag,
|
||||
registry::{BlockRegistry, BlockStateId},
|
||||
};
|
||||
#[derive(Clone, Debug)]
|
||||
struct Moving(u32);
|
||||
impl ParticleController for Moving {
|
||||
fn tick(&mut self, p: &mut Particle, _: &ParticleLevel<'_>) -> ParticleUpdate {
|
||||
if p.advance_age() {
|
||||
self.0 += 1;
|
||||
p.position[0] += f64::from(self.0);
|
||||
}
|
||||
ParticleUpdate {
|
||||
frame: Some(self.0 as usize),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
fn engine() -> ParticleEngine {
|
||||
let mut e = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
e.mod_sets.insert("example:orbit".into(), (0, None));
|
||||
e.set_sprites.push(vec![0, 1]);
|
||||
e.rects = vec![
|
||||
Some(Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 16,
|
||||
h: 16
|
||||
});
|
||||
2
|
||||
];
|
||||
e
|
||||
}
|
||||
#[test]
|
||||
fn controlled_particle_keeps_owned_motion_sprite_interpolation_and_expiry() {
|
||||
let registry = BlockRegistry::from_nbt(&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("defaults", Tag::compound([("minecraft:air", Tag::Int(0))])),
|
||||
]))
|
||||
.unwrap();
|
||||
let cached = CachedCollisionRegistry::from_nbt(
|
||||
&Tag::compound([
|
||||
("DataVersion", Tag::Int(DATA_VERSION)),
|
||||
(
|
||||
"states",
|
||||
Tag::List(vec![Tag::String("minecraft:air".into())]),
|
||||
),
|
||||
("shapes", Tag::List(vec![])),
|
||||
("cached_shapes", Tag::IntArray(vec![-1])),
|
||||
]),
|
||||
®istry,
|
||||
)
|
||||
.unwrap();
|
||||
let collision = BlockCollisionRegistry::new(std::sync::Arc::new(cached)).unwrap();
|
||||
let facts = BlockFacts {
|
||||
states: vec![],
|
||||
air: BlockStateId(0),
|
||||
woods: HashMap::new(),
|
||||
};
|
||||
let world = World::new(BlockStateId(0), 0);
|
||||
let level = ParticleLevel {
|
||||
world: &world,
|
||||
facts: &facts,
|
||||
collision: &collision,
|
||||
player: None,
|
||||
};
|
||||
let mut e = engine();
|
||||
let mut p = e
|
||||
.controlled("example:orbit", [0.0; 3], true, Moving(0))
|
||||
.unwrap();
|
||||
p.lifetime = 2;
|
||||
p.rgba[3] = 0.5;
|
||||
p.velocity = [99.0; 3]; // no vanilla drift may leak into the custom tick
|
||||
let mut copy = p.clone();
|
||||
e.tick_particle(&mut p, &level);
|
||||
e.tick_particle(&mut p, &level);
|
||||
e.tick_particle(&mut copy, &level);
|
||||
assert_eq!((p.position, p.previous), ([3.0, 0.0, 0.0], [1.0, 0.0, 0.0]));
|
||||
assert_eq!(copy.position, [1.0, 0.0, 0.0]); // controller state cloned independently
|
||||
assert!(matches!(p.sprite, Sprite::Index(1))); // clamped frame
|
||||
p.size = 0.1;
|
||||
e.particles.push(p.clone());
|
||||
let mut vertices = Vec::new();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
assert_eq!(vertices.len(), 4);
|
||||
assert!(vertices.iter().all(
|
||||
|v| v.color >> 24 == 127 && v.layer & crate::gfx::TRANSLUCENT_PARTICLE_OVERLAY != 0
|
||||
));
|
||||
e.particles[0].set_visible(false);
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
assert!(vertices.is_empty());
|
||||
assert_eq!(e.particles[0].age, 2);
|
||||
e.pending.push(copy);
|
||||
e.reload(Path::new(""), &HashMap::new(), &HashSet::new());
|
||||
assert!(e.pending.is_empty());
|
||||
vertices.clear();
|
||||
e.render(DVec3::ZERO, 0.0, 0.0, 0.5, &|_| 0, &mut vertices);
|
||||
assert!(vertices.is_empty());
|
||||
e.tick_particle(&mut p, &level);
|
||||
assert!(p.removed);
|
||||
assert_eq!(p.position, [3.0, 0.0, 0.0]);
|
||||
}
|
||||
#[test]
|
||||
fn controlled_spawn_respects_distance_settings_and_declared_sprite_sets() {
|
||||
let mut e = engine();
|
||||
assert!(
|
||||
e.controlled("example:missing", [0.0; 3], true, Moving(0))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
e.controlled("example:orbit", [33.0, 0.0, 0.0], true, Moving(0))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
e.controlled("example:orbit", [f64::NAN, 0.0, 0.0], true, Moving(0))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
e.controlled("example:orbit", [32.0, 0.0, 0.0], true, Moving(0))
|
||||
.is_some()
|
||||
);
|
||||
e.particle_level = 2;
|
||||
assert!(
|
||||
e.controlled("example:orbit", [0.0; 3], true, Moving(0))
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,47 @@ impl Entry {
|
||||
true
|
||||
}
|
||||
|
||||
/// Where option `i` sits on its slider, 0 to 1: float and int options are sliders.
|
||||
pub fn slider_position(&self, i: usize) -> Option<f32> {
|
||||
slider_position(&self.options.get(i)?.kind, *self.values.get(i)?)
|
||||
}
|
||||
|
||||
/// Option `i` at the value nearest the slider position `at`. Whether its value changed.
|
||||
pub fn set_slider_position(&mut self, i: usize, at: f32) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
let Some(new) = slider_value(&o.kind, at) else {
|
||||
return false;
|
||||
};
|
||||
let changed = new != *v;
|
||||
*v = new;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Option `i`'s slider one step on (`back`: one before), stopping at its ends. Whether its
|
||||
/// value changed.
|
||||
pub fn nudge_option(&mut self, i: usize, back: bool) -> bool {
|
||||
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
|
||||
return false;
|
||||
};
|
||||
let dir = if back { -1.0 } else { 1.0 };
|
||||
let new = match &o.kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let n = ((*v - min) / step).round() + dir;
|
||||
on_f32((min + n * step).clamp(min, max))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
(v.round() + dir).clamp(f64::from(*min), f64::from(*max))
|
||||
}
|
||||
_ => return false,
|
||||
};
|
||||
let changed = new != *v;
|
||||
*v = new;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Every option back to its default.
|
||||
pub fn reset_options(&mut self) {
|
||||
let defaults = self.options.iter().map(|o| o.kind.default_constant());
|
||||
@@ -209,6 +250,42 @@ fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where `v` sits between the option's minimum and maximum, 0 to 1; `None` for the options
|
||||
/// that are buttons.
|
||||
fn slider_position(kind: &OptionKind, v: f64) -> Option<f32> {
|
||||
let (min, max) = match kind {
|
||||
OptionKind::Float { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
OptionKind::Int { min, max, .. } => (f64::from(*min), f64::from(*max)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(((v - min) / (max - min)).clamp(0.0, 1.0) as f32)
|
||||
}
|
||||
|
||||
/// The option's value nearest the slider position `at`: a float on its steps from the
|
||||
/// minimum (the last one the maximum, when the range isn't a whole number of steps), an int
|
||||
/// whole.
|
||||
fn slider_value(kind: &OptionKind, at: f32) -> Option<f64> {
|
||||
let at = f64::from(at.clamp(0.0, 1.0));
|
||||
match kind {
|
||||
OptionKind::Float { min, max, step, .. } => {
|
||||
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
|
||||
let steps = ((max - min) / step - 1e-6).ceil();
|
||||
Some(on_f32((min + (at * steps).round() * step).min(max)))
|
||||
}
|
||||
OptionKind::Int { min, max, .. } => {
|
||||
let (min, max) = (f64::from(*min), f64::from(*max));
|
||||
Some((min + at * (max - min)).round())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` as the `f32` the pack's `override` gets: a step of 0.05 is no f32, and 20 of them would
|
||||
/// otherwise miss a default of 1 by a hair.
|
||||
fn on_f32(v: f64) -> f64 {
|
||||
f64::from(v as f32)
|
||||
}
|
||||
|
||||
/// The value as the settings page shows it.
|
||||
pub fn show_value(kind: &OptionKind, v: f64) -> String {
|
||||
match kind {
|
||||
@@ -324,4 +401,65 @@ mod tests {
|
||||
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sliders_take_the_nearest_step_and_stop_at_the_ends() {
|
||||
let float = |default, max, step| OptionKind::Float {
|
||||
default,
|
||||
min: 0.0,
|
||||
max,
|
||||
step,
|
||||
};
|
||||
let int = OptionKind::Int {
|
||||
default: 2,
|
||||
min: 0,
|
||||
max: 10,
|
||||
};
|
||||
let mut entry = Entry {
|
||||
file: "p".into(),
|
||||
title: "P".into(),
|
||||
kind: Kind::Horizon,
|
||||
options: vec![
|
||||
option("a", float(1.0, 2.0, 0.05)),
|
||||
option("b", int),
|
||||
option("c", OptionKind::Bool { default: true }),
|
||||
option("d", float(0.0, 1.0, 0.3)),
|
||||
],
|
||||
values: vec![1.0, 2.0, 1.0, 0.0],
|
||||
pages: Vec::new(),
|
||||
};
|
||||
assert_eq!(entry.slider_position(0), Some(0.5));
|
||||
assert_eq!(entry.slider_position(1), Some(0.2));
|
||||
// A bool (and a choice) stays a button.
|
||||
assert_eq!(entry.slider_position(2), None);
|
||||
assert!(!entry.set_slider_position(2, 0.0));
|
||||
assert_eq!(entry.values[2], 1.0);
|
||||
// 0.26 of 0..2 is 0.52: the step below.
|
||||
assert!(entry.set_slider_position(0, 0.26));
|
||||
assert_eq!(show_value(&entry.options[0].kind, entry.values[0]), "0.50");
|
||||
// Twenty steps of 0.05 are the default of 1 exactly, so it isn't saved.
|
||||
assert!(entry.set_slider_position(0, 0.5));
|
||||
assert_eq!(entry.values[0], 1.0);
|
||||
assert!(!entry.set_slider_position(0, 0.505));
|
||||
// 5.7 of 0..10 is 6.
|
||||
assert!(entry.set_slider_position(1, 0.57));
|
||||
assert_eq!(entry.values[1], 6.0);
|
||||
// A range that isn't a whole number of steps still reaches its maximum.
|
||||
entry.set_slider_position(3, 1.0);
|
||||
assert_eq!(entry.values[3], 1.0);
|
||||
entry.set_slider_position(3, 0.6);
|
||||
assert_eq!(entry.values[3], f64::from(0.6_f32));
|
||||
|
||||
// The arrow keys: a step at a time, not round past the ends.
|
||||
entry.values[0] = 2.0;
|
||||
assert!(!entry.nudge_option(0, false));
|
||||
assert_eq!(entry.values[0], 2.0);
|
||||
assert!(entry.nudge_option(0, true));
|
||||
assert_eq!(entry.values[0], f64::from(1.95_f32));
|
||||
entry.values[1] = 0.0;
|
||||
assert!(!entry.nudge_option(1, true));
|
||||
assert!(entry.nudge_option(1, false));
|
||||
assert_eq!(entry.values[1], 1.0);
|
||||
assert!(!entry.nudge_option(2, false));
|
||||
}
|
||||
}
|
||||
@@ -68,27 +68,48 @@ fn cpu_if_enabled() -> Option<Duration> {
|
||||
/// Run `f`, recording its duration under `name` when spans are enabled.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _span = guard(name);
|
||||
f()
|
||||
}
|
||||
/// A span that ends where it is dropped: [`time`] for a step with several ways out (early
|
||||
/// returns). Nothing is read while spans are off.
|
||||
#[must_use = "the span ends when the guard is dropped"]
|
||||
pub struct Guard {
|
||||
open: Option<(&'static str, Instant, Option<Duration>, u8)>,
|
||||
_heap: Option<horizon_threads::heap_census::Tag>,
|
||||
}
|
||||
/// Start a span named `name` that ends when the returned [`Guard`] is dropped.
|
||||
#[inline]
|
||||
pub fn guard(name: &'static str) -> Guard {
|
||||
let _heap = horizon_threads::heap_tag(name);
|
||||
if !recording_here() {
|
||||
return f();
|
||||
return Guard { open: None, _heap };
|
||||
}
|
||||
let depth = DEPTH.with(|d| d.replace(d.get().saturating_add(1)));
|
||||
let (started, cpu) = (Instant::now(), cpu_if_enabled());
|
||||
let out = f();
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
out
|
||||
Guard {
|
||||
open: Some((name, Instant::now(), cpu_if_enabled(), depth)),
|
||||
_heap,
|
||||
}
|
||||
}
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
let Some((name, started, cpu, depth)) = self.open.take() else {
|
||||
return;
|
||||
};
|
||||
let ms = started.elapsed().as_secs_f32() * 1000.0;
|
||||
let cpu_ms = cpu
|
||||
.and_then(|start| Some((thread_cpu()?, start)))
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
DEPTH.with(|d| d.set(depth));
|
||||
SPANS.with(|s| {
|
||||
s.borrow_mut().push(Span {
|
||||
name,
|
||||
ms,
|
||||
cpu_ms,
|
||||
depth,
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
/// Notes that one step inside a span took `ms` milliseconds doing `what` (one player's
|
||||
/// packets, say), when this thread records spans and the step took at least [`NOTE_MS`]: a
|
||||
@@ -551,6 +572,42 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A guarded span ends on every way out of its step, an early return's too, and holds the
|
||||
/// spans that ran inside it until then; the next span outside it is back at its depth.
|
||||
#[test]
|
||||
fn a_guarded_span_ends_where_its_step_returns() {
|
||||
fn step(early: bool) -> u32 {
|
||||
let _span = super::guard("use_block");
|
||||
super::time("open_loot", || ());
|
||||
if early {
|
||||
return 1;
|
||||
}
|
||||
super::time("open_maps", || ());
|
||||
2
|
||||
}
|
||||
std::thread::spawn(|| {
|
||||
super::record_here(true);
|
||||
assert_eq!(step(true), 1);
|
||||
assert_eq!(step(false), 2);
|
||||
super::time("use_item", || ());
|
||||
let raw = super::take_raw();
|
||||
let seen: Vec<_> = raw.iter().map(|s| (s.name, s.depth)).collect();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
("open_loot", 1),
|
||||
("use_block", 0),
|
||||
("open_loot", 1),
|
||||
("open_maps", 1),
|
||||
("use_block", 0),
|
||||
("use_item", 0)
|
||||
]
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// A slow frame is put down to the slowest outer span, then to the span inside it that took
|
||||
/// at least half of it; repeated spans (several ticks a frame) add up.
|
||||
#[test]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,9 @@ pub struct Tree {
|
||||
/// those naming none in `recipes_any`.
|
||||
by_recipe: HashMap<String, Vec<(usize, usize)>>,
|
||||
recipes_any: Vec<(usize, usize)>,
|
||||
/// The triggers with a criterion that may test what the player looks at
|
||||
/// (`index_looking_at`).
|
||||
looking_at_triggers: BTreeSet<String>,
|
||||
}
|
||||
fn frame(v: &Value) -> Frame {
|
||||
match v.as_str() {
|
||||
@@ -141,6 +144,7 @@ impl Tree {
|
||||
}
|
||||
}
|
||||
tree.index_recipes();
|
||||
tree.index_looking_at();
|
||||
tree
|
||||
}
|
||||
/// Index the `recipe_unlocked` criteria by recipe: a grant that unlocks
|
||||
@@ -159,13 +163,30 @@ impl Tree {
|
||||
match self.list[ai].criteria[ci].conditions.read()["recipes"].as_str() {
|
||||
Some(r) => self
|
||||
.by_recipe
|
||||
.entry(r.to_owned())
|
||||
.entry(full(r).into_owned())
|
||||
.or_default()
|
||||
.push((ai, ci)),
|
||||
None => self.recipes_any.push((ai, ci)),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Index the triggers whose criteria name `looking_at`, or a
|
||||
/// `minecraft:reference` to a predicate file that might: the text is
|
||||
/// searched, nothing is read.
|
||||
pub fn index_looking_at(&mut self) {
|
||||
let reference = ["\"minecraft:reference\"", "\"reference\""];
|
||||
self.looking_at_triggers = (self.list.iter().flat_map(|a| &a.criteria))
|
||||
.filter(|c| {
|
||||
let json = c.conditions.json();
|
||||
json.contains("\"looking_at\"") || reference.iter().any(|r| json.contains(r))
|
||||
})
|
||||
.map(|c| c.trigger.clone())
|
||||
.collect();
|
||||
}
|
||||
/// Whether a criterion of `trigger` may test what the player looks at.
|
||||
pub fn may_look(&self, trigger: &str) -> bool {
|
||||
self.looking_at_triggers.contains(trigger)
|
||||
}
|
||||
/// The `recipe_unlocked` criteria that could pass for `recipe`, in tree
|
||||
/// order (the others name another recipe).
|
||||
pub fn recipe_criteria(&self, recipe: &str) -> Vec<(usize, usize)> {
|
||||
@@ -198,8 +219,8 @@ impl InventoryIndex {
|
||||
fn ids<'t>(v: &Value, tag: &impl Fn(&str) -> Vec<&'t str>, out: &mut Vec<String>) {
|
||||
match v {
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
Some(t) => out.extend(tag(t).into_iter().map(str::to_owned)),
|
||||
None => out.push(s.clone()),
|
||||
Some(t) => out.extend(tag(&full(t)).into_iter().map(str::to_owned)),
|
||||
None => out.push(full(s).into_owned()),
|
||||
},
|
||||
Value::Array(a) => a.iter().for_each(|x| ids(x, tag, out)),
|
||||
_ => {}
|
||||
@@ -253,7 +274,9 @@ fn parse(id: &str, v: &Value) -> Option<Advancement> {
|
||||
.iter()
|
||||
.map(|(name, c)| Criterion {
|
||||
name: name.clone(),
|
||||
trigger: c["trigger"].as_str().unwrap_or("").to_owned(),
|
||||
trigger: c["trigger"]
|
||||
.as_str()
|
||||
.map_or(String::new(), |t| full(t).into_owned()),
|
||||
conditions: LazyJson::new(&c["conditions"]),
|
||||
})
|
||||
.collect();
|
||||
@@ -500,13 +523,21 @@ fn range_f(v: &Value, x: f64) -> bool {
|
||||
pub fn range_i(v: &Value, x: i64) -> bool {
|
||||
range_f(v, x as f64)
|
||||
}
|
||||
/// `Identifier.parse`: an id written without a namespace is a `minecraft:` one.
|
||||
pub fn full(id: &str) -> std::borrow::Cow<'_, str> {
|
||||
if id.contains(':') {
|
||||
id.into()
|
||||
} else {
|
||||
format!("minecraft:{id}").into()
|
||||
}
|
||||
}
|
||||
/// A registry id, `#tag`, or list matched against one id.
|
||||
pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool {
|
||||
match v {
|
||||
Value::Null => true,
|
||||
Value::String(s) => match s.strip_prefix('#') {
|
||||
Some(tag) => w.in_tag(registry, tag, id),
|
||||
None => s == id,
|
||||
Some(tag) => w.in_tag(registry, &full(tag), id),
|
||||
None => full(s) == id,
|
||||
},
|
||||
Value::Array(a) => a.iter().any(|x| id_matches(x, registry, id, w)),
|
||||
_ => false,
|
||||
@@ -538,12 +569,14 @@ pub fn potions(v: &Value, potion: Option<&str>, w: &dyn World) -> bool {
|
||||
// `MobEffectsPredicate.test`: the one listed effect, amplifier and duration.
|
||||
let test = |pred: &Value, e: &(String, i32, i32)| {
|
||||
pred.as_object().is_some_and(|m| {
|
||||
m.get(&e.0).is_some_and(|want| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
m.iter()
|
||||
.find(|(k, _)| full(k) == e.0)
|
||||
.is_some_and(|(_, want)| {
|
||||
range_i(&want["amplifier"], i64::from(e.1))
|
||||
&& range_i(&want["duration"], i64::from(e.2))
|
||||
&& want["ambient"].as_bool().is_none_or(|b| !b)
|
||||
&& want["visible"].as_bool().is_none_or(|b| b)
|
||||
})
|
||||
})
|
||||
};
|
||||
c["contains"]
|
||||
@@ -654,7 +687,7 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
return false;
|
||||
}
|
||||
if let Some(d) = v["dimension"].as_str()
|
||||
&& d != w.dimension()
|
||||
&& full(d) != w.dimension()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -699,13 +732,6 @@ pub fn location(v: &Value, p: [f64; 3], w: &dyn World) -> bool {
|
||||
/// `DataComponentExactPredicate.test` on an entity: every expected
|
||||
/// component equals what the entity reports (absent ones never match).
|
||||
fn components(expected: &Value, have: &[(String, String)]) -> bool {
|
||||
let full = |id: &str| {
|
||||
if id.contains(':') {
|
||||
id.to_owned()
|
||||
} else {
|
||||
format!("minecraft:{id}")
|
||||
}
|
||||
};
|
||||
expected.as_object().is_some_and(|o| {
|
||||
o.iter().all(|(id, want)| {
|
||||
let id = full(id);
|
||||
@@ -737,14 +763,15 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
return false;
|
||||
};
|
||||
for (key, p) in o {
|
||||
let ok = match key.as_str() {
|
||||
"minecraft:entity_type" | "type" => id_matches(p, "entity_type", &e.kind, w),
|
||||
"minecraft:location" | "location" => location(p, e.position, w),
|
||||
// `EntityPredicate.CODEC`: the keys are sub-predicate type ids.
|
||||
let ok = match &*full(key) {
|
||||
"minecraft:entity_type" => id_matches(p, "entity_type", &e.kind, w),
|
||||
"minecraft:location" => location(p, e.position, w),
|
||||
"minecraft:stepping_on" => {
|
||||
location(p, [e.position[0], e.position[1] - 0.5, e.position[2]], w)
|
||||
}
|
||||
"minecraft:distance" | "distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" | "flags" => {
|
||||
"minecraft:distance" => distance(p, e.position, origin),
|
||||
"minecraft:flags" => {
|
||||
let f = |k: &str, have: bool| p[k].as_bool().is_none_or(|want| want == have);
|
||||
f("is_on_fire", e.on_fire)
|
||||
&& f("is_sneaking", e.sneaking)
|
||||
@@ -754,7 +781,7 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
&& f("is_baby", e.baby)
|
||||
&& f("is_on_ground", e.on_ground)
|
||||
}
|
||||
"minecraft:equipment" | "equipment" => {
|
||||
"minecraft:equipment" => {
|
||||
let slots = [
|
||||
"mainhand", "offhand", "head", "chest", "legs", "feet", "body",
|
||||
];
|
||||
@@ -765,19 +792,19 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
|
||||
.is_some_and(|it| item(&p[*s], it, w))
|
||||
})
|
||||
}
|
||||
"minecraft:vehicle" | "vehicle" => e
|
||||
"minecraft:vehicle" => e
|
||||
.vehicle
|
||||
.as_ref()
|
||||
.is_some_and(|veh| entity(p, veh, w, origin)),
|
||||
// PassengerPredicate.matches: any passenger matches the nested predicate.
|
||||
"minecraft:passenger" | "passenger" => e
|
||||
"minecraft:passenger" => e
|
||||
.passengers
|
||||
.iter()
|
||||
.any(|passenger| entity(p, passenger, w, origin)),
|
||||
"minecraft:effects" | "effects" => p.as_object().is_some_and(|m| {
|
||||
"minecraft:effects" => p.as_object().is_some_and(|m| {
|
||||
m.iter().all(|(id, want)| {
|
||||
e.effects.iter().any(|(eid, amp, dur)| {
|
||||
eid == id
|
||||
*eid == full(id)
|
||||
&& range_i(&want["amplifier"], i64::from(*amp))
|
||||
&& range_i(&want["duration"], i64::from(*dur))
|
||||
})
|
||||
@@ -899,9 +926,8 @@ pub fn damage_source(ty: &Value, d: &DamageView, w: &dyn World, origin: [f64; 3]
|
||||
return true;
|
||||
}
|
||||
let tags_ok = ty["tags"].as_array().into_iter().flatten().all(|t| {
|
||||
let tag = t["id"].as_str().unwrap_or("");
|
||||
let tag = tag.strip_prefix('#').unwrap_or(tag);
|
||||
w.in_tag("damage_type", tag, &d.kind) == t["expected"].as_bool().unwrap_or(true)
|
||||
// `TagPredicate`: a `HolderSet`, like `id_matches`.
|
||||
id_matches(&t["id"], "damage_type", &d.kind, w) == t["expected"].as_bool().unwrap_or(true)
|
||||
});
|
||||
let direct_ok = ty["direct_entity"].is_null()
|
||||
|| d.direct
|
||||
@@ -970,7 +996,7 @@ pub fn conditions(v: &Value, ctx: &Entities, w: &dyn World) -> bool {
|
||||
.or_else(|| o.get("type"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("");
|
||||
match ty {
|
||||
match &*full(ty) {
|
||||
"minecraft:entity_properties" => ctx
|
||||
.this
|
||||
.is_some_and(|e| entity(&v["predicate"], e, w, ctx.origin)),
|
||||
@@ -1702,6 +1728,170 @@ mod tests {
|
||||
};
|
||||
assert!(!conditions(&stone, &absent, &Flat));
|
||||
}
|
||||
/// Data pack ids, tags and registry keys written without a namespace are
|
||||
/// `minecraft:` ones (`Identifier.parse`), wherever a predicate reads one.
|
||||
#[test]
|
||||
fn bare_ids_and_tags_mean_minecraft() {
|
||||
let w = Flat;
|
||||
let player = EntityView {
|
||||
kind: "minecraft:player".into(),
|
||||
position: [0.5, 64.0, 0.5],
|
||||
effects: vec![("minecraft:speed".into(), 0, 100)],
|
||||
..Default::default()
|
||||
};
|
||||
let stone = ItemView {
|
||||
id: "minecraft:cobblestone".into(),
|
||||
count: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let ctx = Entities {
|
||||
this: Some(&player),
|
||||
origin: player.position,
|
||||
tool: None,
|
||||
block_pos: None,
|
||||
};
|
||||
let arrow = DamageView {
|
||||
kind: "minecraft:arrow".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let o = [0.0; 3];
|
||||
let j = |s: &str| serde_json::from_str::<Value>(s).unwrap();
|
||||
let cases = [
|
||||
(
|
||||
"item id",
|
||||
id_matches(&j(r#""cobblestone""#), "item", &stone.id, &w),
|
||||
),
|
||||
(
|
||||
"item tag",
|
||||
id_matches(&j(r##""#stone_tool_materials""##), "item", &stone.id, &w),
|
||||
),
|
||||
(
|
||||
"item list",
|
||||
id_matches(&j(r#"["dirt", "cobblestone"]"#), "item", &stone.id, &w),
|
||||
),
|
||||
(
|
||||
"item predicate",
|
||||
item(&j(r#"{"items": "cobblestone"}"#), &stone, &w),
|
||||
),
|
||||
(
|
||||
"dimension",
|
||||
location(&j(r#"{"dimension": "overworld"}"#), player.position, &w),
|
||||
),
|
||||
(
|
||||
"biome",
|
||||
location(&j(r#"{"biomes": "plains"}"#), player.position, &w),
|
||||
),
|
||||
(
|
||||
"entity type",
|
||||
entity(&j(r#"{"minecraft:entity_type": "player"}"#), &player, &w, o),
|
||||
),
|
||||
(
|
||||
"sub-predicate key",
|
||||
entity(&j(r#"{"entity_type": "minecraft:player"}"#), &player, &w, o),
|
||||
),
|
||||
(
|
||||
"effect key",
|
||||
entity(
|
||||
&j(r#"{"minecraft:effects": {"speed": {}}}"#),
|
||||
&player,
|
||||
&w,
|
||||
o,
|
||||
),
|
||||
),
|
||||
(
|
||||
"damage tag",
|
||||
damage_source(
|
||||
&j(r##"{"tags": [{"id": "#is_projectile", "expected": true}]}"##),
|
||||
&arrow,
|
||||
&w,
|
||||
o,
|
||||
),
|
||||
),
|
||||
(
|
||||
"condition type",
|
||||
conditions(
|
||||
&j(r#"{"type": "entity_properties", "entity": "this",
|
||||
"predicate": {"minecraft:entity_type": "minecraft:player"}}"#),
|
||||
&ctx,
|
||||
&w,
|
||||
),
|
||||
),
|
||||
// `TagPredicate.id` is a holder set: one damage type is that type.
|
||||
(
|
||||
"damage type",
|
||||
damage_source(&j(r#"{"tags": [{"id": "arrow"}]}"#), &arrow, &w, o),
|
||||
),
|
||||
// Near misses stay misses.
|
||||
(
|
||||
"other item",
|
||||
!id_matches(&j(r#"["dirt", "stone"]"#), "item", &stone.id, &w),
|
||||
),
|
||||
(
|
||||
"other dimension",
|
||||
!location(&j(r#"{"dimension": "the_nether"}"#), player.position, &w),
|
||||
),
|
||||
(
|
||||
"other effect",
|
||||
!entity(&j(r#"{"effects": {"slowness": {}}}"#), &player, &w, o),
|
||||
),
|
||||
(
|
||||
"other damage type",
|
||||
!damage_source(&j(r#"{"tags": [{"id": "fireball"}]}"#), &arrow, &w, o),
|
||||
),
|
||||
(
|
||||
"the type key is no sub-predicate",
|
||||
!entity(&j(r#"{"type": "player"}"#), &player, &w, o),
|
||||
),
|
||||
];
|
||||
let failed: Vec<&str> = cases.iter().filter(|c| !c.1).map(|c| c.0).collect();
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"bare ids not read as minecraft: {failed:?}"
|
||||
);
|
||||
}
|
||||
/// A bare trigger, item or recipe id reaches the indexes as `minecraft:`.
|
||||
#[test]
|
||||
fn bare_ids_reach_the_trigger_and_item_indexes() {
|
||||
let a = parse(
|
||||
"test:a",
|
||||
&serde_json::json!({"criteria": {
|
||||
"items": {"trigger": "inventory_changed",
|
||||
"conditions": {"items": [{"items": ["cobblestone", "#planks"]}]}},
|
||||
"recipe": {"trigger": "recipe_unlocked", "conditions": {"recipes": "stick"}}
|
||||
}}),
|
||||
)
|
||||
.unwrap();
|
||||
let mut tree = Tree::default();
|
||||
for (ci, c) in a.criteria.iter().enumerate() {
|
||||
tree.by_trigger
|
||||
.entry(c.trigger.clone())
|
||||
.or_default()
|
||||
.push((0, ci));
|
||||
}
|
||||
tree.list.push(a);
|
||||
tree.index_recipes();
|
||||
let index = InventoryIndex::build(&tree, |tag| match tag {
|
||||
"minecraft:planks" => vec!["minecraft:oak_planks"],
|
||||
_ => Vec::new(),
|
||||
});
|
||||
let mut failed = Vec::new();
|
||||
if !tree.by_trigger.contains_key("minecraft:inventory_changed") {
|
||||
failed.push("trigger");
|
||||
}
|
||||
if index.candidates(["minecraft:cobblestone"]).is_empty() {
|
||||
failed.push("item index id");
|
||||
}
|
||||
if index.candidates(["minecraft:oak_planks"]).is_empty() {
|
||||
failed.push("item index tag");
|
||||
}
|
||||
if tree.recipe_criteria("minecraft:stick").is_empty() {
|
||||
failed.push("recipe index");
|
||||
}
|
||||
assert!(
|
||||
failed.is_empty(),
|
||||
"bare ids not read as minecraft: {failed:?}"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn predicates_match_tags_ranges_and_locations() {
|
||||
let w = Flat;
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn native_ingredient(
|
||||
/// stack being matched, `accept` already true (the load-time list named
|
||||
/// the item). Set `accept` to false to refuse the stack — its components
|
||||
/// are the usual reason. The hook may be called many times a craft; the
|
||||
/// answer must be the same for the same stack. API @API:ingredient_test@.
|
||||
/// answer must be the same for the same stack. API 377.
|
||||
#[inline(never)]
|
||||
pub fn native_ingredient_test(
|
||||
kind: &str,
|
||||
|
||||
@@ -941,6 +941,13 @@ pub fn merge_with_neighbours(
|
||||
}
|
||||
}
|
||||
}
|
||||
/// `ExperienceOrbRenderer.submit`'s billboard before its 0.3 scale, in the
|
||||
/// order of its four `vertex` calls (bottom left, bottom right, top right, top
|
||||
/// left). The quad is a whole block tall about the orb's centre and hangs a
|
||||
/// quarter of it below.
|
||||
pub const ORB_QUAD: [[f32; 2]; 4] = [[-0.5, -0.25], [0.5, -0.25], [0.5, 0.75], [-0.5, 0.75]];
|
||||
/// `poseStack.scale(0.3F, 0.3F, 0.3F)`.
|
||||
pub const ORB_QUAD_SCALE: f32 = 0.3;
|
||||
/// ExperienceOrb: gravity 0.03, homing towards the player within 8 blocks.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ExperienceOrb {
|
||||
@@ -1048,6 +1055,20 @@ impl ExperienceOrb {
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
/// `Entity.getLightProbePosition`: the block an entity is lit from is the
|
||||
/// one its eye is in. An orb's box is half a block, so its eye is
|
||||
/// `0.5 * 0.85` above its feet (`EntityDimensions.defaultEyeHeight`).
|
||||
pub fn light_probe_y(&self) -> f64 {
|
||||
self.position.y + 0.5 * 0.85
|
||||
}
|
||||
/// `ExperienceOrbRenderer.getBlockLightLevel` over `EntityRenderer`'s
|
||||
/// packing: the level's block light raised by 7 and clamped, the sky
|
||||
/// light unchanged. `light` is the level's packed light.
|
||||
pub fn render_light(&self, light: i32) -> u32 {
|
||||
let block = ((light & 15) + 7).clamp(0, 15);
|
||||
let sky = light >> 4;
|
||||
(u32::try_from(sky).unwrap_or(0) * 16) | ((u32::try_from(block).unwrap_or(15) * 16) << 8)
|
||||
}
|
||||
pub fn tick(
|
||||
&mut self,
|
||||
world: &World,
|
||||
@@ -3617,4 +3638,75 @@ mod item_destroyed_tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod render_light_tests {
|
||||
use super::*;
|
||||
|
||||
fn orb(y: f64) -> ExperienceOrb {
|
||||
ExperienceOrb {
|
||||
join: Default::default(),
|
||||
no_gravity: false,
|
||||
id: 1,
|
||||
position: Vec3::new(0.5, y, 0.5),
|
||||
previous: Vec3::new(0.5, y, 0.5),
|
||||
velocity: Vec3::new(0.0, 0.0, 0.0),
|
||||
value: 3,
|
||||
age: 0,
|
||||
on_ground: false,
|
||||
count: 1,
|
||||
ticks: 0,
|
||||
health: 5,
|
||||
}
|
||||
}
|
||||
|
||||
/// `ExperienceOrbRenderer.getBlockLightLevel`: the level's block light
|
||||
/// plus 7, clamped to 15; the sky light is the level's own. The orb used
|
||||
/// to be drawn at 10 in both nibbles, so an orb in a dark cave was washed
|
||||
/// out and one in daylight at block light 5 was too dim.
|
||||
#[test]
|
||||
fn an_orb_is_seven_brighter_than_the_block_it_sits_in() {
|
||||
let light = |sky: i32, block: i32| (sky << 4) | block;
|
||||
// Sky 15, block 0 (daylight): sky stays 15, block goes to 7.
|
||||
assert_eq!(
|
||||
orb(64.0).render_light(light(15, 0)),
|
||||
(15 * 16) | ((7 * 16) << 8)
|
||||
);
|
||||
// A dark cave: sky 0, block 0 -> block 7, and no invented sky light.
|
||||
assert_eq!(orb(20.0).render_light(light(0, 0)), (7 * 16) << 8);
|
||||
// Torchlight 9 -> 15, the clamp, not 16.
|
||||
assert_eq!(orb(20.0).render_light(light(0, 9)), (15 * 16) << 8);
|
||||
assert_eq!(
|
||||
orb(20.0).render_light(light(15, 15)),
|
||||
(15 * 16) | ((15 * 16) << 8)
|
||||
);
|
||||
}
|
||||
|
||||
/// `Entity.getLightProbePosition` is the eye position: an orb's box is
|
||||
/// half a block, so it reads the light of the cell 0.425 above its feet.
|
||||
#[test]
|
||||
fn an_orb_is_lit_from_the_block_its_eye_is_in() {
|
||||
assert_eq!(orb(64.6).light_probe_y().floor(), 65.0);
|
||||
assert_eq!(orb(64.6).position.y.floor(), 64.0);
|
||||
assert!((orb(64.0).light_probe_y() - 64.425).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// `ExperienceOrbRenderer.submit`'s quad: a block tall about the orb's
|
||||
/// centre, scaled by 0.3, so it spans -0.075 to +0.225 above the 0.1 the
|
||||
/// pose is translated by. Horizon drew a symmetric 0.3 square, a quarter
|
||||
/// of the sprite too low.
|
||||
#[test]
|
||||
fn the_orb_sprite_hangs_a_quarter_below_its_centre() {
|
||||
let off = ORB_QUAD.map(|[x, y]| [x * ORB_QUAD_SCALE, 0.1 + y * ORB_QUAD_SCALE]);
|
||||
let want = [
|
||||
[-0.15, 0.1 - 0.075],
|
||||
[0.15, 0.1 - 0.075],
|
||||
[0.15, 0.1 + 0.225],
|
||||
[-0.15, 0.1 + 0.225],
|
||||
];
|
||||
for (got, want) in off.into_iter().zip(want) {
|
||||
assert!((got[0] - want[0]).abs() < 1e-6, "{got:?} vs {want:?}");
|
||||
assert!((got[1] - want[1]).abs() < 1e-6, "{got:?} vs {want:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(ItemEntity, ExperienceOrb, ArmorStand);
|
||||
@@ -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())
|
||||
)])))
|
||||
|
||||
@@ -233,6 +233,7 @@ mod native_panel_recipes;
|
||||
mod native_projectiles;
|
||||
mod native_quick_move;
|
||||
mod native_recipes;
|
||||
mod native_reload_listeners;
|
||||
mod native_server;
|
||||
mod native_shaders;
|
||||
mod native_ticks;
|
||||
@@ -421,6 +422,7 @@ pub use native_falling::NativeFalling;
|
||||
pub use native_hooks::{NativeClock, NativeShake};
|
||||
pub use native_item_energy::ItemEnergyBuffer;
|
||||
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
|
||||
pub use native_reload_listeners::NativeReloadListener;
|
||||
pub use native_server::NativeExperienceOrb;
|
||||
pub use native_world_columns::NativeColumnBlocks;
|
||||
pub use native_world_edit::{NativeBlockEdit, NativeBlockRun, NativeHeightmap};
|
||||
@@ -2797,6 +2799,9 @@ struct Game<'a> {
|
||||
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
|
||||
locator_camera: Option<([f64; 3], f32, f32, f32)>,
|
||||
native_camera_frame: Option<horizon_client::mod_api::NativeCamera>,
|
||||
/// The yaw and pitch a native mod's camera drew the last frame from (`native_camera_pose`),
|
||||
/// none when it drew from vanilla's.
|
||||
posed_rotation: Option<(f32, f32)>,
|
||||
/// `LocalPlayer.postEffects`: the chains the server has the screen run
|
||||
/// (`ClientboundPostEffectsPacket`), in order.
|
||||
post_effects: Vec<String>,
|
||||
@@ -5774,21 +5779,25 @@ impl Game<'_> {
|
||||
// ShulkerBoxBlock.canOpen: the lid has no room; nothing opens.
|
||||
} else if let Some(entity) = BlockEntity::for_block(&name) {
|
||||
self.world.block_entities.get_or_insert_with(key, || entity);
|
||||
self.unpack_opened_loot(key, &name);
|
||||
horizon_client::spans::time("open_loot", || self.unpack_opened_loot(key, &name));
|
||||
let centre = [
|
||||
f64::from(key.0) + 0.5,
|
||||
f64::from(key.1) + 0.5,
|
||||
f64::from(key.2) + 0.5,
|
||||
];
|
||||
self.resolve_container_maps(key, centre);
|
||||
horizon_client::spans::time("open_maps", || {
|
||||
self.resolve_container_maps(key, centre);
|
||||
});
|
||||
self.container = Some(key);
|
||||
let first = self.container_opener_event(key, true);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_open",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
if first {
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_open",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
}
|
||||
let stat = match &*name {
|
||||
"minecraft:chest" => "open_chest",
|
||||
"minecraft:trapped_chest" => "trigger_trapped_chest",
|
||||
@@ -6428,9 +6437,13 @@ impl Game<'_> {
|
||||
let mut anchor_passed = false;
|
||||
// Native mods, where ServerPlayerGameMode.useItemOn starts: a
|
||||
// canceled click does nothing (a guest's block is sent back).
|
||||
// A slow tick's line names the phase of a use that took it (#8669): the mods' hook,
|
||||
// the block's own use, then the item's.
|
||||
if let Some(hit) = block_hit {
|
||||
let mut canceled = false;
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
horizon_client::spans::time("use_mods", || {
|
||||
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
|
||||
});
|
||||
if canceled {
|
||||
// Nothing was used, so nothing consumed the action.
|
||||
self.use_failed = true;
|
||||
@@ -6438,6 +6451,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// MultiPlayerGameMode.useItemOn: blocks react first.
|
||||
let block_phase = horizon_client::spans::guard("use_block");
|
||||
if !(sneaking && hands_full)
|
||||
&& let Some(hit) = block_hit
|
||||
{
|
||||
@@ -6620,6 +6634,8 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(block_phase);
|
||||
let _item_phase = horizon_client::spans::guard("use_item");
|
||||
let Some(stack) = self.held().cloned() else {
|
||||
return Ok(false);
|
||||
};
|
||||
@@ -9463,13 +9479,13 @@ impl Game<'_> {
|
||||
// A cart's or chest boat's items go back to it.
|
||||
self.close_cart_container(key);
|
||||
let last = self.container_opener_event(key, false);
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
if last {
|
||||
self.post_block_game_event(
|
||||
"minecraft:container_close",
|
||||
[key.0, key.1, key.2],
|
||||
Some(horizon_client::mobs::Who::Player),
|
||||
None,
|
||||
);
|
||||
self.container_edge_sound(key, false);
|
||||
}
|
||||
self.world.modified.insert((key.0 >> 4, key.2 >> 4));
|
||||
@@ -10855,7 +10871,11 @@ impl Game<'_> {
|
||||
/// `Camera.setRotation`: the camera's orientation, turned round in
|
||||
/// third-person front view.
|
||||
pub(super) fn camera_orientation(&self) -> glam::Quat {
|
||||
camera_rotation(self.camera_type, self.yaw, self.pitch)
|
||||
match self.posed_rotation {
|
||||
// The frame's camera, already turned round in front view.
|
||||
Some((yaw, pitch)) => camera_rotation(perspective::CameraType::FirstPerson, yaw, pitch),
|
||||
None => camera_rotation(self.camera_type, self.yaw, self.pitch),
|
||||
}
|
||||
}
|
||||
/// The entity whose geometry starts at vertex `start` (0: none), for frame
|
||||
/// generation to follow its own motion. Each run lasts until the next mark.
|
||||
@@ -11304,20 +11324,26 @@ impl Game<'_> {
|
||||
let red = ((t.sin() + 1.0) * 0.5 * 255.0) as u32;
|
||||
let blue = (((t + 4.188_79).sin() + 1.0) * 0.1 * 255.0) as u32;
|
||||
let color = 0x8000_0000 | blue << 16 | 255 << 8 | red;
|
||||
// `ExperienceOrbRenderer.getBlockLightLevel` (the block light
|
||||
// plus 7, clamped) at the block the orb's eye is in.
|
||||
let light = self.world.light(
|
||||
o.position.x.floor() as i32,
|
||||
o.position.y.floor() as i32,
|
||||
o.light_probe_y().floor() as i32,
|
||||
o.position.z.floor() as i32,
|
||||
);
|
||||
let packed = (u32::from(light >> 4).max(10) * 16)
|
||||
| ((u32::from(light & 15).max(10) * 16) << 8);
|
||||
let packed = o.render_light(i32::from(light));
|
||||
let c = p.as_vec3();
|
||||
let s = 0.15;
|
||||
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
|
||||
.iter()
|
||||
.zip([[u0, v0 + dv], [u0 + du, v0 + dv], [u0 + du, v0], [u0, v0]])
|
||||
{
|
||||
let v = c + right * (k.0 * s) + up * (k.1 * s);
|
||||
// `ExperienceOrbRenderer.submit`: the quad runs from
|
||||
// (-0.5, -0.25) to (0.5, 0.75) and is scaled by 0.3, so the
|
||||
// sprite hangs a quarter of its height below the orb's centre.
|
||||
let s = horizon_client::entities::ORB_QUAD_SCALE;
|
||||
for (k, uv) in horizon_client::entities::ORB_QUAD.iter().zip([
|
||||
[u0, v0 + dv],
|
||||
[u0 + du, v0 + dv],
|
||||
[u0 + du, v0],
|
||||
[u0, v0],
|
||||
]) {
|
||||
let v = c + right * (k[0] * s) + up * (k[1] * s);
|
||||
out.push(Vertex {
|
||||
normal: [0; 4],
|
||||
pos: v.to_array(),
|
||||
@@ -12291,8 +12317,9 @@ impl Game<'_> {
|
||||
&self.options.resources,
|
||||
&pack_files(&self.world_tables),
|
||||
);
|
||||
// TagsUpdatedEvent (SERVER_DATA_LOAD): the world's data is in, before
|
||||
// its chunks and players are.
|
||||
// The mods' own data folders, then TagsUpdatedEvent (SERVER_DATA_LOAD):
|
||||
// the world's data is in, before its chunks and players are.
|
||||
self.native_reload_listeners();
|
||||
self.native_server_data_loaded(false);
|
||||
self.sync_recipe_content();
|
||||
self.bosses = Default::default();
|
||||
@@ -12868,11 +12895,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;
|
||||
@@ -13991,9 +14021,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
|
||||
@@ -14005,7 +14038,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
|
||||
@@ -14097,20 +14130,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,
|
||||
@@ -14221,6 +14258,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,
|
||||
@@ -14239,7 +14277,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.
|
||||
@@ -14288,6 +14326,10 @@ impl Game<'_> {
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)?;
|
||||
renderer.screenshot = screenshot;
|
||||
renderer.screenshot_result = screenshot_result;
|
||||
@@ -14325,6 +14367,10 @@ impl Game<'_> {
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
}
|
||||
fn apply_settings(&mut self) {
|
||||
@@ -15650,7 +15696,18 @@ impl Game<'_> {
|
||||
// Camera.getCameraEntityPartialTicks: the view, the hand and the
|
||||
// player's own model stay smooth while the level is frozen.
|
||||
let camera_alpha = self.clock.camera_fraction();
|
||||
let view = self.camera_view(camera_alpha as f32);
|
||||
let vanilla = self.camera_view(camera_alpha as f32);
|
||||
let view = vanilla.map(|view| {
|
||||
if self.shows_panorama() {
|
||||
view
|
||||
} else {
|
||||
self.native_camera_pose_now(camera_alpha as f32, view)
|
||||
}
|
||||
});
|
||||
// What faces the camera (particles, item sprites, block entity text) faces a mod's.
|
||||
self.posed_rotation = view
|
||||
.filter(|posed| Some(*posed) != vanilla)
|
||||
.map(|(_, yaw, pitch)| (yaw, pitch));
|
||||
let eye = view.map(|_| {
|
||||
(
|
||||
glam::DVec3::from_array(self.view.eye),
|
||||
@@ -15963,8 +16020,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)
|
||||
};
|
||||
@@ -16121,6 +16178,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,
|
||||
@@ -19090,6 +19148,7 @@ impl<'a> Game<'a> {
|
||||
waypoints: Default::default(),
|
||||
locator_camera: None,
|
||||
native_camera_frame: None,
|
||||
posed_rotation: None,
|
||||
post_effects: Vec::new(),
|
||||
spectated_post_effect: None,
|
||||
experience_display: Default::default(),
|
||||
@@ -19724,6 +19783,7 @@ impl Game<'_> {
|
||||
&overlay,
|
||||
));
|
||||
self.adv.reset_tree();
|
||||
self.native_reload_listeners();
|
||||
self.native_server_data_loaded(true);
|
||||
}
|
||||
|
||||
@@ -20082,7 +20142,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`.
|
||||
@@ -20538,6 +20598,7 @@ fn build_renderer(
|
||||
// offered (`Renderer::new`).
|
||||
wide_frame: bool,
|
||||
shader_pack: bool,
|
||||
filtering: (i32, i32),
|
||||
) -> Result<Renderer> {
|
||||
let assets = &pack.render;
|
||||
let atlas = &gui.atlas;
|
||||
@@ -20585,6 +20646,8 @@ fn build_renderer(
|
||||
renderer
|
||||
.hud
|
||||
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
|
||||
pack.item_renderer
|
||||
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
|
||||
renderer.set_lines_shader(atlas.lines_shader.as_deref());
|
||||
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
|
||||
renderer
|
||||
@@ -20731,6 +20794,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
|
||||
celestial: &[],
|
||||
stars: None,
|
||||
hand: &[],
|
||||
hand_items: &[],
|
||||
hand_fov_degrees: 70.0,
|
||||
screen: &[],
|
||||
pack: Default::default(),
|
||||
@@ -21227,6 +21291,10 @@ impl Loading {
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
@@ -21689,6 +21757,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
|
||||
@@ -21926,6 +22011,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));
|
||||
|
||||
@@ -440,6 +440,16 @@ impl Game<'_> {
|
||||
)
|
||||
}
|
||||
pub(super) fn player_entity(&self) -> EntityView {
|
||||
self.player_entity_looking(true)
|
||||
}
|
||||
/// [`Self::player_entity`] for `trigger`'s criteria: what the player
|
||||
/// looks at only when one of them may ask (a ray against every entity
|
||||
/// each tick otherwise).
|
||||
fn player_entity_for(&self, trigger: &str) -> EntityView {
|
||||
self.player_entity_looking(self.adv.tree.may_look(trigger))
|
||||
}
|
||||
/// The player as predicates see it, with what it looks at when `looking`.
|
||||
fn player_entity_looking(&self, looking: bool) -> EntityView {
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return EntityView::default();
|
||||
};
|
||||
@@ -486,6 +496,11 @@ impl Game<'_> {
|
||||
})
|
||||
.collect(),
|
||||
gamemode: Some(self.mode.name().to_owned()),
|
||||
// Vanilla looks for it inside PlayerPredicate.matches whatever the
|
||||
// trigger; skipped for a tick or location whose criteria can't ask.
|
||||
looking_at: looking
|
||||
.then(|| self.looking_at_entity().map(Box::new))
|
||||
.flatten(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@@ -695,7 +710,9 @@ impl Game<'_> {
|
||||
for r in &allowed {
|
||||
let list = tree.recipe_criteria(r);
|
||||
self.trigger_criteria(&tree, &list, |c, _| {
|
||||
c["recipes"].as_str() == Some(r.as_str())
|
||||
c["recipes"]
|
||||
.as_str()
|
||||
.is_some_and(|id| adv::full(id) == r.as_str())
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -900,7 +917,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.check_inventory_trigger();
|
||||
// ServerPlayer.tick: PlayerTrigger.TICK every tick.
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:tick");
|
||||
self.trigger("minecraft:tick", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -934,7 +951,7 @@ impl Game<'_> {
|
||||
}
|
||||
self.tick_marks();
|
||||
if self.ticks.is_multiple_of(20) {
|
||||
let me = self.player_entity();
|
||||
let me = self.player_entity_for("minecraft:location");
|
||||
self.trigger("minecraft:location", |c, w| {
|
||||
let ctx = adv::Entities {
|
||||
this: Some(&me),
|
||||
@@ -1215,14 +1232,12 @@ impl Game<'_> {
|
||||
&& adv::range_i(&c["delta"], damage - new_damage)
|
||||
});
|
||||
}
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
|
||||
/// what the player looks at (`PlayerPredicate.lookingAt`).
|
||||
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use.
|
||||
pub(super) fn trigger_using_item(&mut self, stack: &ItemStack) {
|
||||
if self.remote.is_some() || self.no_player_here() {
|
||||
return;
|
||||
}
|
||||
let mut me = self.player_entity();
|
||||
me.looking_at = self.looking_at_entity().map(Box::new);
|
||||
let me = self.player_entity();
|
||||
let it = item_view(stack);
|
||||
self.trigger("minecraft:using_item", move |c, w| {
|
||||
let pctx = adv::Entities {
|
||||
@@ -1234,24 +1249,60 @@ impl Game<'_> {
|
||||
adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w)
|
||||
});
|
||||
}
|
||||
/// The mob a 100 block ray from the eyes meets first, if no block is in
|
||||
/// the way (`ProjectileUtil.getEntityHitResult` and `hasLineOfSight`).
|
||||
/// The entity a 100 block ray from the eyes meets first, if no block is
|
||||
/// in the way (`ProjectileUtil.getEntityHitResult` over every entity but
|
||||
/// the player that is not a spectator, then `hasLineOfSight`).
|
||||
fn looking_at_entity(&self) -> Option<EntityView> {
|
||||
use super::commands::Target;
|
||||
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
|
||||
use horizon_core::geometry::Aabb;
|
||||
let eye = self.eye_position()?;
|
||||
let dir = super::view_vector(self.yaw, self.pitch);
|
||||
let (m, _) = self
|
||||
.mobs
|
||||
.iter()
|
||||
.filter(|m| m.alive())
|
||||
.filter_map(|m| Some((m, super::ray_box(eye, dir, 100.0, m.bounds())?)))
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1))?;
|
||||
let mut best: Option<(Target, f64)> = None;
|
||||
let mut offer = |t: Option<Target>, b: Aabb| {
|
||||
if let Some(t) = t
|
||||
&& let Some(d) = super::ray_box(eye, dir, 100.0, b)
|
||||
&& best.is_none_or(|(_, bd)| d < bd)
|
||||
{
|
||||
best = Some((t, d));
|
||||
}
|
||||
};
|
||||
for_each_entity!(entity_lists_of!(self), e => {
|
||||
if !e.is_removed() {
|
||||
offer(e.selector_target(), e.bounds());
|
||||
}
|
||||
});
|
||||
let aabb = |(lo, hi): ([f64; 3], [f64; 3])| {
|
||||
Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
)
|
||||
};
|
||||
for f in self.item_frames.iter() {
|
||||
offer(Some(Target::Frame(f.id)), aabb(f.bounds()));
|
||||
}
|
||||
for p in self.paintings.iter() {
|
||||
offer(Some(Target::Painting(p.id)), aabb(p.bounds()));
|
||||
}
|
||||
for c in self.crystals.iter() {
|
||||
offer(Some(Target::Crystal(c.id)), c.bounds());
|
||||
}
|
||||
let (t, _) = best?;
|
||||
// `LivingEntity.hasLineOfSight`: eye to the target's eye height,
|
||||
// `ClipContext.Block.COLLIDER`, no fluids, within 128 blocks.
|
||||
let at = self.target_position(t)?;
|
||||
let from = Vec3::new(eye[0], eye[1], eye[2]);
|
||||
let to = Vec3::new(m.position.x, m.eye_y(), m.position.z);
|
||||
let to = Vec3::new(at[0], at[1] + self.target_eye_height(t), at[2]);
|
||||
let world = self.mob_world_base(&[]);
|
||||
let clear = to.distance(from) <= 128.0 && world.clear_between(from, to, false);
|
||||
clear.then(|| self.mob_entity(m))
|
||||
if !clear {
|
||||
return None;
|
||||
}
|
||||
Some(match t {
|
||||
Target::Mob(id) => self.mob_entity(self.mobs.iter().find(|m| m.id == id)?),
|
||||
Target::Boat(id) => self.boat_entity(self.boats.iter().find(|b| b.id == id)?),
|
||||
other => self.entity_view_of(other),
|
||||
})
|
||||
}
|
||||
/// `PlayerHurtEntityTrigger`: the player, the damage and the victim.
|
||||
pub(super) fn trigger_player_hurt_entity(
|
||||
@@ -1778,8 +1829,8 @@ impl Game<'_> {
|
||||
pub(super) fn trigger_dimension(&mut self, from: Dim, to: Dim, at: [f64; 3]) {
|
||||
let (from_id, to_id) = (from.id().to_owned(), to.id().to_owned());
|
||||
self.trigger("minecraft:changed_dimension", move |c, _| {
|
||||
c["from"].as_str().is_none_or(|f| f == from_id)
|
||||
&& c["to"].as_str().is_none_or(|t| t == to_id)
|
||||
c["from"].as_str().is_none_or(|f| adv::full(f) == from_id)
|
||||
&& c["to"].as_str().is_none_or(|t| adv::full(t) == to_id)
|
||||
});
|
||||
if from == Dim::Overworld && to == Dim::Nether {
|
||||
self.adv.marks.nether = Some(at);
|
||||
@@ -2141,6 +2192,39 @@ pub(super) fn text_of(v: &Value) -> Text {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn looking_at_triggers_are_the_ones_that_ask() {
|
||||
let criterion = |trigger: &str, conditions: serde_json::Value| adv::Criterion {
|
||||
name: "c".into(),
|
||||
trigger: trigger.into(),
|
||||
conditions: horizon_world::lazy_json::LazyJson::new(&conditions),
|
||||
};
|
||||
let looking = serde_json::json!({"player": {"type": "minecraft:entity_properties",
|
||||
"entity": "this", "predicate": {"minecraft:type_specific/player":
|
||||
{"looking_at": {"minecraft:entity_type": "minecraft:oak_boat"}}}}});
|
||||
let reference = serde_json::json!({"player": [{"condition": "minecraft:reference",
|
||||
"name": "pack:looks"}]});
|
||||
let plain = serde_json::json!({"player": {"type": "minecraft:entity_properties",
|
||||
"entity": "this", "predicate": {"minecraft:flags": {"is_sneaking": true}}}});
|
||||
let mut tree = Tree::default();
|
||||
tree.list.push(adv::Advancement {
|
||||
id: "pack:a".into(),
|
||||
parent: None,
|
||||
display: None,
|
||||
criteria: vec![
|
||||
criterion("minecraft:using_item", looking),
|
||||
criterion("minecraft:location", reference),
|
||||
criterion("minecraft:tick", plain),
|
||||
],
|
||||
requirements: Vec::new(),
|
||||
rewards: Default::default(),
|
||||
});
|
||||
tree.index_looking_at();
|
||||
assert!(tree.may_look("minecraft:using_item"));
|
||||
assert!(tree.may_look("minecraft:location"));
|
||||
assert!(!tree.may_look("minecraft:tick"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toast_labels_are_yellow_or_pink() {
|
||||
// The batch takes colours as `rgba`: 0xFFFF_FF00 there is cyan (#5850).
|
||||
|
||||
@@ -18,6 +18,12 @@ pub(super) const BOARDING_COOLDOWN: i32 = 60;
|
||||
/// feet are.
|
||||
const PLAYER_RIDING_OFFSET: f64 = 0.6;
|
||||
|
||||
/// `Entity.canRide` for a mob species: `Warden` and `WitherBoss` override it
|
||||
/// to return false, so no vehicle ever takes them aboard.
|
||||
pub(super) fn species_can_ride(species: Species) -> bool {
|
||||
!matches!(species, Species::Warden | Species::Wither)
|
||||
}
|
||||
|
||||
/// `Entity.isVehicle`: a player or a mob rides it.
|
||||
fn carries(m: &Mob) -> bool {
|
||||
m.rider.is_some() || m.extra.rider.is_some()
|
||||
@@ -251,7 +257,7 @@ impl Game<'_> {
|
||||
// boarding cooldown runs (Entity.canRide) or the boat
|
||||
// is under water, or when a mod cancels it
|
||||
// (EntityMountEvent).
|
||||
let ready = m.extra.boarding_cooldown <= 0;
|
||||
let ready = m.extra.boarding_cooldown <= 0 && species_can_ride(m.species());
|
||||
if room
|
||||
&& ready
|
||||
&& self.native_mob_may_ride(mob, id, true)
|
||||
@@ -447,6 +453,13 @@ mod tests {
|
||||
assert!(push_impulse(Vec3::ZERO, Vec3::new(0.005, 0.0, 0.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warden_and_wither_never_board() {
|
||||
assert!(!species_can_ride(Species::Warden));
|
||||
assert!(!species_can_ride(Species::Wither));
|
||||
assert!(species_can_ride(Species::Cow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boats_push_by_height() {
|
||||
let boat = Aabb::new(Vec3::new(0.0, 64.0, 0.0), Vec3::new(1.0, 64.5625, 1.0));
|
||||
|
||||
@@ -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);
|
||||
@@ -450,6 +450,51 @@ fn ids_in(root: &Path, extension: &str, namespace: &str) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `SuggestionProviders.SUMMONABLE_ENTITIES`: Entity types available for
|
||||
/// `/summon` autocomplete, filtering out types that cannot be summoned.
|
||||
pub(super) fn summonable_entities(
|
||||
entity_types: &[String],
|
||||
mob_types: impl IntoIterator<Item = impl AsRef<str>>,
|
||||
) -> Vec<String> {
|
||||
let mut summonable: Vec<String> = if !entity_types.is_empty() {
|
||||
entity_types
|
||||
.iter()
|
||||
.filter(|id| !matches!(id.as_str(), "minecraft:player" | "minecraft:fishing_bobber"))
|
||||
.cloned()
|
||||
.collect()
|
||||
} else {
|
||||
let mut list: Vec<String> = mob_types
|
||||
.into_iter()
|
||||
.map(|k| horizon_client::command::args::normalize(k.as_ref()))
|
||||
.filter(|id| !matches!(id.as_str(), "minecraft:player" | "minecraft:fishing_bobber"))
|
||||
.collect();
|
||||
list.extend(
|
||||
[
|
||||
"minecraft:item",
|
||||
"minecraft:experience_orb",
|
||||
"minecraft:falling_block",
|
||||
"minecraft:arrow",
|
||||
"minecraft:lightning_bolt",
|
||||
"minecraft:minecart",
|
||||
"minecraft:chest_minecart",
|
||||
"minecraft:hopper_minecart",
|
||||
"minecraft:furnace_minecart",
|
||||
"minecraft:tnt_minecart",
|
||||
"minecraft:wind_charge",
|
||||
"minecraft:breeze_wind_charge",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
list
|
||||
};
|
||||
// content mods' thrown types.
|
||||
let thrown = horizon_client::projectiles::thrown_types().iter();
|
||||
summonable.extend(thrown.map(|t| t.id.to_owned()));
|
||||
summonable.sort();
|
||||
summonable.dedup();
|
||||
summonable
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// The caches `build_registries` and `join_server` read (`resource_ids`,
|
||||
/// `block_properties`, `command_export`, `builtin_registries`), filled on a helper
|
||||
@@ -572,33 +617,7 @@ impl Game<'_> {
|
||||
.collect();
|
||||
reg.block_tags.sort();
|
||||
reg.blocks = BlockProperties::clone(&block_properties(self.pack.assets.registry));
|
||||
let mut summonable: Vec<String> = self
|
||||
.res
|
||||
.mob_types()
|
||||
.keys()
|
||||
.map(|k| horizon_client::command::args::normalize(k))
|
||||
.collect();
|
||||
summonable.extend(
|
||||
[
|
||||
"minecraft:item",
|
||||
"minecraft:experience_orb",
|
||||
"minecraft:falling_block",
|
||||
"minecraft:arrow",
|
||||
"minecraft:lightning_bolt",
|
||||
"minecraft:minecart",
|
||||
"minecraft:chest_minecart",
|
||||
"minecraft:hopper_minecart",
|
||||
"minecraft:furnace_minecart",
|
||||
"minecraft:tnt_minecart",
|
||||
]
|
||||
.map(String::from),
|
||||
);
|
||||
// content mods' thrown types.
|
||||
let thrown = horizon_client::projectiles::thrown_types().iter();
|
||||
summonable.extend(thrown.map(|t| t.id.to_owned()));
|
||||
summonable.sort();
|
||||
summonable.dedup();
|
||||
reg.summonable = summonable;
|
||||
reg.summonable = summonable_entities(&ids.entity_types, self.res.mob_types().keys());
|
||||
let mut types = ids.entity_types.clone();
|
||||
if types.is_empty() {
|
||||
types = reg.summonable.clone();
|
||||
@@ -1736,4 +1755,147 @@ mod tests {
|
||||
);
|
||||
assert_eq!(shared.nodes.len(), vanilla.nodes.len());
|
||||
}
|
||||
|
||||
/// When `entity_types` is empty (no `entity-types.json`), fallback includes
|
||||
/// standard mobs, non-mob entities, and `wind_charge` / `breeze_wind_charge`.
|
||||
#[test]
|
||||
fn summonable_entities_empty_fallback_includes_mobs_and_non_mobs() {
|
||||
let mobs = ["cow", "zombie", "skeleton"];
|
||||
let summonable = summonable_entities(&[], mobs);
|
||||
assert!(summonable.contains(&"minecraft:cow".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:zombie".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:skeleton".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:item".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:arrow".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:lightning_bolt".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:minecart".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:breeze_wind_charge".to_string()));
|
||||
}
|
||||
|
||||
/// Non-summonables `minecraft:player` and `minecraft:fishing_bobber` are
|
||||
/// never included in `reg.summonable`.
|
||||
#[test]
|
||||
fn summonable_entities_never_includes_player_or_fishing_bobber() {
|
||||
let all_types = vec![
|
||||
"minecraft:cow".to_string(),
|
||||
"minecraft:player".to_string(),
|
||||
"minecraft:fishing_bobber".to_string(),
|
||||
"minecraft:wind_charge".to_string(),
|
||||
];
|
||||
let summonable = summonable_entities(&all_types, &[] as &[&str]);
|
||||
assert!(!summonable.contains(&"minecraft:player".to_string()));
|
||||
assert!(!summonable.contains(&"minecraft:fishing_bobber".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:cow".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
|
||||
|
||||
let fallback = summonable_entities(&[], ["player", "fishing_bobber", "zombie"]);
|
||||
assert!(!fallback.contains(&"minecraft:player".to_string()));
|
||||
assert!(!fallback.contains(&"minecraft:fishing_bobber".to_string()));
|
||||
assert!(fallback.contains(&"minecraft:zombie".to_string()));
|
||||
}
|
||||
|
||||
/// `minecraft:wind_charge` and `minecraft:breeze_wind_charge` are in
|
||||
/// `reg.summonable`, and `/summon wind` or `/summon minecraft:wind_charg`
|
||||
/// suggest them via `Suggests::SummonableEntities` / `resource_matches`.
|
||||
#[test]
|
||||
fn summonable_entities_suggests_wind_charge_variants() {
|
||||
let all_types = vec![
|
||||
"minecraft:wind_charge".to_string(),
|
||||
"minecraft:breeze_wind_charge".to_string(),
|
||||
"minecraft:zombie".to_string(),
|
||||
];
|
||||
let summonable = summonable_entities(&all_types, &[] as &[&str]);
|
||||
assert!(summonable.contains(&"minecraft:wind_charge".to_string()));
|
||||
assert!(summonable.contains(&"minecraft:breeze_wind_charge".to_string()));
|
||||
|
||||
assert!(horizon_client::command::args::resource_matches(
|
||||
"wind",
|
||||
"minecraft:wind_charge"
|
||||
));
|
||||
assert!(horizon_client::command::args::resource_matches(
|
||||
"wind",
|
||||
"minecraft:breeze_wind_charge"
|
||||
));
|
||||
assert!(horizon_client::command::args::resource_matches(
|
||||
"minecraft:wind_charg",
|
||||
"minecraft:wind_charge"
|
||||
));
|
||||
assert!(!horizon_client::command::args::resource_matches(
|
||||
"minecraft:wind_charg",
|
||||
"minecraft:breeze_wind_charge"
|
||||
));
|
||||
|
||||
let reg = Registries {
|
||||
permission: 2,
|
||||
summonable,
|
||||
..Default::default()
|
||||
};
|
||||
let vanilla = horizon_client::command::vanilla::game_dispatcher();
|
||||
|
||||
let parse = vanilla.parse("summon wind", ®);
|
||||
let (_range, suggestions) = vanilla.client_suggestions(&parse, "summon wind".len(), ®);
|
||||
let texts: Vec<String> = suggestions.into_iter().map(|s| s.text).collect();
|
||||
assert!(texts.contains(&"minecraft:wind_charge".to_string()));
|
||||
assert!(texts.contains(&"minecraft:breeze_wind_charge".to_string()));
|
||||
|
||||
let parse = vanilla.parse("summon minecraft:wind_charg", ®);
|
||||
let (_range, suggestions) =
|
||||
vanilla.client_suggestions(&parse, "summon minecraft:wind_charg".len(), ®);
|
||||
let texts: Vec<String> = suggestions.into_iter().map(|s| s.text).collect();
|
||||
assert_eq!(texts, vec!["minecraft:wind_charge".to_string()]);
|
||||
}
|
||||
|
||||
/// `Registries::fill_from_export` and `fill_from_exported` populate
|
||||
/// `self.summonable` and `self.entity_types` when present in export JSON,
|
||||
/// and sort/dedup properly.
|
||||
#[test]
|
||||
fn export_propagation_populates_and_dedups_summonable_and_entity_types() {
|
||||
let export_json = serde_json::json!({
|
||||
"summonable": [
|
||||
"minecraft:zombie",
|
||||
"minecraft:arrow",
|
||||
"minecraft:zombie",
|
||||
"minecraft:wind_charge"
|
||||
],
|
||||
"entity_types": [
|
||||
"minecraft:zombie",
|
||||
"minecraft:player",
|
||||
"minecraft:arrow",
|
||||
"minecraft:zombie"
|
||||
]
|
||||
});
|
||||
let mut reg = Registries::default();
|
||||
reg.fill_from_export(&export_json);
|
||||
assert_eq!(
|
||||
reg.summonable,
|
||||
vec![
|
||||
"minecraft:arrow".to_string(),
|
||||
"minecraft:wind_charge".to_string(),
|
||||
"minecraft:zombie".to_string(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
reg.entity_types,
|
||||
vec![
|
||||
"minecraft:arrow".to_string(),
|
||||
"minecraft:player".to_string(),
|
||||
"minecraft:zombie".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let mut dest = Registries::default();
|
||||
dest.fill_from_exported(®);
|
||||
assert_eq!(dest.summonable, reg.summonable);
|
||||
assert_eq!(dest.entity_types, reg.entity_types);
|
||||
|
||||
let mut prefilled = Registries {
|
||||
summonable: vec!["minecraft:pig".to_string()],
|
||||
entity_types: vec!["minecraft:pig".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
prefilled.fill_from_exported(®);
|
||||
assert_eq!(prefilled.summonable, vec!["minecraft:pig".to_string()]);
|
||||
assert_eq!(prefilled.entity_types, vec!["minecraft:pig".to_string()]);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -1303,6 +1303,7 @@ impl Game<'_> {
|
||||
"culled_sections": r.culled_sections(),
|
||||
"gpu_culling": r.gpu_culling(),
|
||||
"gpu_frame": r.gpu_frame(),
|
||||
"gpu_fading": r.gpu_fading(),
|
||||
"uploaded_bytes": r.uploaded_bytes,
|
||||
"resident_sections": r.resident_sections(),
|
||||
"arena_bytes": arena_bytes,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -311,7 +311,7 @@ impl Game<'_> {
|
||||
swimming: p.swimming,
|
||||
look: view_vector(self.yaw, self.pitch),
|
||||
main_hand: self.held().map(|s| s.item.clone()),
|
||||
off_hand: None,
|
||||
off_hand: self.offhand.as_ref().map(|s| s.item.clone()),
|
||||
head: self.armor[0].as_ref().map(|s| s.item.clone()),
|
||||
armor_cover: self.armor.iter().filter(|a| a.is_some()).count() as f32 / 4.0,
|
||||
invisible: self.potions.effects.get("invisibility").is_some(),
|
||||
@@ -1321,7 +1321,7 @@ impl Game<'_> {
|
||||
self.for_player(who, |g| {
|
||||
// CatalystListener: trigger(player, killedEntity, damageSource).
|
||||
let view = g.mob_entity(m);
|
||||
let blow = m.last_damage.map(|(d, _)| g.damage_view(d));
|
||||
let blow = g.last_blow(m);
|
||||
g.trigger_killed_as(
|
||||
"minecraft:kill_mob_near_sculk_catalyst",
|
||||
&view,
|
||||
@@ -1413,9 +1413,24 @@ impl Game<'_> {
|
||||
self.award_custom_stat("mob_kills", 1);
|
||||
self.score_player_killed(super::commands::Target::Mob(m.id));
|
||||
let view = self.mob_entity(m);
|
||||
let blow = m.last_damage.map(|(d, _)| self.damage_view(d));
|
||||
let blow = self.last_blow(m);
|
||||
self.trigger_killed(&view, blow.as_ref());
|
||||
}
|
||||
/// The damage source a mob died to (`die`'s), its direct entity included when the mob
|
||||
/// recorded one ([`Mob::last_direct`]).
|
||||
fn last_blow(&self, m: &Mob) -> Option<horizon_client::advancements::DamageView> {
|
||||
let (d, _) = m.last_damage?;
|
||||
let mut view = self.damage_view(d);
|
||||
if let Some(kind) = m.last_direct {
|
||||
view.direct = Some(horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: d.from.unwrap_or_default(),
|
||||
..Default::default()
|
||||
});
|
||||
view.is_direct = false;
|
||||
}
|
||||
Some(view)
|
||||
}
|
||||
/// A mob's damage source as advancement predicates see it: its type, the
|
||||
/// causing entity, and the projectile when one dealt it.
|
||||
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
|
||||
@@ -4132,6 +4147,22 @@ impl Game<'_> {
|
||||
self.last_hurt_by_player.insert(id, self.lan_acting);
|
||||
}
|
||||
}
|
||||
/// `LivingEntity.hurtServer` for a snowball, egg or pearl (`kind`) that hurt the mob `id`,
|
||||
/// 0 damage included: the hit is its owner's, and a player's fires PLAYER_HURT_ENTITY.
|
||||
fn thrown_hurt_mob(&mut self, p: &Projectile, id: u32, kind: &str, amount: f32) {
|
||||
self.player_hurt_mob(p, id);
|
||||
if p.owner == Some(Who::Player)
|
||||
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
|
||||
{
|
||||
let victim = self.mob_entity(m);
|
||||
let direct = horizon_client::advancements::EntityView {
|
||||
kind: kind.into(),
|
||||
position: [p.position.x, p.position.y, p.position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.player_hit(&victim, "minecraft:thrown", Some(direct), amount);
|
||||
}
|
||||
}
|
||||
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
|
||||
/// as the host's when they have left.
|
||||
fn guest_projectile_hit(&mut self, conn: u32, p: &Projectile, hit: Hit) -> Result<()> {
|
||||
@@ -4853,7 +4884,9 @@ impl Game<'_> {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
if m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z]) {
|
||||
self.thrown_hurt_mob(p, id, "minecraft:snowball", amount);
|
||||
}
|
||||
} else if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
@@ -4912,7 +4945,9 @@ impl Game<'_> {
|
||||
} = hit
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
if m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]) {
|
||||
self.thrown_hurt_mob(p, id, "minecraft:egg", 0.0);
|
||||
}
|
||||
} else if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
@@ -4944,7 +4979,9 @@ impl Game<'_> {
|
||||
} = hit
|
||||
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
|
||||
{
|
||||
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
|
||||
if m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]) {
|
||||
self.thrown_hurt_mob(p, id, "minecraft:ender_pearl", 0.0);
|
||||
}
|
||||
} else if let Hit::Entity {
|
||||
who: Who::Mob(id), ..
|
||||
} = hit
|
||||
@@ -5415,6 +5452,11 @@ impl Game<'_> {
|
||||
self.audio
|
||||
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
|
||||
self.trigger_item("minecraft:filled_bucket", &filled);
|
||||
// PlayerInteractTrigger: the water bucket as it was, and the mob picked up.
|
||||
if let Some(stack) = &held {
|
||||
let target = self.mob_entity(&self.mobs[index]);
|
||||
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
|
||||
}
|
||||
let species = bucket.trim_end_matches("_bucket").to_owned();
|
||||
let id = self.mobs[index].id as i32;
|
||||
// Leashable.dropLeash, then discard.
|
||||
@@ -7372,6 +7414,7 @@ pub(super) fn melee(kind: horizon_client::mobs::DamageKind) -> bool {
|
||||
pub(super) fn damage_type(kind: horizon_client::mobs::DamageKind, caused: bool) -> &'static str {
|
||||
use horizon_client::mobs::DamageKind as K;
|
||||
match kind {
|
||||
K::Named(named) => named.name,
|
||||
K::PlayerAttack => "minecraft:player_attack",
|
||||
K::MaceSmash => "minecraft:mace_smash",
|
||||
K::Spear => "minecraft:spear",
|
||||
@@ -7744,6 +7787,24 @@ mod breeding_tests {
|
||||
mod damage_type_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn named_damage_keeps_its_registry_id_with_or_without_an_attacker() {
|
||||
let named = horizon_client::mobs::NamedDamage::new(
|
||||
"example:fan_fire",
|
||||
horizon_client::mobs::NamedDamage::IS_FIRE,
|
||||
);
|
||||
for attacker in [None, Some(Who::Mob(7)), Some(Who::Player)] {
|
||||
assert_eq!(
|
||||
source_damage_type(Damage {
|
||||
kind: DamageKind::Named(named),
|
||||
attacker,
|
||||
from: None,
|
||||
}),
|
||||
"example:fan_fire"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `DamageSources.explosion`: a blast with a causing entity is
|
||||
/// `player_explosion` whoever caused it, so an exploding creeper's too.
|
||||
#[test]
|
||||
|
||||
@@ -187,6 +187,42 @@ impl Game<'_> {
|
||||
self.set_default_game_mode(state.game_mode);
|
||||
return json!({ "value": GAME_MODES[state.game_mode as usize] });
|
||||
}
|
||||
"serversettings/entity_broadcast_range" => {
|
||||
return json!({
|
||||
"value": self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(100, |host| host.hub.entity_range_percent)
|
||||
});
|
||||
}
|
||||
"serversettings/entity_broadcast_range/set" => {
|
||||
let Some(percentage) = body["value"]
|
||||
.as_i64()
|
||||
.and_then(|value| i32::try_from(value).ok())
|
||||
else {
|
||||
return json!({
|
||||
"error": "entity broadcast range percentage must be an integer"
|
||||
});
|
||||
};
|
||||
let current = self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(100, |host| host.hub.entity_range_percent);
|
||||
// MinecraftServerSettingsServiceImpl logs the value asked for; the
|
||||
// DedicatedServerProperties deserializer clamps what is applied and saved.
|
||||
rpc(format!(
|
||||
"Update entity broadcast range percentage from {current}% to {percentage}%"
|
||||
));
|
||||
let percentage = percentage.clamp(10, 1000);
|
||||
self.set_entity_broadcast_range_percentage(percentage);
|
||||
self.store_property("entity-broadcast-range-percentage", percentage.to_string());
|
||||
return json!({
|
||||
"value": self
|
||||
.lan
|
||||
.as_ref()
|
||||
.map_or(100, |host| host.hub.entity_range_percent)
|
||||
});
|
||||
}
|
||||
"serversettings/simulation_distance" => {
|
||||
return json!({
|
||||
"value": self
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -191,15 +191,36 @@ impl Game<'_> {
|
||||
}) {
|
||||
return;
|
||||
}
|
||||
let original = amount;
|
||||
let amount = if self.armor[0].is_some() {
|
||||
amount * 0.75
|
||||
} else {
|
||||
amount
|
||||
};
|
||||
if let Some(p) = self.player.as_mut()
|
||||
&& let Some(id) = self.motion.health.damage.id(kind)
|
||||
{
|
||||
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
|
||||
let hurt = match (self.player.as_mut(), self.motion.health.damage.id(kind)) {
|
||||
(Some(p), Some(id)) => p
|
||||
.hurt(&self.motion.health, self.world.health_rules, id, amount)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
};
|
||||
if hurt {
|
||||
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, the falling block
|
||||
// (`DamageSources.fallingBlock(this)` and the like) being both the
|
||||
// causing and the direct entity.
|
||||
let block = horizon_client::advancements::EntityView {
|
||||
kind: "minecraft:falling_block".into(),
|
||||
position: [position.x, position.y, position.z],
|
||||
..Default::default()
|
||||
};
|
||||
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
|
||||
kind: kind.to_owned(),
|
||||
direct: Some(block.clone()),
|
||||
is_direct: true,
|
||||
source: Some(block),
|
||||
original,
|
||||
actual: amount,
|
||||
blocked: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ pub(super) struct PackStack {
|
||||
impl PackStack {
|
||||
/// The enabled packs in `enabled` (the selected) order, later overriding
|
||||
/// earlier: `PackRepository.openAllSelected` keeps the selected order.
|
||||
fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
|
||||
pub(super) fn enabled_packs(&self) -> impl Iterator<Item = &DataPack> {
|
||||
self.enabled
|
||||
.iter()
|
||||
.filter_map(|id| self.packs.iter().find(|p| &p.id == id))
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
@@ -1708,6 +1708,13 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the live entity tracking range percentage.
|
||||
pub(super) fn set_entity_broadcast_range_percentage(&mut self, percentage: i32) {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
host.hub.entity_range_percent = percentage;
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the live movement check's flight policy.
|
||||
pub(super) fn set_allow_flight(&mut self, allow: bool) {
|
||||
if let Some(host) = self.lan.as_mut() {
|
||||
@@ -1862,7 +1869,8 @@ impl Game<'_> {
|
||||
for event in events {
|
||||
if timed {
|
||||
note_event(&host, previous.take());
|
||||
previous = Some((Instant::now(), event_kind(&event)));
|
||||
let detail = self.event_detail(&host, &event);
|
||||
previous = Some((Instant::now(), event_kind(&event), detail));
|
||||
}
|
||||
match event {
|
||||
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
|
||||
@@ -3086,22 +3094,73 @@ fn event_kind(event: &HostEvent) -> EventKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Notes how long the event that started at `started` took (`spans::note`).
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind)>) {
|
||||
let Some((started, (kind, conn, name))) = started else {
|
||||
/// Notes how long the event that started at `started` took (`spans::note`), with what it
|
||||
/// was about when [`Game::event_detail`] said.
|
||||
fn note_event(host: &LanHost, started: Option<(Instant, EventKind, Option<UseDetail>)>) {
|
||||
let Some((started, (kind, conn, name), detail)) = started else {
|
||||
return;
|
||||
};
|
||||
horizon_client::spans::note(started.elapsed().as_secs_f32() * 1000.0, || {
|
||||
let who = name
|
||||
.or_else(|| conn.and_then(|c| host.hub.guest(c)).map(|g| g.name.clone()))
|
||||
.or_else(|| conn.map(|c| format!("connection {c}")));
|
||||
match who {
|
||||
let line = match who {
|
||||
Some(who) => format!("{kind} of {who}"),
|
||||
None => kind.to_owned(),
|
||||
};
|
||||
match detail {
|
||||
Some(d) => format!("{line} ({d})"),
|
||||
None => line,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// What a use packet was about, taken before it ran (the use may consume the item or change
|
||||
/// the block): the item in the hand it names and, on a block, that block, where, and the face.
|
||||
struct UseDetail {
|
||||
item: Option<Arc<str>>,
|
||||
block: Option<(Arc<str>, [i32; 3], i32)>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for UseDetail {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.item.as_deref().unwrap_or("empty hand"))?;
|
||||
if let Some((block, [x, y, z], face)) = &self.block {
|
||||
let face = ["down", "up", "north", "south", "west", "east"]
|
||||
.get(*face as usize)
|
||||
.copied()
|
||||
.unwrap_or("?");
|
||||
write!(f, " on {block} at {x} {y} {z} {face}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// For a slow tick's note (#8669): the item a `UseItemOn` or `UseItem` uses and the block
|
||||
/// it aims at. None for other events.
|
||||
fn event_detail(&self, host: &LanHost, event: &HostEvent) -> Option<UseDetail> {
|
||||
let HostEvent::Packet(conn, packet) = event else {
|
||||
return None;
|
||||
};
|
||||
let (hand, block) = match &**packet {
|
||||
play::Serverbound::UseItemOn(p) => (p.hand, Some((p.pos, p.face))),
|
||||
play::Serverbound::UseItem(p) => (p.hand, None),
|
||||
_ => return None,
|
||||
};
|
||||
let guest = host.hub.guest(*conn)?;
|
||||
let held = if hand == 1 {
|
||||
guest.offhand.as_ref()
|
||||
} else {
|
||||
guest.inventory.get(guest.selected).and_then(Option::as_ref)
|
||||
};
|
||||
Some(UseDetail {
|
||||
item: held.map(|s| Arc::clone(&s.item)),
|
||||
block: block.map(|(pos, face)| (self.block_name(pos), pos, face)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{OFFHAND_SLOT, equipment_slot_name, living_flags, mob_freeze_meta};
|
||||
|
||||
@@ -1199,6 +1199,7 @@ impl Game<'_> {
|
||||
.facts()
|
||||
.get(self.world.block(pos.0, pos.1, pos.2))
|
||||
.name;
|
||||
let block = name.to_string();
|
||||
let (menu, slots) = if &**name == "minecraft:ender_chest" {
|
||||
// EnderChestBlock.useWithoutItem: the player's own inventory.
|
||||
("minecraft:generic_9x3", Slots::Ender)
|
||||
@@ -1353,8 +1354,28 @@ impl Game<'_> {
|
||||
// lid (a vehicle keeps its own `Container`, an ender chest has none).
|
||||
let counts_openers = matches!(slots, Slots::Block(_) | Slots::Double(_, _));
|
||||
let hidden = self.native_hidden_slots(pos, items.len());
|
||||
// A mod menu type's block tells the client where it is, with what its
|
||||
// mod writes (`openMenu` with an extra-data writer); a stock menu
|
||||
// sends nothing more.
|
||||
let extra = match slots {
|
||||
Slots::Block(k) if self.mod_menu_at(k).is_some() => {
|
||||
let mut data = None;
|
||||
self.native_menu_extra_data(uuid, menu, &block, [k.0, k.1, k.2], &mut data);
|
||||
let mut data = data.unwrap_or_default();
|
||||
if data.len() > horizon_protocol::menu_extra::MAX_DATA {
|
||||
log::warn!(
|
||||
"Horizon: {block}'s menu extra data ({} bytes) is past {} bytes; sent without it",
|
||||
data.len(),
|
||||
horizon_protocol::menu_extra::MAX_DATA
|
||||
);
|
||||
data.clear();
|
||||
}
|
||||
Some(data)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
.open_container(conn, menu, title, pos, slots, items, extra);
|
||||
if !hidden.is_empty() {
|
||||
host.hub.hide_menu_slots(conn, hidden);
|
||||
}
|
||||
@@ -1418,7 +1439,7 @@ impl Game<'_> {
|
||||
// ServerPlayer.openMenu closes the menu that was open.
|
||||
self.guest_close_all(host, conn);
|
||||
host.hub
|
||||
.open_container(conn, menu, title, pos, slots, items);
|
||||
.open_container(conn, menu, title, pos, slots, items, None);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -2096,7 +2117,9 @@ impl Game<'_> {
|
||||
let stack = g
|
||||
.with_hand(hand, |g| g.held().cloned())
|
||||
.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
horizon_client::spans::time("use_advancements", || {
|
||||
g.trigger_any_block_use(p.pos, &stack);
|
||||
});
|
||||
}
|
||||
g.forced_hit = None;
|
||||
g.guest_use = None;
|
||||
|
||||
@@ -135,6 +135,15 @@ pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], veloc
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A snowflake from a vehicle (`PowderSnowBlock.entityInside`), to every player around.
|
||||
pub(super) fn lan_snowflake(&self, at: [f64; 3], velocity: [f64; 3]) {
|
||||
if let Some(host) = self.lan.as_ref() {
|
||||
send_snowflake_particle(host, at, velocity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles` of one
|
||||
/// snowflake (`count` 1, no spread, `RandomizationType.ALTERNATIVE`).
|
||||
pub(super) fn send_snowflake_particle(
|
||||
|
||||
@@ -149,6 +149,27 @@ impl Levels {
|
||||
self.parked.get_mut(&dim)
|
||||
}
|
||||
|
||||
/// The entities of a parked level, counted as `Game::entity_count` counts the one in place.
|
||||
pub(super) fn parked_entity_count(&self, dim: Dim) -> Option<usize> {
|
||||
let l = self.parked.get(&dim)?;
|
||||
Some(
|
||||
l.mobs.iter().filter(|m| !m.removed).count()
|
||||
+ l.dropped.len()
|
||||
+ l.orbs.len()
|
||||
+ l.projectiles.len()
|
||||
+ l.carts.len()
|
||||
+ l.boats.len()
|
||||
+ l.falling.len()
|
||||
+ l.paintings.len()
|
||||
+ l.item_frames.len()
|
||||
+ l.armor_stands.len()
|
||||
+ l.cushions.len()
|
||||
+ l.tridents.len()
|
||||
+ l.rockets.len()
|
||||
+ l.tnts.len(),
|
||||
)
|
||||
}
|
||||
|
||||
/// The mobs of a parked level.
|
||||
pub(super) fn parked_mobs(&self, dim: Dim) -> Option<&[Mob]> {
|
||||
self.parked.get(&dim).map(|level| level.mobs.as_slice())
|
||||
|
||||
@@ -252,9 +252,10 @@ fn pickup_instead_of_push(species: Species, is_passenger: bool, cart_is_vehicle:
|
||||
!cart_is_vehicle && !is_passenger && species != Species::IronGolem
|
||||
}
|
||||
|
||||
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden never rides.
|
||||
/// `Entity.canRide` for a cart: the boarding cooldown, and a warden or the
|
||||
/// wither never rides (`canRide` false).
|
||||
fn can_ride_cart(species: Species, boarding_cooldown: i32) -> bool {
|
||||
boarding_cooldown <= 0 && species != Species::Warden
|
||||
boarding_cooldown <= 0 && !matches!(species, Species::Warden | Species::Wither)
|
||||
}
|
||||
|
||||
/// `Entity.positionRider` on an `AbstractMinecart`: passenger attachment less
|
||||
@@ -3039,6 +3040,12 @@ mod tests {
|
||||
assert!(!can_ride_cart(Species::Warden, 0));
|
||||
}
|
||||
|
||||
/// `WitherBoss.canRide` returns false, so a moving cart cannot scoop the boss up.
|
||||
#[test]
|
||||
fn a_wither_never_rides_a_cart() {
|
||||
assert!(!can_ride_cart(Species::Wither, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cart_seat_matches_the_player_offset() {
|
||||
let at = Vec3::new(1.0, 10.0, 2.0);
|
||||
|
||||
@@ -10,13 +10,13 @@ impl Game<'_> {
|
||||
/// layer, as `WorldDrawn::block_sprites` cuts them), where it lies on the side, how its texture runs across it,
|
||||
/// its tint source and whether it is cut out, translucent or animated; its particle sprite; whether it is a plain
|
||||
/// cube. None for a block the game does not know. The model is the state's first random variant. API
|
||||
/// @API:native_block_sprites@.
|
||||
/// 380.
|
||||
pub(super) fn native_block_sprites(&self, blocks: &[&str]) -> Vec<Option<BlockSprites>> {
|
||||
let lookup = self.native_sprite_lookup();
|
||||
blocks.iter().map(|block| lookup.block(block)).collect()
|
||||
}
|
||||
/// For a client mod: each texture's sprite by name (`minecraft:block/stone`, `block/stone`), none for one no
|
||||
/// model uses. API @API:native_block_sprites@.
|
||||
/// model uses. API 380.
|
||||
pub(super) fn native_texture_sprites(&self, textures: &[&str]) -> Vec<Option<SpriteRef>> {
|
||||
let lookup = self.native_sprite_lookup();
|
||||
textures
|
||||
@@ -25,7 +25,7 @@ impl Game<'_> {
|
||||
.collect()
|
||||
}
|
||||
/// For a client mod: changes each time the resources load (a resource pack turned on or off can move every
|
||||
/// sprite): ask `native_block_sprites` again then. API @API:native_block_sprites@.
|
||||
/// sprite): ask `native_block_sprites` again then. API 380.
|
||||
pub(super) fn native_block_sprites_version(&self) -> u64 {
|
||||
self.pack.context.render.generation
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ const MAX_TAGS: usize = 1024;
|
||||
impl Game<'_> {
|
||||
/// The entity's data as `/data get entity` shows it
|
||||
/// (`Entity.saveWithoutId`): its compound without `id`. An entity with no
|
||||
/// UUID yet gets the one it will be saved with. API @API:entity_data@.
|
||||
/// UUID yet gets the one it will be saved with. API 386.
|
||||
pub(super) fn native_entity_nbt(&mut self, entity: EntityRef) -> Option<Compound> {
|
||||
let t = self.native_entity_target(entity)?;
|
||||
match self.entity_nbt(t)? {
|
||||
@@ -28,7 +28,7 @@ impl Game<'_> {
|
||||
/// changed. False for a player (vanilla refuses them too), a kind Horizon
|
||||
/// can't reload yet (only mobs, dropped items, armor stands and item
|
||||
/// frames), data nested too deep, or data that changes nothing. API
|
||||
/// @API:entity_data@.
|
||||
/// 386.
|
||||
pub(super) fn native_entity_merge_nbt(&mut self, entity: EntityRef, nbt: Compound) -> bool {
|
||||
let Some(t) = self.native_entity_target(entity) else {
|
||||
return false;
|
||||
@@ -44,12 +44,12 @@ impl Game<'_> {
|
||||
merged != old && self.apply_entity_nbt(t, merged).is_ok()
|
||||
}
|
||||
/// The entity's tags (`Entity.getTags`), as `/tag <entity> list` shows
|
||||
/// them. API @API:entity_data@.
|
||||
/// them. API 386.
|
||||
pub(super) fn native_entity_tags(&self, entity: EntityRef) -> Option<Vec<String>> {
|
||||
Some(self.tags_of(self.native_entity_target(entity)?))
|
||||
}
|
||||
/// Adds tag `tag` (`Entity.addTag`): false when it has it already, holds
|
||||
/// 1024, its kind can't store tags yet, or the handle is stale. API @API:entity_data@.
|
||||
/// 1024, its kind can't store tags yet, or the handle is stale. API 386.
|
||||
pub(super) fn native_entity_add_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
|
||||
let Some(t) = self.native_entity_target(entity) else {
|
||||
return false;
|
||||
@@ -64,7 +64,7 @@ impl Game<'_> {
|
||||
self.set_entity_tags(t, &tags)
|
||||
}
|
||||
/// Removes tag `tag` (`Entity.removeTag`): false when it didn't have it.
|
||||
/// API @API:entity_data@.
|
||||
/// API 386.
|
||||
pub(super) fn native_entity_remove_tag(&mut self, entity: EntityRef, tag: &str) -> bool {
|
||||
let Some(t) = self.native_entity_target(entity) else {
|
||||
return false;
|
||||
@@ -75,7 +75,7 @@ impl Game<'_> {
|
||||
tags.len() != before && self.set_entity_tags(t, &tags)
|
||||
}
|
||||
/// The entity's name as plain text (`Entity.getName`): its custom name,
|
||||
/// a player's name, else its type's name. API @API:entity_data@.
|
||||
/// a player's name, else its type's name. API 386.
|
||||
pub(super) fn native_entity_name(&self, entity: EntityRef) -> Option<String> {
|
||||
Some(self.target_name(self.native_entity_target(entity)?).plain())
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ impl Game<'_> {
|
||||
/// The entity's velocity in blocks a tick (`Entity.getDeltaMovement`). A
|
||||
/// player's is the move its client last reported (`getKnownMovement`); an
|
||||
/// entity Horizon doesn't simulate (a display, a painting) has none, so
|
||||
/// zero. API @API:entity_motion@.
|
||||
/// zero. API 379.
|
||||
pub(super) fn native_entity_velocity(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
let t = self.native_entity_target(entity)?;
|
||||
if let Some(g) = self.native_target_guest(t) {
|
||||
@@ -47,7 +47,7 @@ impl Game<'_> {
|
||||
/// Sets the entity's velocity (`Entity.setDeltaMovement`); clients see it
|
||||
/// as they see the entity move. False for a player (its client moves
|
||||
/// it), an entity Horizon doesn't simulate, or a value that isn't finite.
|
||||
/// API @API:entity_motion@.
|
||||
/// API 379.
|
||||
pub(super) fn native_entity_set_velocity(
|
||||
&mut self,
|
||||
entity: EntityRef,
|
||||
@@ -67,7 +67,7 @@ impl Game<'_> {
|
||||
true
|
||||
}
|
||||
/// Whether the entity stands on a block (`Entity.onGround`). API
|
||||
/// @API:entity_motion@.
|
||||
/// 379.
|
||||
pub(super) fn native_entity_on_ground(&self, entity: EntityRef) -> Option<bool> {
|
||||
let t = self.native_entity_target(entity)?;
|
||||
if let Some(g) = self.native_target_guest(t) {
|
||||
@@ -80,7 +80,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// The entity's box (`Entity.getBoundingBox`): its lowest and highest
|
||||
/// corner. None for the kinds whose box Horizon doesn't keep (displays,
|
||||
/// markers, End crystals, the dragon). API @API:entity_motion@.
|
||||
/// markers, End crystals, the dragon). API 379.
|
||||
pub(super) fn native_entity_bounding_box(&self, entity: EntityRef) -> Option<[[f64; 3]; 2]> {
|
||||
let t = self.native_entity_target(entity)?;
|
||||
if let Some(g) = self.native_target_guest(t) {
|
||||
@@ -97,25 +97,25 @@ impl Game<'_> {
|
||||
Some([lo, hi])
|
||||
}
|
||||
/// The height of the entity's eyes above its feet (`Entity.getEyeHeight`).
|
||||
/// API @API:entity_motion@.
|
||||
/// API 379.
|
||||
pub(super) fn native_entity_eye_height(&self, entity: EntityRef) -> Option<f64> {
|
||||
Some(self.target_eye_height(self.native_entity_target(entity)?))
|
||||
}
|
||||
/// Where the entity's eyes are (`Entity.getEyePosition`). API
|
||||
/// @API:entity_motion@.
|
||||
/// 379.
|
||||
pub(super) fn native_entity_eye_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
let [x, y, z] = self.native_entity_position(entity)?;
|
||||
Some([x, y + self.native_entity_eye_height(entity)?, z])
|
||||
}
|
||||
/// The unit vector the entity looks along (`Entity.getViewVector`, from
|
||||
/// its pitch and yaw). API @API:entity_motion@.
|
||||
/// its pitch and yaw). API 379.
|
||||
pub(super) fn native_entity_view_vector(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
let [yaw, pitch] = self.native_entity_rotation(entity)?;
|
||||
let v = horizon_world::view_direction::view_vector(pitch, yaw);
|
||||
Some([v.x, v.y, v.z])
|
||||
}
|
||||
/// The square of the distance from the entity's feet to `pos`
|
||||
/// (`Entity.distanceToSqr`). API @API:entity_motion@.
|
||||
/// (`Entity.distanceToSqr`). API 379.
|
||||
pub(super) fn native_entity_distance_to_sqr(
|
||||
&self,
|
||||
entity: EntityRef,
|
||||
|
||||
@@ -37,14 +37,14 @@ impl Game<'_> {
|
||||
/// The entity `id` (the id events carry) as a handle for the `entity_*`
|
||||
/// methods: None on a client, with no world open, or for no such entity
|
||||
/// in the level in place or among the server's players. API
|
||||
/// @API:entity_handles@.
|
||||
/// 373.
|
||||
pub(super) fn native_entity(&self, id: u32) -> Option<EntityRef> {
|
||||
let entity = EntityRef::new(self.native_lifecycle.epoch()?, id);
|
||||
self.native_entity_target(entity).map(|_| entity)
|
||||
}
|
||||
/// The level `dimension` (`minecraft:the_nether`) as a handle: None on a
|
||||
/// client, with no world open, or for a dimension the server hasn't
|
||||
/// loaded. API @API:entity_handles@.
|
||||
/// loaded. API 373.
|
||||
pub(super) fn native_level(&self, dimension: &str) -> Option<LevelRef> {
|
||||
if self.remote.is_some() {
|
||||
return None;
|
||||
@@ -56,29 +56,29 @@ impl Game<'_> {
|
||||
Some(LevelRef::new(epoch, dim.id()))
|
||||
}
|
||||
/// The entity's position (`Entity.position`): its feet, the middle of
|
||||
/// its box for a painting or an item frame. API @API:entity_handles@.
|
||||
/// its box for a painting or an item frame. API 373.
|
||||
pub(super) fn native_entity_position(&self, entity: EntityRef) -> Option<[f64; 3]> {
|
||||
self.target_position(self.native_entity_target(entity)?)
|
||||
}
|
||||
/// The block the entity's position is in (`Entity.blockPosition`). API
|
||||
/// @API:entity_handles@.
|
||||
/// 373.
|
||||
pub(super) fn native_entity_block_position(&self, entity: EntityRef) -> Option<[i32; 3]> {
|
||||
self.native_entity_position(entity)
|
||||
.map(|p| p.map(|v| v.floor() as i32))
|
||||
}
|
||||
/// The entity's yaw and pitch in degrees (`Entity.getYRot`, `getXRot`); 0
|
||||
/// for the kinds that don't turn. API @API:entity_handles@.
|
||||
/// for the kinds that don't turn. API 373.
|
||||
pub(super) fn native_entity_rotation(&self, entity: EntityRef) -> Option<[f32; 2]> {
|
||||
Some(self.target_rotation(self.native_entity_target(entity)?))
|
||||
}
|
||||
/// The entity's type (`Entity.getType`): `minecraft:zombie`. API
|
||||
/// @API:entity_handles@.
|
||||
/// 373.
|
||||
pub(super) fn native_entity_kind(&self, entity: EntityRef) -> Option<String> {
|
||||
Some(self.target_type(self.native_entity_target(entity)?))
|
||||
}
|
||||
/// The entity's UUID (`Entity.getUUID`), the one it is saved with; an
|
||||
/// entity that has none yet gets it now, as a new vanilla entity does.
|
||||
/// API @API:entity_handles@.
|
||||
/// API 373.
|
||||
pub(super) fn native_entity_uuid(&mut self, entity: EntityRef) -> Option<u128> {
|
||||
let words = match self.native_entity_target(entity)? {
|
||||
Target::Player => return self.command_guest(self.lan_acting?).map(|g| g.uuid),
|
||||
@@ -87,7 +87,7 @@ impl Game<'_> {
|
||||
};
|
||||
Some(uuid_from_words(words))
|
||||
}
|
||||
/// The level the entity is in (`Entity.level`). API @API:entity_handles@.
|
||||
/// The level the entity is in (`Entity.level`). API 373.
|
||||
pub(super) fn native_entity_level(&self, entity: EntityRef) -> Option<LevelRef> {
|
||||
let dim: Dim = self.target_level(self.native_entity_target(entity)?);
|
||||
Some(LevelRef::new(entity.epoch(), dim.id()))
|
||||
|
||||
@@ -6,6 +6,91 @@
|
||||
//! Horizon's own HUD extras do, and keep a mod's settings where the
|
||||
//! WebAssembly mod of the same id kept them.
|
||||
//!
|
||||
//! API 399 adds a player's chat message before it is sent (`native_player_chat`).
|
||||
//!
|
||||
//! API 398 adds the player's network entity id on `NativeServerPlayer` (`entity_id`).
|
||||
//!
|
||||
//! API 397 adds mod config files, loaded and reloaded (`native_register_configs`, `native_config_loading`,
|
||||
//! `native_config_reloading`, `native_config_unloading`; `native_configs.rs`).
|
||||
//!
|
||||
//! API 396 adds a slime or magma cube splitting on death, cancellable (`native_mob_split`).
|
||||
//!
|
||||
//! API 395 adds two animals breeding and a baby about to spawn, cancellable (`native_baby_entity_spawn`).
|
||||
//!
|
||||
//! API 394 adds experience points a server player is about to gain, changeable or cancelled (`native_player_xp_change`).
|
||||
//!
|
||||
//! API 393 adds the problem that stops a player sleeping in a bed, changeable (`native_can_player_sleep`).
|
||||
//!
|
||||
//! API 392 adds a living entity about to die, cancellable (`native_living_death`).
|
||||
//!
|
||||
//! API 391 adds a mob about to convert into another, cancellable (`native_living_conversion_pre`).
|
||||
//!
|
||||
//! API 390 adds a mob converted into another (`native_living_conversion_post`).
|
||||
//!
|
||||
//! API 389 adds A village siege found a spawn position, cancellable (`native_village_siege`).
|
||||
//!
|
||||
//! API 388 adds Possible natural spawn entries, mutable and cancellable (`native_potential_spawns`).
|
||||
//!
|
||||
//! API 387 adds A player tunes a note block (`native_note_block_change`).
|
||||
//!
|
||||
//! API 386 adds an entity's data (read and merge, as `/data` does), tags and name through an `EntityRef`.
|
||||
//!
|
||||
//! API 385 adds per-stack conditional item model properties (`native_item_model_property`).
|
||||
//!
|
||||
//! API 384 adds a host-aware player-weapon damage hook (`native_living_incoming_damage_v2`), leaving v1 compatible.
|
||||
//!
|
||||
//! API 383 adds living_knockback_v2: raw knockback with the attacker's entity id and held items, including armor-stand sweeps.
|
||||
//!
|
||||
//! API 382 adds living entity handles (`LivingRef`, `native_living`) and a living entity's equipment and hand items, read and set.
|
||||
//!
|
||||
//! API 381 adds a level's blocks and time through a `LevelRef`: block state, block, property, whether a chunk is loaded, height, game time and day time.
|
||||
//!
|
||||
//! API 380 adds block textures for client mods: `native_block_sprites` and `native_texture_sprites` say where blocks' and textures' sprites are in the block sprite arrays, and `WorldDrawn::block_sprites` hands a mod the arrays.
|
||||
//!
|
||||
//! API 379 adds an entity's velocity (and setting it), ground, box, eye height and position, view vector and distance through an `EntityRef`.
|
||||
//!
|
||||
//! API 378 adds validated category declarations for custom recipe types (`native_recipe_book_categories`, `native_recipe_book_category`); display and unlock integration follows separately.
|
||||
//!
|
||||
//! API 377 adds a mod's own ingredient kind matching a concrete stack
|
||||
//! (`native_ingredient_test`, which the ingredient's test calls with the kind,
|
||||
//! its JSON, the stack and the verdict — the load-time list already named
|
||||
//! the item).
|
||||
//!
|
||||
//! API 376 adds The ammunition a bow or crossbow shooter finds (`native_living_get_projectile`).
|
||||
//!
|
||||
//! API 375 adds A player is about to nock a bow (`native_arrow_nock`).
|
||||
//!
|
||||
//! API 374 adds Explosion velocity replacement (`native_explosion_knockback`).
|
||||
//!
|
||||
//! API 373 adds entity and level handles (`EntityRef`, `LevelRef`, `native_entity`, `native_level`) and an entity's position, block position, rotation, type, UUID and level.
|
||||
//!
|
||||
//! API 372 adds a name tag hook (native_render_name_tag).
|
||||
//!
|
||||
//! API 371 adds `WorldDrawn::motion`: which mob, player or dropped item each run of the frame's entity quads is.
|
||||
//!
|
||||
//! API 370 adds a mod item holding items (`native_item_container`), and the item container helpers in
|
||||
//! `native_item_container.rs`: `native_item_container_slots`, `native_item_container_insert`, `native_item_container_extract`.
|
||||
//!
|
||||
//! API 369 adds an additive first-person hand and item pose (`native_render_hand_pose`).
|
||||
//!
|
||||
//! API 368 adds tooltip box colours (`native_tooltip_color`).
|
||||
//!
|
||||
//! API 367 adds the screen_opening screen lifecycle observation (`native_screen_opening`) at most once per frame.
|
||||
//!
|
||||
//! API 366 adds the screen_init_post screen lifecycle observation (`native_screen_init_post`) at most once per frame.
|
||||
//!
|
||||
//! API 365 adds the screen_closing screen lifecycle observation (`native_screen_closing`) at most once per frame.
|
||||
//!
|
||||
//! API 364 adds the render hook point `native_hand_drawn(hand)`: the first-person hand is drawn (`HandDrawn`).
|
||||
//!
|
||||
//! API 363 adds reading the world camera and block collision boxes (`native_camera`, `native_block_collision`).
|
||||
//!
|
||||
//! API 362 adds a per-tick player FOV modifier hook (`native_compute_fov_modifier`).
|
||||
//!
|
||||
//! API 361 adds a cancellable item frame item render hook (`native_render_item_in_frame`).
|
||||
//!
|
||||
//! API 360 adds Local player movement input before application (`native_movement_input`).
|
||||
//!
|
||||
//! API 359 adds a living entity jumping (`native_living_jump`).
|
||||
//!
|
||||
//! API 358 adds a living entity healing, with a changeable amount and cancellation (`native_living_heal`).
|
||||
@@ -880,6 +965,7 @@ use super::{
|
||||
native_configs::{NativeConfig, NativeConfigFile},
|
||||
native_mob_draw::{NativeMobRender, NativePlayerRender},
|
||||
native_panel_recipes::RecipePage,
|
||||
native_reload_listeners::NativeReloadListener,
|
||||
};
|
||||
use horizon_assets::item_model::StackView;
|
||||
use horizon_client::mod_api::{
|
||||
@@ -1048,7 +1134,7 @@ pub(super) use horizon_world::level::flags;
|
||||
/// The hook points' API version. The launcher reads this line from the
|
||||
/// source it builds; keep its form.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 359;
|
||||
pub(crate) const NATIVE_HOOKS_API: u32 = 399;
|
||||
|
||||
impl GameLevel<'_> {
|
||||
/// A hopper at `pos` is about to pick up `count` of `item`. Set `cancel` to leave the dropped stack in the world. Server-side. 232.
|
||||
@@ -1278,7 +1364,7 @@ impl GameLevel<'_> {
|
||||
/// client prediction. `context` gives its dimension, position, instrument
|
||||
/// and old note; `new_note` starts as vanilla's cycled note. Set it to 0
|
||||
/// through 24; an out-of-range value keeps vanilla's. Cancel to leave the
|
||||
/// note unchanged and prevent playback. API @API:native_note_block_change@.
|
||||
/// note unchanged and prevent playback. API 387.
|
||||
#[inline(always)]
|
||||
pub(super) fn native_note_block_change(
|
||||
&self,
|
||||
@@ -1759,6 +1845,27 @@ pub struct NativeShake {
|
||||
pub offset: [f32; 3],
|
||||
}
|
||||
|
||||
/// A mod's camera (`native_camera_pose`): each value that isn't finite keeps vanilla's from `view`.
|
||||
fn posed_camera(
|
||||
view: (glam::DVec3, f32, f32),
|
||||
position: [f64; 3],
|
||||
yaw: f32,
|
||||
pitch: f32,
|
||||
) -> (glam::DVec3, f32, f32) {
|
||||
let position = std::array::from_fn(|i| {
|
||||
if position[i].is_finite() {
|
||||
position[i]
|
||||
} else {
|
||||
view.0[i]
|
||||
}
|
||||
});
|
||||
(
|
||||
glam::DVec3::from_array(position),
|
||||
if yaw.is_finite() { yaw } else { view.1 },
|
||||
if pitch.is_finite() { pitch } else { view.2 },
|
||||
)
|
||||
}
|
||||
|
||||
/// The largest turn a shake gives, in degrees, on each axis.
|
||||
const SHAKE_MAX_DEGREES: f32 = 10.0;
|
||||
/// The largest offset a shake gives, in blocks, along each axis.
|
||||
@@ -1826,17 +1933,28 @@ fn shake_pose(shake: NativeShake) -> glam::Mat4 {
|
||||
}
|
||||
|
||||
/// The mod screen open on this client: the block it is for, the key its
|
||||
/// slots are under, the window id of its menu, and the menu's data slots as
|
||||
/// the server sent them.
|
||||
/// slots are under, the window id of its menu, the menu's data slots as
|
||||
/// the server sent them, and what the server sent before the menu opened
|
||||
/// (`horizon:menu_extra`), when it did.
|
||||
pub(super) struct OpenNativeScreen {
|
||||
key: Pos,
|
||||
window: i32,
|
||||
block: String,
|
||||
screen: NativeScreen,
|
||||
data: Vec<i32>,
|
||||
extra: Option<horizon_protocol::menu_extra::Message>,
|
||||
}
|
||||
|
||||
impl OpenNativeScreen {
|
||||
/// The block the menu is of: where the server said, else the clicked
|
||||
/// block its slots are under (a server without `horizon:menu_extra`).
|
||||
fn pos(&self) -> Option<[i32; 3]> {
|
||||
match &self.extra {
|
||||
Some(extra) => Some(extra.pos),
|
||||
None if self.key.0 != i32::MIN => Some([self.key.0, self.key.1, self.key.2]),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
/// Data slot `id` of window `window`'s menu: false for another window's
|
||||
/// (an old or replaced one), which this screen leaves alone.
|
||||
fn set_data(&mut self, window: i32, id: i16, value: i16) -> bool {
|
||||
@@ -2194,6 +2312,32 @@ impl Game<'_> {
|
||||
pub(super) fn native_server_data_loaded(&mut self, reload: bool) {
|
||||
mod_api::dispatch_server_data_loaded(self, mod_api::mods(), reload);
|
||||
}
|
||||
/// The folders of JSON files native mods read from the data
|
||||
/// (`AddServerReloadListenersEvent`): push yours, each a listener id and a
|
||||
/// folder under `data/<namespace>/`. Asked at the start of every data load
|
||||
/// of a server: a world opening, `/reload`. A folder that leaves
|
||||
/// `data/<namespace>/` is skipped. API @API:server_reload_listeners@.
|
||||
pub(super) fn native_add_server_reload_listeners(
|
||||
&mut self,
|
||||
listeners: &mut Vec<NativeReloadListener>,
|
||||
) {
|
||||
mod_api::dispatch_add_server_reload_listeners(self, mod_api::mods(), listeners);
|
||||
}
|
||||
/// A listener's files were read (`SimpleJsonResourceReloadListener`'s
|
||||
/// `apply`): `files` holds `<namespace>:<path>` (the file's path under
|
||||
/// the folder, without `.json`) and its JSON, sorted by id, from the
|
||||
/// resource root (vanilla's and content mods' data) and the world's enabled
|
||||
/// data packs; for each id the top pack's file, and a file that isn't JSON
|
||||
/// is logged and left out. Every mod hears every listener, so check
|
||||
/// `listener.id`. Fired on each data load before `native_server_data_loaded`.
|
||||
/// Server-side. API @API:server_reload_listeners@.
|
||||
pub(super) fn native_server_reload_listener_apply(
|
||||
&mut self,
|
||||
listener: &NativeReloadListener,
|
||||
files: &[(String, serde_json::Value)],
|
||||
) {
|
||||
mod_api::dispatch_server_reload_listener_apply(self, mod_api::mods(), listener, files);
|
||||
}
|
||||
/// The server is assembling its data tables from the world's data packs
|
||||
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
|
||||
/// run, and the recipes, tags, loot and advancements are built after
|
||||
@@ -2253,7 +2397,7 @@ impl Game<'_> {
|
||||
/// (`Common` and `Server`) and by a server before each world it opens
|
||||
/// (`Server`); a game reads only its own kinds, so push them all every
|
||||
/// time. A file outside `config/`, or one already registered, is skipped.
|
||||
/// API @API:mod_configs@.
|
||||
/// API 397.
|
||||
pub(super) fn native_register_configs(&mut self, configs: &mut Vec<NativeConfig>) {
|
||||
mod_api::dispatch_register_configs(self, mod_api::mods(), configs);
|
||||
}
|
||||
@@ -2261,7 +2405,7 @@ impl Game<'_> {
|
||||
/// says whose, which file and its text, which the mod parses (TOML by
|
||||
/// convention). Every mod hears every config, so check `mod_id`. Fired
|
||||
/// where the config's kind loads (`native_register_configs`), a `Server`
|
||||
/// one before `native_server_about_to_start`. API @API:mod_configs@.
|
||||
/// one before `native_server_about_to_start`. API 397.
|
||||
pub(super) fn native_config_loading(&mut self, config: &NativeConfigFile) {
|
||||
mod_api::dispatch_config_loading(self, mod_api::mods(), config);
|
||||
}
|
||||
@@ -2269,14 +2413,14 @@ impl Game<'_> {
|
||||
/// its new text. The game looks at the files' modification times once a
|
||||
/// second, on the window and on the server's tick; a file saved with the
|
||||
/// same text, removed or unreadable fires nothing and keeps its last
|
||||
/// text. API @API:mod_configs@.
|
||||
/// text. API 397.
|
||||
pub(super) fn native_config_reloading(&mut self, config: &NativeConfigFile) {
|
||||
mod_api::dispatch_config_reloading(self, mod_api::mods(), config);
|
||||
}
|
||||
/// A `Server` config is dropped as its server stops
|
||||
/// (`ModConfigEvent.Unloading`), after `native_server_stopping`; the next
|
||||
/// world reads it again. `Client` and `Common` configs stay until the
|
||||
/// game quits. API @API:mod_configs@.
|
||||
/// game quits. API 397.
|
||||
pub(super) fn native_config_unloading(&mut self, config: &NativeConfigFile) {
|
||||
mod_api::dispatch_config_unloading(self, mod_api::mods(), config);
|
||||
}
|
||||
@@ -2500,7 +2644,7 @@ impl Game<'_> {
|
||||
mod_api::dispatch_commands(self, mod_api::mods(), commands);
|
||||
}
|
||||
/// Player FOV scale, after vanilla effects and before zoom and easing.
|
||||
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
|
||||
/// Invalid values or values outside 0.01..=10 revert to base. API 362.
|
||||
pub(super) fn native_compute_fov_modifier(
|
||||
&self,
|
||||
player: &horizon_client::camera::FovPlayer,
|
||||
@@ -2562,7 +2706,7 @@ impl Game<'_> {
|
||||
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
|
||||
mod_api::dispatch_camera_shake(self, mod_api::mods(), partial, shake);
|
||||
}
|
||||
/// Name tag visibility and optional replacement text. API @API:render_name_tag@.
|
||||
/// Name tag visibility and optional replacement text. API 372.
|
||||
pub(super) fn native_render_name_tag(
|
||||
&self,
|
||||
entity_id: u32,
|
||||
@@ -2608,7 +2752,7 @@ impl Game<'_> {
|
||||
|
||||
/// Additional first-person hand and item pose, including empty arms and maps.
|
||||
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
|
||||
/// API @API:render_hand_pose@.
|
||||
/// API 369.
|
||||
pub(super) fn native_render_hand_pose(
|
||||
&self,
|
||||
hand: horizon_client::first_person::Hand,
|
||||
@@ -2660,6 +2804,21 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
|
||||
/// The camera this frame is drawn from, `partial` ticks past the last tick, after vanilla
|
||||
/// placed it (`Camera.setup`): its position, and its yaw and pitch in degrees. Set them to
|
||||
/// draw the world from anywhere: a cinematic or replay camera. Only the picture, the sound's
|
||||
/// listener and the billboards facing the camera follow it; the player, where they aim, the
|
||||
/// first-person hand and which chunks load stay the player's. Values that aren't finite keep
|
||||
/// vanilla's. Not called on the title screen's panorama. API @API:camera_pose@.
|
||||
pub(super) fn native_camera_pose(
|
||||
&self,
|
||||
partial: f32,
|
||||
position: &mut [f64; 3],
|
||||
yaw: &mut f32,
|
||||
pitch: &mut f32,
|
||||
) {
|
||||
mod_api::dispatch_camera_pose(self, mod_api::mods(), partial, position, yaw, pitch);
|
||||
}
|
||||
/// Final scaled third-person camera distance, before visual-shape collision clipping.
|
||||
/// `entity` includes its living scale/distance and optional mount scale/distance.
|
||||
/// Only finite requests in 0..=1024 blocks replace the vanilla distance.
|
||||
@@ -2923,7 +3082,7 @@ impl Game<'_> {
|
||||
/// `native_item_container_insert`, `native_item_container_extract`, in
|
||||
/// `native_item_container.rs`). The contents are kept in the stack's
|
||||
/// `minecraft:container`, as a shulker box item keeps them.
|
||||
/// API @API:item_container@.
|
||||
/// API 370.
|
||||
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
|
||||
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
|
||||
}
|
||||
@@ -4269,7 +4428,7 @@ impl Game<'_> {
|
||||
/// without a message) to refuse. The spawn point is set before it fires,
|
||||
/// as in vanilla, only when vanilla found no problem. After it,
|
||||
/// `native_player_bed_enter` still fires and may cancel. Server-side.
|
||||
/// API @API:can_player_sleep@.
|
||||
/// API 393.
|
||||
pub(super) fn native_can_player_sleep(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4417,7 +4576,7 @@ impl Game<'_> {
|
||||
/// an advancement reward gives nothing for a negative amount. Experience
|
||||
/// bottles and furnaces make orbs, so they come through the orb path.
|
||||
/// `native_player_exp_change` is separate and still fires first for an orb
|
||||
/// pickup. Server-side. API @API:player_xp_change@.
|
||||
/// pickup. Server-side. API 394.
|
||||
pub(super) fn native_player_xp_change(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -4638,6 +4797,23 @@ impl Game<'_> {
|
||||
) {
|
||||
mod_api::dispatch_inventory_block_start(self, mod_api::mods(), player, name, pos, cancel);
|
||||
}
|
||||
/// Block `block` at `pos` opens its mod menu type `menu` for server
|
||||
/// player `player`: set `data` to hand the menu's screen bytes of your
|
||||
/// own (a machine's tier, mode or recipes), up to 32 KiB, which the
|
||||
/// client reads with `native_screen_extra_data` while the screen is open
|
||||
/// (NeoForge's `openMenu` with an extra-data writer). The first mod to
|
||||
/// set it wins, so answer only for your blocks. The player's state is not
|
||||
/// swapped in. @API:menu_extra_data@.
|
||||
pub(super) fn native_menu_extra_data(
|
||||
&mut self,
|
||||
player: u128,
|
||||
menu: &str,
|
||||
block: &str,
|
||||
pos: [i32; 3],
|
||||
data: &mut Option<Vec<u8>>,
|
||||
) {
|
||||
mod_api::dispatch_menu_extra_data(self, mod_api::mods(), player, menu, block, pos, data);
|
||||
}
|
||||
|
||||
/// A sculk catalyst at `pos` has begun blooming after taking experience.
|
||||
/// The catalyst block state has changed. Server-side.
|
||||
@@ -5216,7 +5392,7 @@ impl Game<'_> {
|
||||
/// A player is about to nock `bow` with `hand` (`BowItem.use`, NeoForge's
|
||||
/// `ArrowNockEvent`): `has_ammo` is whether `getProjectile` found ammunition
|
||||
/// or the player is in creative. Set `cancel` to fail the use.
|
||||
/// @API:arrow_nock@.
|
||||
/// 375.
|
||||
pub(super) fn native_arrow_nock(
|
||||
&mut self,
|
||||
player: u128,
|
||||
@@ -5243,7 +5419,7 @@ impl Game<'_> {
|
||||
/// mob (`entity`, `kind`): a skeleton or illusioner's shot, or the crossbow
|
||||
/// load of a pillager or piglin. `projectile` is the vanilla
|
||||
/// ammunition; replace it to choose another. A replaced stack is not taken
|
||||
/// from the inventory. @API:living_get_projectile@.
|
||||
/// from the inventory. 376.
|
||||
pub(super) fn native_living_get_projectile(
|
||||
&mut self,
|
||||
player: Option<u128>,
|
||||
@@ -5499,7 +5675,7 @@ impl Game<'_> {
|
||||
/// size of the children (half of it) and `count` how many vanilla drew
|
||||
/// (2 to 4). A mod may change `count` (0 spawns none, at most 64) or set
|
||||
/// `cancel` to spawn no children. Only for a parent above size 1; the
|
||||
/// split's random draw is made first. On the server. API @API:mob_split@.
|
||||
/// split's random draw is made first. On the server. API 396.
|
||||
pub(super) fn native_mob_split(
|
||||
&mut self,
|
||||
parent: u32,
|
||||
@@ -5531,7 +5707,7 @@ impl Game<'_> {
|
||||
/// statistic, no advancement and no heart particles, as NeoForge
|
||||
/// returns before `finalizeSpawnChildFromBreeding`. Not fired for
|
||||
/// turtles, frogs and sniffers (an egg, no baby), villagers or
|
||||
/// armadillos' own paths. On the server. API @API:baby_entity_spawn@.
|
||||
/// armadillos' own paths. On the server. API 395.
|
||||
pub(super) fn native_baby_entity_spawn(
|
||||
&mut self,
|
||||
parent: u32,
|
||||
@@ -5567,7 +5743,7 @@ impl Game<'_> {
|
||||
/// Possible natural spawns at a server position (NeoForge's
|
||||
/// `LevelEvent.PotentialSpawns`, `NaturalSpawner.mobsAt`). Mods may add or
|
||||
/// remove entries, or cancel to make the list empty.
|
||||
/// @API:native_potential_spawns@.
|
||||
/// 388.
|
||||
pub(super) fn native_potential_spawns(
|
||||
&self,
|
||||
dimension: &str,
|
||||
@@ -5624,7 +5800,7 @@ impl Game<'_> {
|
||||
|
||||
/// A village siege found a spawn position on the server. Set `canceled`
|
||||
/// to stop the siege. There is one siege per dimension.
|
||||
/// @API:native_village_siege@.
|
||||
/// 389.
|
||||
pub(super) fn native_village_siege(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -5715,6 +5891,39 @@ impl Game<'_> {
|
||||
mod_api::dispatch_broadcast_message(self, mod_api::mods(), line, cancel);
|
||||
}
|
||||
|
||||
/// NeoForge's `ClientChatReceivedEvent`: a chat line from the server is
|
||||
/// about to show, once Chat Delay lets it through. `system` is a system
|
||||
/// message (`ClientChatReceivedEvent.System`), shown in the action bar
|
||||
/// when `overlay` is set; otherwise a player or disguised message in its
|
||||
/// chat type `chat_type` (`getBoundChatType`, its registry name; `None`
|
||||
/// for a system message or an inline chat type). `sender` is the player's
|
||||
/// UUID, 0 (`Util.NIL_UUID`) for system and disguised messages. `message`
|
||||
/// starts as the decorated line; replace it and the chat shows the new
|
||||
/// one, while the narrator still says the original. Set `cancel` and it
|
||||
/// doesn't show: a delayed message lets the next one through. Lines the
|
||||
/// client adds itself and validation errors are not told. Client-side.
|
||||
/// API @API:client_chat_received@.
|
||||
pub(super) fn native_client_chat_received(
|
||||
&mut self,
|
||||
system: bool,
|
||||
overlay: bool,
|
||||
sender: u128,
|
||||
chat_type: Option<&str>,
|
||||
message: &mut Text,
|
||||
cancel: &mut bool,
|
||||
) {
|
||||
mod_api::dispatch_client_chat_received(
|
||||
self,
|
||||
mod_api::mods(),
|
||||
system,
|
||||
overlay,
|
||||
sender,
|
||||
chat_type,
|
||||
message,
|
||||
cancel,
|
||||
);
|
||||
}
|
||||
|
||||
/// The dedicated server's whitelist changed to `enabled`. 305.
|
||||
pub(super) fn native_whitelist_state_update(&mut self, enabled: bool) {
|
||||
mod_api::dispatch_whitelist_state_update(self, mod_api::mods(), enabled);
|
||||
@@ -5758,7 +5967,7 @@ impl Game<'_> {
|
||||
/// mob that dealt it, 0 for none or a player. Set `cancel` to keep it alive:
|
||||
/// Horizon decides death by health, so it stays at 1 health. The hook
|
||||
/// fires once per death attempt. Armor stands, boats and other non-mob
|
||||
/// entities are not told. API @API:living_death@.
|
||||
/// entities are not told. API 392.
|
||||
pub(super) fn native_living_death(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -5785,7 +5994,7 @@ impl Game<'_> {
|
||||
/// lightning. `outcome` is the new type. Set `cancel` to keep the mob as
|
||||
/// it is; a drowning zombie starts its timers again. Not told: a
|
||||
/// mooshroom a player shears, a zombie villager's cure, a villager turned
|
||||
/// by a zombie. API @API:living_conversion_pre@.
|
||||
/// by a zombie. API 391.
|
||||
pub(super) fn native_living_conversion_pre(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -5807,7 +6016,7 @@ impl Game<'_> {
|
||||
/// `LivingConversionEvent.Post`): `entity` is gone, `converted` took its
|
||||
/// place. Fires with `native_entity_transform`, after the new mob joined
|
||||
/// the level, for the conversions `native_living_conversion_pre` tells.
|
||||
/// API @API:living_conversion_post@.
|
||||
/// API 390.
|
||||
pub(super) fn native_living_conversion_post(
|
||||
&mut self,
|
||||
entity: u32,
|
||||
@@ -6531,7 +6740,7 @@ impl Game<'_> {
|
||||
/// what is added to the entity's velocity; a player's client is sent the
|
||||
/// same. Wind bursts too, except dragons and end crystals. End crystals
|
||||
/// are also omitted from ordinary blasts: Horizon removes them without a
|
||||
/// velocity to change. Server-side. @API:explosion_knockback@.
|
||||
/// velocity to change. Server-side. 374.
|
||||
pub(super) fn native_explosion_knockback(
|
||||
&mut self,
|
||||
dimension: &str,
|
||||
@@ -7435,7 +7644,7 @@ impl Game<'_> {
|
||||
/// DamageSource position. The acting player's network id is `causing`
|
||||
/// (also the direct entity). Weapon enchantments include their levels.
|
||||
/// Runs after the victim's invulnerability check (an invulnerable or dead victim fires nothing) and before v1; cancellation refuses
|
||||
/// delivery. Other damage routes still only fire v1. API @API:living_incoming_damage_v2@.
|
||||
/// delivery. Other damage routes still only fire v1. API 384.
|
||||
pub(super) fn native_living_incoming_damage_v2(
|
||||
&mut self,
|
||||
entity: i32,
|
||||
@@ -7518,7 +7727,7 @@ impl Game<'_> {
|
||||
/// include the guest whose action runs. Includes mob melee and armor-stand sweeps, not
|
||||
/// direct velocity pushes (explosions, Punch arrows, hoglin throws). Non-finite changes
|
||||
/// are restored before resistance. Server-side, without self: damage borrows the victim.
|
||||
/// API @API:living_knockback_v2@.
|
||||
/// API 383.
|
||||
pub(super) fn native_living_knockback_v2(
|
||||
entity: i32,
|
||||
kind: &str,
|
||||
@@ -8719,6 +8928,19 @@ impl Game<'_> {
|
||||
pub(super) fn native_screen_title(&self) -> Option<String> {
|
||||
self.remote_menu_title().map(|t| t.plain())
|
||||
}
|
||||
/// The block of the mod screen that is open (`native_screen`), as the
|
||||
/// server named it when the block opened its menu type; None when no mod
|
||||
/// screen is open. @API:menu_extra_data@.
|
||||
pub(super) fn native_screen_pos(&self) -> Option<[i32; 3]> {
|
||||
self.shown_native_screen()?.pos()
|
||||
}
|
||||
/// What the block's mod wrote for the mod screen that is open
|
||||
/// (`native_menu_extra_data` on the server), empty when it wrote nothing;
|
||||
/// None when no mod screen is open. @API:menu_extra_data@.
|
||||
pub(super) fn native_screen_extra_data(&self) -> Option<&[u8]> {
|
||||
let screen = self.shown_native_screen()?;
|
||||
Some(screen.extra.as_ref().map_or(&[], |e| e.data.as_slice()))
|
||||
}
|
||||
/// A data file of the game's resources, content mods' included
|
||||
/// (`data/mymod/machines.json`), when there is one: what a mod's content
|
||||
/// says, read at runtime. Only relative paths of plain names. API 20.
|
||||
@@ -9436,12 +9658,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// A menu opened on this client with its slots under `key`, on block
|
||||
/// `target`: a mod block's is laid out by its mod (`native_screen`).
|
||||
/// `extra` is what the server sent for this window first.
|
||||
pub(super) fn open_native_screen(
|
||||
&mut self,
|
||||
key: Pos,
|
||||
target: Option<Pos>,
|
||||
window: i32,
|
||||
menu: &str,
|
||||
extra: Option<horizon_protocol::menu_extra::Message>,
|
||||
) {
|
||||
self.mod_screen = None;
|
||||
let Some(t) = target else {
|
||||
@@ -9475,6 +9699,7 @@ impl Game<'_> {
|
||||
block,
|
||||
screen,
|
||||
data: Vec::new(),
|
||||
extra,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9861,6 +10086,18 @@ impl Game<'_> {
|
||||
shake_pose(bounded_shake(shake, self.screen_effect_scale()))
|
||||
}
|
||||
|
||||
/// The camera native mods draw this frame from (`native_camera_pose`), starting from
|
||||
/// `view`, vanilla's position, yaw and pitch.
|
||||
pub(super) fn native_camera_pose_now(
|
||||
&self,
|
||||
partial: f32,
|
||||
view: (glam::DVec3, f32, f32),
|
||||
) -> (glam::DVec3, f32, f32) {
|
||||
let (mut position, mut yaw, mut pitch) = (view.0.to_array(), view.1, view.2);
|
||||
self.native_camera_pose(partial, &mut position, &mut yaw, &mut pitch);
|
||||
posed_camera(view, position, yaw, pitch)
|
||||
}
|
||||
|
||||
/// What native mods hand shader packs in this frame (`native_shader_params`),
|
||||
/// with values that aren't finite as 0.
|
||||
pub(super) fn native_shader_params_now(&self, partial: f32) -> [[f32; 4]; 8] {
|
||||
@@ -10235,6 +10472,26 @@ mod tests {
|
||||
assert_eq!(super::chars_to_type("", |_| false), None);
|
||||
}
|
||||
|
||||
/// A mod's camera replaces vanilla's value by value; one that isn't finite keeps vanilla's.
|
||||
#[test]
|
||||
fn a_mod_camera_replaces_each_finite_value() {
|
||||
use super::posed_camera;
|
||||
let vanilla = (glam::DVec3::new(1.0, 2.0, 3.0), 10.0, 20.0);
|
||||
assert_eq!(
|
||||
posed_camera(vanilla, [-40.5, 90.0, 1e7], 270.0, -35.0),
|
||||
(glam::DVec3::new(-40.5, 90.0, 1e7), 270.0, -35.0)
|
||||
);
|
||||
assert_eq!(
|
||||
posed_camera(
|
||||
vanilla,
|
||||
[f64::NAN, 5.0, f64::INFINITY],
|
||||
f32::NAN,
|
||||
f32::NEG_INFINITY
|
||||
),
|
||||
(glam::DVec3::new(1.0, 5.0, 3.0), 10.0, 20.0)
|
||||
);
|
||||
}
|
||||
|
||||
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
|
||||
/// value that isn't finite is none.
|
||||
#[test]
|
||||
@@ -10415,6 +10672,7 @@ mod tests {
|
||||
block: "gtmod:machine".into(),
|
||||
screen: super::NativeScreen::default(),
|
||||
data: Vec::new(),
|
||||
extra: None,
|
||||
};
|
||||
assert!(s.set_data(4, 1, 7));
|
||||
assert!(!s.set_data(3, 1, 99));
|
||||
|
||||
@@ -37,7 +37,7 @@ impl Game<'_> {
|
||||
/// The block state at `pos` (`Level.getBlockState`) as `/setblock` takes
|
||||
/// it: `minecraft:oak_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]`.
|
||||
/// None for a chunk that isn't loaded or a `y` outside the world. API
|
||||
/// @API:level_reads@.
|
||||
/// 381.
|
||||
pub(super) fn native_level_block_state(
|
||||
&self,
|
||||
level: LevelRef,
|
||||
@@ -47,14 +47,14 @@ impl Game<'_> {
|
||||
.map(|state| self.state_string(state))
|
||||
}
|
||||
/// The block at `pos` without its properties: `minecraft:oak_stairs`.
|
||||
/// None as for `block_state`. API @API:level_reads@.
|
||||
/// None as for `block_state`. API 381.
|
||||
pub(super) fn native_level_block(&self, level: LevelRef, pos: [i32; 3]) -> Option<String> {
|
||||
self.native_level_state(level, pos)
|
||||
.map(|state| self.res.facts().get(state).name.to_string())
|
||||
}
|
||||
/// Property `key` of the block state at `pos` (`BlockState.getValue`):
|
||||
/// `east` for `facing`. None for a block without it, or as for
|
||||
/// `block_state`. API @API:level_reads@.
|
||||
/// `block_state`. API 381.
|
||||
pub(super) fn native_level_block_property(
|
||||
&self,
|
||||
level: LevelRef,
|
||||
@@ -72,7 +72,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// Whether `pos` is inside the world's height and its chunk is loaded
|
||||
/// (`Level.isLoaded`, which is false outside `isInValidBounds`): a block
|
||||
/// read or write there finds the world. API @API:level_reads@.
|
||||
/// read or write there finds the world. API 381.
|
||||
pub(super) fn native_level_is_loaded(&self, level: LevelRef, pos: [i32; 3]) -> bool {
|
||||
self.native_level_world(level).is_some_and(|w| {
|
||||
(w.bottom_y()..w.top_y()).contains(&pos[1])
|
||||
@@ -80,19 +80,19 @@ impl Game<'_> {
|
||||
})
|
||||
}
|
||||
/// The lowest block `y` and the height of the level
|
||||
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API @API:level_reads@.
|
||||
/// (`LevelHeightAccessor.getMinY`, `getHeight`). API 381.
|
||||
pub(super) fn native_level_height(&self, level: LevelRef) -> Option<(i32, i32)> {
|
||||
self.native_level_world(level)
|
||||
.map(|w| (w.shape.min_y, w.shape.height))
|
||||
}
|
||||
/// The ticks the server has run (`Level.getGameTime`), shared by every
|
||||
/// level as in vanilla. API @API:level_reads@.
|
||||
/// level as in vanilla. API 381.
|
||||
pub(super) fn native_level_game_time(&self, level: LevelRef) -> Option<i64> {
|
||||
self.native_level_world(level)?;
|
||||
Some(self.world.game_time)
|
||||
}
|
||||
/// The time of day in ticks (`Level.getOverworldClockTime`), shared by
|
||||
/// every level; it keeps counting past 24000. API @API:level_reads@.
|
||||
/// every level; it keeps counting past 24000. API 381.
|
||||
pub(super) fn native_level_day_time(&self, level: LevelRef) -> Option<i64> {
|
||||
self.native_level_world(level)?;
|
||||
Some(self.world.day_time)
|
||||
|
||||
@@ -62,14 +62,14 @@ impl Game<'_> {
|
||||
}
|
||||
/// The entity as a living entity (vanilla's `LivingEntity`: a mob, a
|
||||
/// player, an armor stand or the dragon), or None for another kind or a
|
||||
/// stale handle. API @API:living_refs@.
|
||||
/// stale handle. API 382.
|
||||
pub(super) fn native_living(&self, entity: EntityRef) -> Option<LivingRef> {
|
||||
let living = LivingRef::new(entity);
|
||||
self.native_living_target(living).map(|_| living)
|
||||
}
|
||||
/// The stack in equipment slot `slot` (`LivingEntity.getItemBySlot`):
|
||||
/// None when it is empty, the entity has no such slot (a player has no
|
||||
/// body or saddle slot), or the handle is stale. API @API:living_refs@.
|
||||
/// body or saddle slot), or the handle is stale. API 382.
|
||||
pub(super) fn native_living_equipment(
|
||||
&self,
|
||||
living: LivingRef,
|
||||
@@ -101,7 +101,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
/// The stack in hand `hand` (`LivingEntity.getItemInHand`). API
|
||||
/// @API:living_refs@.
|
||||
/// 382.
|
||||
pub(super) fn native_living_hand_item(
|
||||
&self,
|
||||
living: LivingRef,
|
||||
@@ -118,7 +118,7 @@ impl Game<'_> {
|
||||
/// when the entity has no such slot: a player has no body or saddle
|
||||
/// slot, the dragon none. A mob has every slot, head and body apart
|
||||
/// (`EntityEquipment`), as `/item` does; Horizon keeps no mob saddle here
|
||||
/// yet. API @API:living_refs@.
|
||||
/// yet. API 382.
|
||||
pub(super) fn native_living_set_equipment(
|
||||
&mut self,
|
||||
living: LivingRef,
|
||||
|
||||
@@ -162,4 +162,29 @@ impl Game<'_> {
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A mod's chat line or command (`/` first), sent as `send_chat` sends
|
||||
/// it (ClientPacketListener.sendChat / sendCommand), to any server: no
|
||||
/// chat history, and no client chat event (vanilla's `ClientChatEvent`
|
||||
/// comes only from ChatScreen). An error when the player isn't in a
|
||||
/// server's play state or the Chat option blocks the message. API
|
||||
/// @API:send_chat@.
|
||||
pub(super) fn native_send_chat(&mut self, message: &str) -> Result<(), String> {
|
||||
let Some(remote) = self.remote.as_ref() else {
|
||||
return Err("not connected to a server".into());
|
||||
};
|
||||
// A server that reconfigures the player has them out of the play state.
|
||||
if !remote.joined || remote.reconfiguring.is_some() {
|
||||
return Err("the player has not joined yet".into());
|
||||
}
|
||||
let message = message.trim();
|
||||
if message.is_empty() {
|
||||
return Err("the message is empty".into());
|
||||
}
|
||||
if !self.chat_abilities().allows(message) {
|
||||
return Err("the Chat option blocks this message".into());
|
||||
}
|
||||
self.send_chat(message);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! Native mods' own data folders, read on each data load of a server
|
||||
//! (NeoForge's `AddServerReloadListenersEvent`, docs/native-mods.md). A mod
|
||||
//! names a folder under `data/<namespace>/` (`native_add_server_reload_listeners`);
|
||||
//! when a world opens and on `/reload`, the folder's `.json` files of the
|
||||
//! resource root (vanilla's and content mods') and of the world's enabled data
|
||||
//! packs are read as vanilla's `SimpleJsonResourceReloadListener.prepare` reads
|
||||
//! its folder, and handed to the mod (`native_server_reload_listener_apply`).
|
||||
use super::{Game, functions};
|
||||
use horizon_client::datapack::DataPack;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Component, Path};
|
||||
|
||||
/// A folder of JSON files a mod reads from the data packs, as vanilla's
|
||||
/// `SimpleJsonResourceReloadListener` over `FileToIdConverter.json(folder)`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct NativeReloadListener {
|
||||
/// `mymod:ore_veins`: which of the mod's listeners the files are for.
|
||||
pub id: String,
|
||||
/// The folder under `data/<namespace>/`: `ore_veins`, `mymod/ore_veins`.
|
||||
pub folder: String,
|
||||
}
|
||||
|
||||
impl NativeReloadListener {
|
||||
/// The `.json` files of `data/<namespace>/<folder>/`, for listener `id`.
|
||||
pub fn json(id: &str, folder: &str) -> Self {
|
||||
Self {
|
||||
id: id.to_owned(),
|
||||
folder: folder.to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `folder` stays below `data/<namespace>/`.
|
||||
fn folder_ok(folder: &str) -> bool {
|
||||
let path = Path::new(folder);
|
||||
!folder.contains('\\')
|
||||
&& path.components().next().is_some()
|
||||
&& path.components().all(|c| matches!(c, Component::Normal(_)))
|
||||
}
|
||||
|
||||
/// `FileToIdConverter.fileToId`: `data/<ns>/<folder>/<path>.json` is
|
||||
/// `<ns>:<path>`; None for a file of another folder or extension.
|
||||
fn file_id(file: &str, folder: &str) -> Option<String> {
|
||||
let (ns, rest) = file.strip_prefix("data/")?.split_once('/')?;
|
||||
let path = rest
|
||||
.strip_prefix(folder)?
|
||||
.strip_prefix('/')?
|
||||
.strip_suffix(".json")?;
|
||||
(!path.is_empty()).then(|| format!("{ns}:{path}"))
|
||||
}
|
||||
|
||||
/// The folder's files over `packs`, the bottom one first: each id's file is
|
||||
/// the top pack's (`ResourceManager.listResources`), parsed; one that isn't
|
||||
/// JSON is logged and skipped, as `prepare` does. Sorted by id.
|
||||
fn read_folder(
|
||||
packs: &[&DataPack],
|
||||
listener: &str,
|
||||
folder: &str,
|
||||
) -> Vec<(String, serde_json::Value)> {
|
||||
let mut top: BTreeMap<String, (&DataPack, String)> = BTreeMap::new();
|
||||
for pack in packs {
|
||||
for file in pack.list_folder(folder) {
|
||||
if let Some(id) = file_id(&file, folder) {
|
||||
top.insert(id, (pack, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = Vec::with_capacity(top.len());
|
||||
for (id, (pack, file)) in top {
|
||||
let Some(bytes) = pack.read(&file) else {
|
||||
continue;
|
||||
};
|
||||
match serde_json::from_slice(&bytes) {
|
||||
Ok(json) => files.push((id, json)),
|
||||
Err(e) => log::error!(
|
||||
"Horizon: couldn't parse data file '{id}' from '{file}' in pack {} for {listener}: {e}",
|
||||
pack.id
|
||||
),
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// A server's data load (a world opening, `/reload`): every listener's
|
||||
/// files go to its mod, before `native_server_data_loaded`.
|
||||
pub(super) fn native_reload_listeners(&mut self) {
|
||||
let mut listeners = Vec::new();
|
||||
self.native_add_server_reload_listeners(&mut listeners);
|
||||
if listeners.is_empty() {
|
||||
return;
|
||||
}
|
||||
let stack = functions::pack_stack(
|
||||
&self.world_root,
|
||||
&self.options.resources,
|
||||
self.cmds.vanilla_disabled,
|
||||
&self.level.enabled_features,
|
||||
);
|
||||
let packs: Vec<&DataPack> = stack.builtin.iter().chain(stack.enabled_packs()).collect();
|
||||
for listener in &listeners {
|
||||
if !folder_ok(&listener.folder) {
|
||||
log::warn!(
|
||||
"Horizon: reload listener {} folder {:?} is not a folder under data/<namespace>/",
|
||||
listener.id,
|
||||
listener.folder
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let files = read_folder(&packs, &listener.id, &listener.folder);
|
||||
self.native_server_reload_listener_apply(listener, &files);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Folder(PathBuf);
|
||||
|
||||
impl Folder {
|
||||
fn new(name: &str) -> Self {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"horizon-reload-listeners-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
Self(dir)
|
||||
}
|
||||
/// A folder pack with these files.
|
||||
fn pack(&self, name: &str, files: &[(&str, &str)]) -> DataPack {
|
||||
let root = self.0.join(name);
|
||||
for (path, text) in files {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
DataPack::builtin(&root)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Folder {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_top_pack_wins_each_id_and_ids_drop_the_folder_and_extension() {
|
||||
let dir = Folder::new("top");
|
||||
let root = dir.pack(
|
||||
"root",
|
||||
&[
|
||||
("data/gt/ore_veins/copper.json", r#"{"weight": 1}"#),
|
||||
("data/gt/ore_veins/deep/tin.json", r#"{"weight": 2}"#),
|
||||
("data/other/ore_veins/zinc.json", r#"{"weight": 3}"#),
|
||||
// Another folder, and a name that only starts like it.
|
||||
("data/gt/recipe/plate.json", "{}"),
|
||||
("data/gt/ore_veins_old/lead.json", "{}"),
|
||||
],
|
||||
);
|
||||
let world = dir.pack(
|
||||
"world",
|
||||
&[
|
||||
("data/gt/ore_veins/copper.json", r#"{"weight": 10}"#),
|
||||
("data/gt/ore_veins/notes.txt", "not json, not read"),
|
||||
],
|
||||
);
|
||||
let files = read_folder(&[&root, &world], "gt:veins", "ore_veins");
|
||||
assert_eq!(
|
||||
files,
|
||||
[
|
||||
("gt:copper".to_owned(), json!({"weight": 10})),
|
||||
("gt:deep/tin".to_owned(), json!({"weight": 2})),
|
||||
("other:zinc".to_owned(), json!({"weight": 3})),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_top_file_is_skipped_without_falling_back() {
|
||||
let dir = Folder::new("broken");
|
||||
let root = dir.pack(
|
||||
"root",
|
||||
&[
|
||||
("data/gt/machines/press.json", r#"{"tier": 1}"#),
|
||||
("data/gt/machines/mixer.json", r#"{"tier": 2}"#),
|
||||
],
|
||||
);
|
||||
let world = dir.pack("world", &[("data/gt/machines/press.json", "{ tier: ")]);
|
||||
let files = read_folder(&[&root, &world], "gt:machines", "machines");
|
||||
assert_eq!(files, [("gt:mixer".to_owned(), json!({"tier": 2}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nested_folder_reads_only_below_it() {
|
||||
let dir = Folder::new("nested");
|
||||
let pack = dir.pack(
|
||||
"root",
|
||||
&[
|
||||
("data/gt/gt/veins/a.json", "1"),
|
||||
("data/gt/gt/other/b.json", "2"),
|
||||
],
|
||||
);
|
||||
let files = read_folder(&[&pack], "gt:veins", "gt/veins");
|
||||
assert_eq!(files, [("gt:a".to_owned(), json!(1))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zipped_pack_reads_only_the_folder() {
|
||||
let dir = Folder::new("zip");
|
||||
let packs = dir.0.join("datapacks");
|
||||
fs::create_dir_all(&packs).unwrap();
|
||||
let mut zip = zip::ZipWriter::new(fs::File::create(packs.join("veins.zip")).unwrap());
|
||||
for (name, text) in [
|
||||
("pack.mcmeta", r#"{"pack":{"pack_format":48}}"#),
|
||||
("data/gt/ore_veins/iron.json", r#"{"y": -16}"#),
|
||||
("data/gt/ore_veins_old/lead.json", "{}"),
|
||||
("data/gt/recipe/plate.json", "{}"),
|
||||
] {
|
||||
zip.start_file(name, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut zip, text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
let found = horizon_client::datapack::discover_folder(&packs);
|
||||
let refs: Vec<&DataPack> = found.iter().collect();
|
||||
let files = read_folder(&refs, "gt:veins", "ore_veins");
|
||||
assert_eq!(files, [("gt:iron".to_owned(), json!({"y": -16}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_outside_the_namespace_is_refused() {
|
||||
assert!(folder_ok("ore_veins"));
|
||||
assert!(folder_ok("gt/ore_veins"));
|
||||
for bad in ["", "..", "../recipe", "gt/../../x", "/abs", "a\\b"] {
|
||||
assert!(!folder_ok(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,6 +357,10 @@ impl Game<'_> {
|
||||
self.settings.horizon.translucent_batching,
|
||||
self.settings.horizon.wide_frame(),
|
||||
super::shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
(
|
||||
self.settings.file.int("textureFiltering"),
|
||||
self.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)
|
||||
})
|
||||
.transpose()
|
||||
|
||||
@@ -109,6 +109,11 @@ pub(super) struct PotionCloud {
|
||||
/// Who its instant damage names (`indirectMagic(cloud, owner)`): the
|
||||
/// thrower, else the cloud.
|
||||
source: Text,
|
||||
/// `AreaEffectCloud.getOwner`: the thrower (`ThrownLingeringPotion.onHitAsPotion`), whose
|
||||
/// kill a mob's death to its instant damage is.
|
||||
owner: Option<Who>,
|
||||
/// On a server, the guest (by connection) who threw it.
|
||||
guest: Option<u32>,
|
||||
}
|
||||
impl PotionCloud {
|
||||
/// `AreaEffectCloud.updateParticle`: an entity effect in the potion's colour.
|
||||
@@ -131,6 +136,18 @@ pub(super) struct PotionOwner {
|
||||
attacker: Option<u32>,
|
||||
/// A player threw it: the water damage `indirectMagic` deals is theirs.
|
||||
player: bool,
|
||||
/// On a server, the guest (by connection) who threw it: whose kill the mobs it hurts are
|
||||
/// (`LivingEntity.lastHurtByPlayer`).
|
||||
guest: Option<u32>,
|
||||
}
|
||||
impl PotionOwner {
|
||||
/// The owner as a damage source's causing entity.
|
||||
fn who(&self) -> Option<Who> {
|
||||
match self.attacker {
|
||||
Some(id) => Some(Who::Mob(id)),
|
||||
None => self.player.then_some(Who::Player),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A mob's effect ids carry the `minecraft:` namespace; a mod's has its own.
|
||||
pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
@@ -140,6 +157,9 @@ pub(super) fn mob_effect_id(id: &str) -> Option<&'static str> {
|
||||
/// `WitherSkeleton` shrug off wither, `Spider` and `AbstractNautilus` poison,
|
||||
/// `Parched` weakness): the immune-to-infested and immune-to-oozing tags
|
||||
/// decide first, then the tag of mobs that ignore poison and regeneration.
|
||||
/// `WitherBoss.addEffect` refusing everything is `Mob::add_effect`'s: a cloud
|
||||
/// still takes the wither as a victim (`AreaEffectCloud` asks `canBeAffected`),
|
||||
/// so its instant effects land and it shrinks.
|
||||
pub(super) fn mob_can_be_affected(
|
||||
tags: &std::collections::HashMap<String, std::collections::HashSet<String>>,
|
||||
kind: &str,
|
||||
@@ -1238,7 +1258,15 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
if let Some((p, mob, block)) = hit {
|
||||
landed.push((t.stack.clone(), p, mob, block, t.age, t.owner.clone()));
|
||||
landed.push((
|
||||
t.stack.clone(),
|
||||
p,
|
||||
mob,
|
||||
block,
|
||||
t.age,
|
||||
t.owner.clone(),
|
||||
t.guest,
|
||||
));
|
||||
return false;
|
||||
}
|
||||
t.position = next;
|
||||
@@ -1250,7 +1278,7 @@ impl Game<'_> {
|
||||
t.age < 1200 && t.position.y > -128.0
|
||||
});
|
||||
self.potions.thrown = thrown;
|
||||
for (stack, at, mob, block, age, owner) in landed {
|
||||
for (stack, at, mob, block, age, owner, guest) in landed {
|
||||
// Projectile.onHit (super.onHit of AbstractThrownPotion): a block
|
||||
// is heard at its centre, an entity at the hit.
|
||||
let heard = if mob.is_some() {
|
||||
@@ -1266,6 +1294,7 @@ impl Game<'_> {
|
||||
player: owner.is_some(),
|
||||
name: owner,
|
||||
attacker: None,
|
||||
guest,
|
||||
};
|
||||
self.splash(&stack, at, mob, block, age, &owner);
|
||||
if item != "minecraft:experience_bottle" {
|
||||
@@ -1356,6 +1385,7 @@ impl Game<'_> {
|
||||
(&potion.hidden, &potion.ambient),
|
||||
c.at,
|
||||
&potion.source,
|
||||
(potion.owner, potion.guest),
|
||||
);
|
||||
// setRadiusOnUse(-0.5).
|
||||
c.radius -= 0.5;
|
||||
@@ -1393,6 +1423,8 @@ impl Game<'_> {
|
||||
color,
|
||||
victims: Vec::new(),
|
||||
source: Text::translate("entity.minecraft.area_effect_cloud", &[]),
|
||||
owner: None,
|
||||
guest: None,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1406,6 +1438,7 @@ impl Game<'_> {
|
||||
(hidden, ambient): (&[&'static str], &[&'static str]),
|
||||
at: [f64; 3],
|
||||
source: &Text,
|
||||
(owner, guest): (Option<Who>, Option<u32>),
|
||||
) {
|
||||
// `indirectMagic(cloud, owner)` names the cloud in the death message
|
||||
// (it gives no kill credit).
|
||||
@@ -1460,8 +1493,13 @@ impl Game<'_> {
|
||||
let amount =
|
||||
|base: i32| (0.5 * f64::from(base << amp) + 0.5) as i32 as f32;
|
||||
if (*id == "instant_damage") != inverted {
|
||||
let harm = m.potion_harm(amount(6));
|
||||
m.hurt(harm, Some(at), &mut self.world.random);
|
||||
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(cloud, owner).
|
||||
let source = Damage::indirect_magic(owner, at);
|
||||
if m.damage_by(amount(6), source, "minecraft:area_effect_cloud")
|
||||
&& owner == Some(Who::Player)
|
||||
{
|
||||
self.last_hurt_by_player.insert(mob, guest);
|
||||
}
|
||||
} else {
|
||||
m.heal(amount(4));
|
||||
}
|
||||
@@ -1657,11 +1695,13 @@ impl Game<'_> {
|
||||
name: Some(self.player_display_text()),
|
||||
attacker: None,
|
||||
player: true,
|
||||
guest: self.lan_acting,
|
||||
},
|
||||
Some(Who::Mob(id)) => PotionOwner {
|
||||
name: self.mob_display_name(id),
|
||||
attacker: Some(id),
|
||||
player: false,
|
||||
guest: None,
|
||||
},
|
||||
None => PotionOwner::default(),
|
||||
}
|
||||
@@ -1764,6 +1804,8 @@ impl Game<'_> {
|
||||
source: owner.name.clone().unwrap_or_else(|| {
|
||||
Text::translate("entity.minecraft.area_effect_cloud", &[])
|
||||
}),
|
||||
owner: owner.who(),
|
||||
guest: owner.guest,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -1804,8 +1846,13 @@ impl Game<'_> {
|
||||
};
|
||||
let m = &mut self.mobs[i];
|
||||
if harm {
|
||||
let harm = m.potion_harm((intensity * f64::from(6 << amp) + 0.5) as i32 as f32);
|
||||
m.hurt(harm, Some([at.x, at.y, at.z]), &mut self.world.random);
|
||||
// HealOrHarmMobEffect.applyInstantaneousEffect: indirectMagic(potion, owner).
|
||||
let harm = (intensity * f64::from(6 << amp) + 0.5) as i32 as f32;
|
||||
let source = Damage::indirect_magic(owner.who(), [at.x, at.y, at.z]);
|
||||
if m.damage_by(harm, source, "minecraft:splash_potion") && owner.player {
|
||||
let id = m.id;
|
||||
self.last_hurt_by_player.insert(id, owner.guest);
|
||||
}
|
||||
} else {
|
||||
m.heal((intensity * f64::from(4 << amp) + 0.5) as i32 as f32);
|
||||
}
|
||||
@@ -1907,6 +1954,21 @@ mod tests {
|
||||
use super::*;
|
||||
use horizon_client::screen_effects::nausea_blend_added;
|
||||
|
||||
/// `WitherBoss.canBeAffected` refuses only the wither effect: its `addEffect`
|
||||
/// refuses the rest, but a cloud of strength or healing still counts the boss
|
||||
/// as a victim (`AreaEffectCloud.serverTick` asks `canBeAffected`).
|
||||
#[test]
|
||||
fn a_cloud_still_counts_the_wither_as_a_victim() {
|
||||
let tags = std::collections::HashMap::new();
|
||||
for effect in ["strength", "instant_health"] {
|
||||
assert!(
|
||||
mob_can_be_affected(&tags, "minecraft:wither", effect),
|
||||
"{effect}"
|
||||
);
|
||||
}
|
||||
assert!(!mob_can_be_affected(&tags, "minecraft:wither", "wither"));
|
||||
}
|
||||
|
||||
/// #5876: a potion thrown straight up from inside its thrower's box does
|
||||
/// not hit them while it climbs out, then hits them on the way down;
|
||||
/// another player in its path is hit at once.
|
||||
@@ -2070,6 +2132,8 @@ mod tests {
|
||||
color: 0,
|
||||
victims: Vec::new(),
|
||||
source: Text::empty(),
|
||||
owner: None,
|
||||
guest: None,
|
||||
}),
|
||||
}];
|
||||
assert!(!collect_dragon_breath(player_box, &mut lingering));
|
||||
|
||||
@@ -195,14 +195,18 @@ fn menu_title(tag: &Tag) -> horizon_client::text::Text {
|
||||
}
|
||||
|
||||
/// A player message Chat Delay holds back, its signature and trust level,
|
||||
/// and what the narrator says of it once it shows.
|
||||
type QueuedChat = (
|
||||
horizon_client::text::Text,
|
||||
horizon_client::chat::ChatSource,
|
||||
Option<Signature>,
|
||||
ChatTrust,
|
||||
String,
|
||||
);
|
||||
/// what the narrator says of it once it shows, and who sent it in which
|
||||
/// chat type, for mods told when it shows (`ClientChatReceivedEvent`).
|
||||
struct QueuedChat {
|
||||
line: horizon_client::text::Text,
|
||||
signature: Option<Signature>,
|
||||
trust: ChatTrust,
|
||||
narration: String,
|
||||
/// The player's UUID, 0 for disguised chat (`Util.NIL_UUID`).
|
||||
sender: u128,
|
||||
/// The chat type's network ID (`ChatType.Bound.chatType`).
|
||||
chat_type: i32,
|
||||
}
|
||||
|
||||
/// `ChatTrustLevel`; a modified message keeps the signed content it was
|
||||
/// changed from.
|
||||
@@ -537,6 +541,9 @@ pub(super) struct Remote {
|
||||
pub(super) menu_title: Option<horizon_client::text::Text>,
|
||||
state_id: i32,
|
||||
use_target: Option<[i32; 3]>,
|
||||
/// A mod menu's extra data, kept for the `OpenScreen` of its window that
|
||||
/// follows (`horizon:menu_extra`).
|
||||
menu_extra: Option<horizon_protocol::menu_extra::Message>,
|
||||
entities: HashMap<i32, Tracked>,
|
||||
lerps: HashMap<i32, Lerp>,
|
||||
/// The minecarts the server moves with `ClientboundMoveMinecartPacket`
|
||||
@@ -1039,6 +1046,7 @@ impl Game<'_> {
|
||||
menu_title: None,
|
||||
state_id: 0,
|
||||
use_target: None,
|
||||
menu_extra: None,
|
||||
entities: HashMap::new(),
|
||||
lerps: HashMap::new(),
|
||||
cart_lerps: HashMap::new(),
|
||||
@@ -1398,10 +1406,6 @@ impl Game<'_> {
|
||||
signature: Option<Signature>,
|
||||
trust: ChatTrust,
|
||||
) -> bool {
|
||||
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
|
||||
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
|
||||
return false;
|
||||
}
|
||||
if !self.ui.chat.shows(source) {
|
||||
return false;
|
||||
}
|
||||
@@ -1442,21 +1446,13 @@ impl Game<'_> {
|
||||
/// `ChatListener.handleMessage` for player and disguised chat: held in the
|
||||
/// queue while Chat Delay is set, shown at once otherwise. `narration` is
|
||||
/// the chat type's narration of it (`decorateNarration`).
|
||||
pub(super) fn handle_player_chat(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
signature: Option<Signature>,
|
||||
trust: ChatTrust,
|
||||
narration: String,
|
||||
) {
|
||||
let source = horizon_client::chat::ChatSource::Player;
|
||||
fn handle_player_chat(&mut self, chat: QueuedChat) {
|
||||
if self.chat_delay_ms() > 0 {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_queue
|
||||
.push_back((line, source, signature, trust, narration));
|
||||
r.chat_queue.push_back(chat);
|
||||
}
|
||||
} else {
|
||||
self.accept_chat(line, source, signature, trust, &narration);
|
||||
self.accept_chat(chat);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1484,7 +1480,14 @@ impl Game<'_> {
|
||||
let narration = self
|
||||
.decorate_chat(chat_type, &error, Decoration::Narration)
|
||||
.plain();
|
||||
self.handle_player_chat(line, None, ChatTrust::Error, narration);
|
||||
self.handle_player_chat(QueuedChat {
|
||||
line,
|
||||
signature: None,
|
||||
trust: ChatTrust::Error,
|
||||
narration,
|
||||
sender: 0,
|
||||
chat_type: chat_type.chat_type,
|
||||
});
|
||||
}
|
||||
|
||||
/// `ChatTrustLevel.evaluate` of a player message, with
|
||||
@@ -1550,7 +1553,7 @@ impl Game<'_> {
|
||||
r.secure_chat.ignore_pending(&signature);
|
||||
let queued = r.chat_queue.len();
|
||||
r.chat_queue
|
||||
.retain(|(_, _, s, _, _)| s.as_ref() != Some(&signature));
|
||||
.retain(|chat| chat.signature.as_ref() != Some(&signature));
|
||||
if r.chat_queue.len() == queued {
|
||||
self.ui
|
||||
.chat
|
||||
@@ -1561,15 +1564,9 @@ impl Game<'_> {
|
||||
|
||||
/// A message's `accept`: shown, it is narrated (`narrateChatMessage`) and
|
||||
/// starts the Chat Delay wait again.
|
||||
fn accept_chat(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
signature: Option<Signature>,
|
||||
trust: ChatTrust,
|
||||
narration: &str,
|
||||
) -> bool {
|
||||
let shown = self.player_chat_line(line, source, signature, trust, narration);
|
||||
fn accept_chat(&mut self, chat: QueuedChat) -> bool {
|
||||
let signature = chat.signature;
|
||||
let shown = self.player_chat_line(chat);
|
||||
if let Some(signature) = &signature {
|
||||
self.mark_message_processed(signature, shown);
|
||||
}
|
||||
@@ -1579,28 +1576,60 @@ impl Game<'_> {
|
||||
shown
|
||||
}
|
||||
|
||||
/// `showMessageToPlayer`: the line joins the chat if the Chat option and
|
||||
/// Only Show Secure Chat let it through, and the narrator says it
|
||||
/// (`sayChatQueued`).
|
||||
fn player_chat_line(
|
||||
&mut self,
|
||||
line: horizon_client::text::Text,
|
||||
source: horizon_client::chat::ChatSource,
|
||||
signature: Option<Signature>,
|
||||
trust: ChatTrust,
|
||||
narration: &str,
|
||||
) -> bool {
|
||||
// handleChatMessageError says its line as system chat.
|
||||
/// `showMessageToPlayer`: the line joins the chat if Only Show Secure
|
||||
/// Chat, mods and the Chat option let it through, and the narrator says
|
||||
/// it (`sayChatQueued`).
|
||||
fn player_chat_line(&mut self, chat: QueuedChat) -> bool {
|
||||
let QueuedChat {
|
||||
mut line,
|
||||
signature,
|
||||
trust,
|
||||
narration,
|
||||
sender,
|
||||
chat_type,
|
||||
} = chat;
|
||||
// ChatListener.showMessageToPlayer: Only Show Secure Chat drops what is not secure.
|
||||
if matches!(trust, ChatTrust::NotSecure) && self.settings.file.bool("onlyShowSecureChat") {
|
||||
return false;
|
||||
}
|
||||
// handleChatMessageError says its line as system chat, and mods are not told of it.
|
||||
let error = matches!(trust, ChatTrust::Error);
|
||||
if !error && !self.chat_received(false, false, sender, Some(chat_type), &mut line) {
|
||||
return false;
|
||||
}
|
||||
let source = horizon_client::chat::ChatSource::Player;
|
||||
let shown = self.signed_chat_line(line, source, signature, trust);
|
||||
if shown && error {
|
||||
self.say_system_chat_queued(&horizon_client::text::Text::literal(narration));
|
||||
self.say_system_chat_queued(&horizon_client::text::Text::literal(&narration));
|
||||
} else if shown {
|
||||
self.say_chat_queued(narration);
|
||||
self.say_chat_queued(&narration);
|
||||
}
|
||||
shown
|
||||
}
|
||||
|
||||
/// NeoForge's `ClientChatReceivedEvent` (`ClientHooks.onClientChat`,
|
||||
/// `onClientPlayerChat` and `onClientSystemChat`): mods may replace
|
||||
/// `line` or cancel it. False when cancelled.
|
||||
fn chat_received(
|
||||
&mut self,
|
||||
system: bool,
|
||||
overlay: bool,
|
||||
sender: u128,
|
||||
chat_type: Option<i32>,
|
||||
line: &mut horizon_client::text::Text,
|
||||
) -> bool {
|
||||
if horizon_client::mod_api::mods().is_empty() {
|
||||
return true;
|
||||
}
|
||||
let registries = self.remote.as_ref().map(|r| Arc::clone(&r.registries));
|
||||
let name = chat_type
|
||||
.zip(registries.as_deref())
|
||||
.and_then(|(id, registries)| registries.name("minecraft:chat_type", id));
|
||||
let mut cancel = false;
|
||||
self.native_client_chat_received(system, overlay, sender, name, line, &mut cancel);
|
||||
!cancel
|
||||
}
|
||||
|
||||
/// `ChatListener.messageDelay`: Chat Delay in milliseconds, as Java
|
||||
/// truncates it.
|
||||
fn chat_delay_ms(&self) -> i64 {
|
||||
@@ -1640,10 +1669,8 @@ impl Game<'_> {
|
||||
}
|
||||
// A message the Chat option hides is dropped and the next one is tried,
|
||||
// until one shows.
|
||||
while let Some((line, source, signature, trust, narration)) =
|
||||
self.remote.as_mut().and_then(|r| r.chat_queue.pop_front())
|
||||
{
|
||||
if self.accept_chat(line, source, signature, trust, &narration) {
|
||||
while let Some(chat) = self.remote.as_mut().and_then(|r| r.chat_queue.pop_front()) {
|
||||
if self.accept_chat(chat) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1659,8 +1686,8 @@ impl Game<'_> {
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for (line, source, signature, trust, narration) in queue {
|
||||
self.accept_chat(line, source, signature, trust, &narration);
|
||||
for chat in queue {
|
||||
self.accept_chat(chat);
|
||||
}
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.chat_previous = None;
|
||||
@@ -3051,6 +3078,18 @@ impl Game<'_> {
|
||||
C::CustomPayload(p) if p.channel == horizon_protocol::attachment_sync::CHANNEL => {
|
||||
self.remote_attachment_sync(&p.data);
|
||||
}
|
||||
// A mod menu type's block and its mod's bytes, just before the
|
||||
// menu's `OpenScreen`.
|
||||
C::CustomPayload(p) if p.channel == horizon_protocol::menu_extra::CHANNEL => {
|
||||
match horizon_protocol::menu_extra::decode(&p.data) {
|
||||
Ok(extra) => {
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
r.menu_extra = Some(extra);
|
||||
}
|
||||
}
|
||||
Err(e) => log::debug!("Horizon: a bad menu extra payload: {e}"),
|
||||
}
|
||||
}
|
||||
// The server's recipes its native mods asked to send.
|
||||
C::CustomPayload(p) if p.channel == horizon_client::lan::recipes::CONTENT_CHANNEL => {
|
||||
self.remote_recipe_content(&p.data);
|
||||
@@ -4566,8 +4605,12 @@ impl Game<'_> {
|
||||
}
|
||||
C::SystemChat(p) => {
|
||||
// The component as sent, translated in the client's own
|
||||
// language. ChatListener.handleOverlay for the action bar.
|
||||
let text = remote_text(&p.content);
|
||||
// language; mods may change or cancel it. ChatListener.handleOverlay
|
||||
// for the action bar.
|
||||
let mut text = remote_text(&p.content);
|
||||
if !self.chat_received(true, p.overlay, 0, None, &mut text) {
|
||||
return Ok(());
|
||||
}
|
||||
if p.overlay {
|
||||
self.say_system_queued(&text);
|
||||
self.titles.set_overlay(text, false);
|
||||
@@ -4683,7 +4726,14 @@ impl Game<'_> {
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.handle_player_chat(line, signature, trust, narration);
|
||||
self.handle_player_chat(QueuedChat {
|
||||
line,
|
||||
signature,
|
||||
trust,
|
||||
narration,
|
||||
sender: p.sender,
|
||||
chat_type: p.chat_type.chat_type,
|
||||
});
|
||||
}
|
||||
C::DisguisedChat(p) => {
|
||||
let content = horizon_client::scoreboard::tag_to_json(&p.message);
|
||||
@@ -4691,7 +4741,14 @@ impl Game<'_> {
|
||||
.decorate_chat(&p.chat_type, &content, Decoration::Narration)
|
||||
.plain();
|
||||
let line = self.decorate_chat(&p.chat_type, &content, Decoration::Chat);
|
||||
self.handle_player_chat(line, None, ChatTrust::Secure, narration);
|
||||
self.handle_player_chat(QueuedChat {
|
||||
line,
|
||||
signature: None,
|
||||
trust: ChatTrust::Secure,
|
||||
narration,
|
||||
sender: 0,
|
||||
chat_type: p.chat_type.chat_type,
|
||||
});
|
||||
}
|
||||
C::DeleteChat(p) => self.handle_delete_chat(&p.signature)?,
|
||||
C::PlayerInfoUpdate(p) => {
|
||||
@@ -6038,15 +6095,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,
|
||||
@@ -9434,6 +9495,18 @@ impl Game<'_> {
|
||||
.as_mut()
|
||||
.and_then(|r| r.use_target.take())
|
||||
.map(|t| (t[0], t[1], t[2]));
|
||||
// The server names a mod menu's block itself: it, not the last
|
||||
// clicked block, is the one the menu is of. Only for this window:
|
||||
// any other's is stale.
|
||||
let extra = self
|
||||
.remote
|
||||
.as_mut()
|
||||
.and_then(|r| r.menu_extra.take())
|
||||
.filter(|e| e.window == p.container);
|
||||
let target = extra
|
||||
.as_ref()
|
||||
.map(|e| (e.pos[0], e.pos[1], e.pos[2]))
|
||||
.or(target);
|
||||
if menu == "minecraft:lectern" {
|
||||
// LecternScreen: the book and page come in the menu's content and data.
|
||||
if let Some(r) = self.remote.as_mut() {
|
||||
@@ -9541,7 +9614,7 @@ impl Game<'_> {
|
||||
let key = target.filter(holds).unwrap_or((i32::MIN, 0, 0));
|
||||
self.world.block_entities.insert(key, entity);
|
||||
self.container = Some(key);
|
||||
self.open_native_screen(key, target, p.container, &menu);
|
||||
self.open_native_screen(key, target, p.container, &menu, extra);
|
||||
// A mod menu type shows through its mod's screen only: without one
|
||||
// (`MenuScreens.create` finds no screen) the window closes again.
|
||||
if self.mod_screen.is_none() && mod_type.is_some() {
|
||||
@@ -10564,6 +10637,64 @@ mod tests {
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
|
||||
/// A mod's chat goes out only from a joined player, and only what the
|
||||
/// Chat option allows.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn mod_chat_needs_a_joined_player_and_what_the_chat_option_allows() {
|
||||
use super::super::{
|
||||
Audio, Game, Options, Progress, Settings, execution_role::ExecutionRole, load_resources,
|
||||
};
|
||||
use horizon_protocol::connection::Pipe;
|
||||
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var_os("HORIZON_TEST_RESOURCES")
|
||||
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
|
||||
);
|
||||
let settings = Settings::default();
|
||||
let loaded = load_resources(
|
||||
root.clone(),
|
||||
None,
|
||||
&root,
|
||||
&settings,
|
||||
std::time::Instant::now(),
|
||||
false,
|
||||
true,
|
||||
&Progress::new(),
|
||||
&mut |_, _| unreachable!("no menu handoff"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
|
||||
options.resources = root.clone();
|
||||
options.execution_role = ExecutionRole::Client;
|
||||
let mut game = Game::new(
|
||||
&loaded.resources,
|
||||
loaded.pack,
|
||||
loaded.gui.unwrap(),
|
||||
options,
|
||||
settings,
|
||||
Audio::silent(&root),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(game.native_send_chat("/warp spawn").is_err());
|
||||
let (client, _server) = Pipe::pair();
|
||||
game.connect_local(client, "ChatTest").unwrap();
|
||||
assert!(game.native_send_chat("/warp spawn").is_err());
|
||||
game.remote.as_mut().unwrap().joined = true;
|
||||
assert!(game.native_send_chat(" ").is_err());
|
||||
// Full, then Commands Only, then Hidden.
|
||||
game.settings.chat_visibility = 0;
|
||||
assert!(game.native_send_chat("hello").is_ok());
|
||||
assert!(game.native_send_chat("/warp spawn").is_ok());
|
||||
game.settings.chat_visibility = 1;
|
||||
assert!(game.native_send_chat("hello").is_err());
|
||||
assert!(game.native_send_chat("/warp spawn").is_ok());
|
||||
game.settings.chat_visibility = 2;
|
||||
assert!(game.native_send_chat("/warp spawn").is_err());
|
||||
}
|
||||
|
||||
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
|
||||
/// swing, CONSUME's do not (#6425).
|
||||
#[test]
|
||||
|
||||
@@ -41,7 +41,7 @@ pub(super) fn read_shader_options(
|
||||
let chosen = chosen_shader_options(name);
|
||||
let pack = pack.with_chosen(chosen.clone());
|
||||
let text = pack.read_text("shaders.properties").unwrap_or_default();
|
||||
let properties = Properties::parse(&text, &horizon_shaders::with_options(&pack, macros));
|
||||
let properties = Properties::parse(&text, &horizon_shaders::properties_macros(&pack, macros));
|
||||
let screens = horizon_shaders::Screens::parse(&properties, &options);
|
||||
Some(ShaderOptions::new(
|
||||
name.to_owned(),
|
||||
@@ -108,16 +108,14 @@ fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pack `settings` pick (or `HORIZON_SHADER_PACK`) is one of Horizon's own: only
|
||||
/// then do resource loads read the resource packs' LabPBR maps (`sprites::set_labpbr_maps`).
|
||||
pub(super) fn picks_native_pack(settings: &horizon_client::settings::Settings) -> bool {
|
||||
/// A selected pack may read resource-pack material maps, whether native or GLSL.
|
||||
pub(super) fn picks_material_pack(settings: &horizon_client::settings::Settings) -> bool {
|
||||
let dev = std::env::var("HORIZON_SHADER_PACK").ok();
|
||||
let picked = active_pack(
|
||||
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
|
||||
&settings.mods,
|
||||
);
|
||||
!picked.is_empty()
|
||||
&& horizon_shader_api::package::is_pack(&Game::shader_packs_dir().join(picked))
|
||||
}
|
||||
|
||||
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
|
||||
@@ -134,6 +132,11 @@ pub(super) struct PackIds {
|
||||
/// −1, or 0 without the file (5.3).
|
||||
items: HashMap<String, i32>,
|
||||
missing_item: i32,
|
||||
/// `currentRenderedItemId` of each block item (`StateFacts::item` of its block's default
|
||||
/// state): that state's `block.properties` id, or 0.
|
||||
block_items: HashMap<String, i32>,
|
||||
/// Whether the pack has `item.properties` (`currentRenderedItemId` of an item it lacks).
|
||||
item_file: bool,
|
||||
/// `oldHandLight` (3.2): the first held item and light are those of the brighter hand.
|
||||
old_hand_light: bool,
|
||||
/// `vignette`: `false` hides the game's vignette over the pack's image.
|
||||
@@ -178,6 +181,11 @@ impl Game<'_> {
|
||||
if picked.is_empty() {
|
||||
return PackInputs::default();
|
||||
}
|
||||
if !horizon_client::sprites::labpbr_maps() {
|
||||
horizon_client::sprites::set_labpbr_maps(true);
|
||||
self.reload_packs();
|
||||
return PackInputs::default();
|
||||
}
|
||||
let name = picked;
|
||||
let dimension = match self.dim {
|
||||
Dim::Overworld => 0,
|
||||
@@ -248,7 +256,11 @@ impl Game<'_> {
|
||||
&Properties::parse(text.as_deref().unwrap_or_default(), ¯os),
|
||||
"item",
|
||||
);
|
||||
let block_items =
|
||||
block_item_ids(self.res.facts(), self.pack.assets.registry, &ids);
|
||||
self.pack_ids = Some(PackIds {
|
||||
block_items,
|
||||
item_file: text.is_some(),
|
||||
items: items.plain(),
|
||||
missing_item: if text.is_some() { -1 } else { 0 },
|
||||
// OQ-21: on unless the pack turns it off.
|
||||
@@ -457,6 +469,17 @@ impl Game<'_> {
|
||||
self.settings.vignette && self.pack_ids.as_ref().is_none_or(|ids| ids.vignette)
|
||||
}
|
||||
|
||||
/// `currentRenderedItemId` of a held `item`, [`rendered_item_id`]'s; 0 without a pack.
|
||||
pub(super) fn rendered_item_id(&self, item: &str) -> i32 {
|
||||
self.pack_ids.as_ref().map_or(0, |ids| {
|
||||
rendered_item_id(
|
||||
ids.block_items.get(item).copied(),
|
||||
ids.items.get(item).copied(),
|
||||
ids.item_file,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// `heldItemId(2)` and `heldBlockLightValue(2)`: main hand, then off hand (5.2, 5.3).
|
||||
fn held_for_pack(&mut self) -> ([i32; 2], [i32; 2]) {
|
||||
if self.pack_ids.is_none() {
|
||||
@@ -606,6 +629,41 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `currentRenderedItemId` of an item (Iris 1.11.7, observed with probe packs): a block item
|
||||
/// (`block`: its block's `block.properties` id, 0 when the file lacks it) reads that, whatever
|
||||
/// `item.properties` says of it; any other item its `item.properties` id (`item`), and one the
|
||||
/// file lacks 65535, or 0 when the pack has no `item.properties` (`item_file`).
|
||||
fn rendered_item_id(block: Option<i32>, item: Option<i32>, item_file: bool) -> i32 {
|
||||
block.or(item).unwrap_or(if item_file { 65535 } else { 0 })
|
||||
}
|
||||
|
||||
/// The block items' `currentRenderedItemId`: for each block's default state, the item it gives
|
||||
/// (`StateFacts::item`) takes the state's id in `ids`, or 0. A block named as its item wins over
|
||||
/// another block giving that item.
|
||||
fn block_item_ids(
|
||||
facts: &horizon_client::blocks::BlockFacts,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
ids: &BlockIds,
|
||||
) -> HashMap<String, i32> {
|
||||
let mut out = HashMap::new();
|
||||
for (i, state) in facts.states.iter().enumerate() {
|
||||
let id = horizon_world::registry::BlockStateId(i as u32);
|
||||
if &*state.item == "minecraft:air" || registry.default_state(&state.name) != Some(id) {
|
||||
continue;
|
||||
}
|
||||
let value = registry
|
||||
.state(id)
|
||||
.and_then(|s| ids.id(&s.name, |k| s.properties.get(k).map(String::as_str)))
|
||||
.unwrap_or(0);
|
||||
if state.item == state.name {
|
||||
out.insert(state.item.to_string(), value);
|
||||
} else {
|
||||
out.entry(state.item.to_string()).or_insert(value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Spans for a native mod (`native_render::RenderNeeds::entity_spans`), without a pack.
|
||||
fn native_spans() -> bool {
|
||||
horizon_client::gfx::native_render::render_needs().entity_spans
|
||||
@@ -826,6 +884,27 @@ mod emission_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rendered_item_tests {
|
||||
use super::rendered_item_id;
|
||||
|
||||
/// Iris 1.11.7's values (probe packs of ours): a block item reads its block's id, and 0 when
|
||||
/// `block.properties` lacks it even if `item.properties` names it (oak planks as item 15);
|
||||
/// other items read their `item.properties` id, or 65535 when the file lacks them, and 0
|
||||
/// when the pack has no such file.
|
||||
#[test]
|
||||
fn a_held_item_reads_its_block_id_or_item_id() {
|
||||
// Stone: block 21 and item 12.
|
||||
assert_eq!(rendered_item_id(Some(21), Some(12), true), 21);
|
||||
assert_eq!(rendered_item_id(Some(0), Some(15), true), 0);
|
||||
assert_eq!(rendered_item_id(Some(0), None, true), 0);
|
||||
assert_eq!(rendered_item_id(Some(0), None, false), 0);
|
||||
assert_eq!(rendered_item_id(None, Some(11), true), 11);
|
||||
assert_eq!(rendered_item_id(None, None, true), 65535);
|
||||
assert_eq!(rendered_item_id(None, None, false), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod native_pack_tests {
|
||||
use super::{active_pack, pack_draws_flat};
|
||||
|
||||
@@ -405,6 +405,10 @@ impl<'a> Split<'a> {
|
||||
batching,
|
||||
self.host.settings.horizon.wide_frame(),
|
||||
shader_pack,
|
||||
(
|
||||
self.host.settings.file.int("textureFiltering"),
|
||||
self.host.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
);
|
||||
let renderer = match built {
|
||||
Ok(r) => r,
|
||||
@@ -483,6 +487,10 @@ impl<'a> Split<'a> {
|
||||
batching,
|
||||
self.host.settings.horizon.wide_frame(),
|
||||
shader_pack,
|
||||
(
|
||||
self.host.settings.file.int("textureFiltering"),
|
||||
self.host.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
);
|
||||
let renderer = match built {
|
||||
Ok(r) => r,
|
||||
|
||||
@@ -476,6 +476,10 @@ impl Split<'_> {
|
||||
host.settings.horizon.wide_frame(),
|
||||
// The pack its own game loads, from its profile.
|
||||
crate::game::shader_packs::picked_pack_draws_flat(&guest.settings),
|
||||
(
|
||||
guest.settings.file.int("textureFiltering"),
|
||||
guest.settings.file.int("maxAnisotropyBit"),
|
||||
),
|
||||
)?);
|
||||
guest.window = Some(window);
|
||||
guest.world_renderer_ready = true;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
//! them: [`gui`] into the HUD batch with the GUI item lighting, [`WorldSink`]
|
||||
//! into entity vertices with the level's lighting.
|
||||
pub mod cit;
|
||||
mod glint_atlas;
|
||||
#[cfg(test)]
|
||||
mod glint_tests;
|
||||
pub mod gui;
|
||||
mod special;
|
||||
|
||||
@@ -45,6 +48,8 @@ pub struct ItemQuad {
|
||||
/// The model UVs `GLINT_TEXTURING` scrolls for the glint (vanilla's UV0,
|
||||
/// or UV3 for a special model).
|
||||
pub foil_uv: [[f32; 2]; 4],
|
||||
/// Standard GUI glint uses the vanilla items atlas; CIT keeps sprite-local UVs.
|
||||
pub gui_glint_uv: Option<[[f32; 2]; 4]>,
|
||||
pub normal: Vec3,
|
||||
pub color: u32,
|
||||
pub foil: Foil,
|
||||
@@ -422,7 +427,10 @@ fn submit_quads(
|
||||
FoilType::Standard => Foil::Standard,
|
||||
FoilType::Special => Foil::Special,
|
||||
};
|
||||
let _ = display;
|
||||
let atlas = renderer
|
||||
.glint_atlas
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut normals = FaceNormals::new(m);
|
||||
// Quads of one layer mostly share a sprite: look its rect and its
|
||||
// translucency up once.
|
||||
@@ -480,6 +488,12 @@ fn submit_quads(
|
||||
pos,
|
||||
uv,
|
||||
foil_uv: q.uv,
|
||||
gui_glint_uv: (display == DisplayContext::Gui && foil == Foil::Standard)
|
||||
.then(|| atlas.get(sprite_name(&q.material.sprite)))
|
||||
.flatten()
|
||||
.map(|r| {
|
||||
q.uv.map(|[u, v]| [r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v])
|
||||
}),
|
||||
normal: normals.face(q.facing),
|
||||
color: argb(color),
|
||||
foil,
|
||||
@@ -631,6 +645,7 @@ pub struct ItemRenderer {
|
||||
/// The enabled packs' OptiFine CIT rules and their swapped bakes; `None`
|
||||
/// without rules.
|
||||
pub cit: Option<cit::CitModels>,
|
||||
glint_atlas: Mutex<HashMap<String, Rect>>,
|
||||
}
|
||||
|
||||
/// Every `items/**/*.json` client item definition, by identifier. Through
|
||||
@@ -663,7 +678,26 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
|
||||
out
|
||||
}
|
||||
|
||||
fn glint_padding(filtering: i32, bit: i32) -> usize {
|
||||
if filtering == 2 {
|
||||
bit.wrapping_sub(1).clamp(0, 4) as usize
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
impl ItemRenderer {
|
||||
/// Prepare GUI glint after the device exists; propagate packing failures to pack rollback.
|
||||
pub fn prepare_glint_atlas(&self, maximum: u32, filtering: (i32, i32)) -> crate::Result<()> {
|
||||
let padding = glint_padding(filtering.0, filtering.1);
|
||||
let sprites = glint_atlas::load(&self.sprites.root, 1 << padding, maximum)?;
|
||||
*self
|
||||
.glint_atlas
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = sprites;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load item definitions, item prototypes (`items.json`), every model,
|
||||
/// the special renderers' entity models and the grass colormap.
|
||||
pub fn load(root: &Path) -> crate::Result<Self> {
|
||||
@@ -737,6 +771,7 @@ impl ItemRenderer {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
glint_atlas: Mutex::default(),
|
||||
item_ids,
|
||||
models,
|
||||
library,
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Vanilla item-atlas coordinates for GUI glint; the HUD's unrelated packing cannot supply them.
|
||||
use crate::{atlas_sources, hud::Rect};
|
||||
use horizon_assets::animation::AnimationMetadata;
|
||||
use std::{collections::HashMap, path::Path};
|
||||
|
||||
type Entry = (String, u32, u32);
|
||||
|
||||
/// `SpriteSourceList.list` and `SpriteResourceLoader.loadSprite` for the non-mipmapped ITEMS atlas.
|
||||
pub(super) fn load(
|
||||
root: &Path,
|
||||
padding: u32,
|
||||
maximum: u32,
|
||||
) -> crate::Result<HashMap<String, Rect>> {
|
||||
let sources = atlas_sources::atlases(root);
|
||||
let sources = sources
|
||||
.get("minecraft:items")
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let textures = atlas_sources::list_textures(root);
|
||||
let decode = |name: &str, dir: &str| {
|
||||
crate::hud::decode(&atlas_sources::texture_path(root, name, dir)).ok()
|
||||
};
|
||||
let mut entries = vec![("minecraft:missingno".into(), 16, 16)];
|
||||
let mut names = Vec::new();
|
||||
for (id, origin) in atlas_sources::list(sources, &textures) {
|
||||
names.push(id.clone());
|
||||
let dimensions = match &origin {
|
||||
atlas_sources::SpriteOrigin::File(texture) => (|| {
|
||||
let (w, h, _) = decode(texture, "textures")?;
|
||||
let path = atlas_sources::texture_path(root, texture, "textures")
|
||||
.with_extension("png.mcmeta");
|
||||
let metadata = horizon_core::prepared_files::read_to_string(&path)
|
||||
.ok()
|
||||
.map(|text| serde_json::from_str::<serde_json::Value>(&text))
|
||||
.transpose()
|
||||
.ok()?;
|
||||
if let Some(metadata) = &metadata {
|
||||
metadata.as_object()?;
|
||||
if let Some(texture) = metadata.get("texture") {
|
||||
texture_metadata(texture)?;
|
||||
}
|
||||
}
|
||||
let size = match metadata.as_ref().and_then(|v| v.get("animation")) {
|
||||
Some(animation) => AnimationMetadata::from_json(&animation.to_string())
|
||||
.ok()?
|
||||
.frame_size(w as i32, h as i32)
|
||||
.map(|x| x as u32),
|
||||
None => [w, h],
|
||||
};
|
||||
(size[0] > 0
|
||||
&& size[1] > 0
|
||||
&& w.is_multiple_of(size[0])
|
||||
&& h.is_multiple_of(size[1]))
|
||||
.then_some((size[0], size[1]))
|
||||
})(),
|
||||
// Generated sprites have empty resource metadata, so their whole image is one frame.
|
||||
_ => atlas_sources::generate(&origin, &decode).map(|(w, h, _)| (w, h)),
|
||||
};
|
||||
if let Some((w, h)) = dimensions {
|
||||
entries.push((id, w, h));
|
||||
}
|
||||
}
|
||||
let mut sprites = stitch(&entries, padding, maximum).ok_or_else(|| {
|
||||
std::io::Error::other(format!(
|
||||
"item atlas does not fit texture limit {maximum} with padding {padding}"
|
||||
))
|
||||
})?;
|
||||
// TextureAtlas.getSprite returns missingno for rejected or unplaced source sprites.
|
||||
if let Some(missing) = sprites.get("missingno").copied() {
|
||||
for name in names {
|
||||
sprites
|
||||
.entry(super::sprite_name(&name).to_owned())
|
||||
.or_insert(missing);
|
||||
}
|
||||
}
|
||||
Ok(sprites)
|
||||
}
|
||||
|
||||
/// `TextureMetadataSection.CODEC`: invalid fields reject the sprite, even without mipmaps.
|
||||
fn texture_metadata(section: &serde_json::Value) -> Option<()> {
|
||||
let fields = section.as_object()?;
|
||||
for (name, value) in fields.iter().filter(|(_, value)| !value.is_null()) {
|
||||
let valid = match name.as_str() {
|
||||
"blur" | "clamp" => value.is_boolean(),
|
||||
"mipmap_strategy" => matches!(
|
||||
value.as_str(),
|
||||
Some("auto" | "mean" | "cutout" | "strict_cutout" | "dark_cutout")
|
||||
),
|
||||
"alpha_cutoff_bias" => value.is_number(),
|
||||
_ => true,
|
||||
};
|
||||
if !valid {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Region {
|
||||
x: u32,
|
||||
y: u32,
|
||||
w: u32,
|
||||
h: u32,
|
||||
entry: Option<usize>,
|
||||
children: Vec<Region>,
|
||||
}
|
||||
impl Region {
|
||||
fn new(x: u32, y: u32, w: u32, h: u32) -> Self {
|
||||
Self {
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
/// `Stitcher.Region.add`: first fitting child, with vanilla's split order and no rotation.
|
||||
fn add(&mut self, index: usize, w: u32, h: u32) -> bool {
|
||||
if self.entry.is_some() || w > self.w || h > self.h {
|
||||
return false;
|
||||
}
|
||||
if w == self.w && h == self.h {
|
||||
self.entry = Some(index);
|
||||
return true;
|
||||
}
|
||||
if self.children.is_empty() {
|
||||
let (dw, dh) = (self.w - w, self.h - h);
|
||||
self.children.push(Self::new(self.x, self.y, w, h));
|
||||
if dw > 0 && dh > 0 {
|
||||
if self.h.max(dw) >= self.w.max(dh) {
|
||||
self.children.push(Self::new(self.x, self.y + h, w, dh));
|
||||
self.children
|
||||
.push(Self::new(self.x + w, self.y, dw, self.h));
|
||||
} else {
|
||||
self.children.push(Self::new(self.x + w, self.y, dw, h));
|
||||
self.children
|
||||
.push(Self::new(self.x, self.y + h, self.w, dh));
|
||||
}
|
||||
} else if dw == 0 {
|
||||
self.children.push(Self::new(self.x, self.y + h, w, dh));
|
||||
} else {
|
||||
self.children.push(Self::new(self.x + w, self.y, dw, h));
|
||||
}
|
||||
}
|
||||
self.children.iter_mut().any(|r| r.add(index, w, h))
|
||||
}
|
||||
fn gather(
|
||||
&self,
|
||||
entries: &[Entry],
|
||||
size: [u32; 2],
|
||||
padding: u32,
|
||||
out: &mut HashMap<String, Rect>,
|
||||
) {
|
||||
if let Some(i) = self.entry {
|
||||
let (name, w, h) = &entries[i];
|
||||
let (x, y) = (self.x + padding, self.y + padding);
|
||||
out.insert(
|
||||
super::sprite_name(name).to_owned(),
|
||||
Rect {
|
||||
u0: x as f32 / size[0] as f32,
|
||||
v0: y as f32 / size[1] as f32,
|
||||
u1: (x + w) as f32 / size[0] as f32,
|
||||
v1: (y + h) as f32 / size[1] as f32,
|
||||
w: *w,
|
||||
h: *h,
|
||||
},
|
||||
);
|
||||
}
|
||||
for child in &self.children {
|
||||
child.gather(entries, size, padding, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Stitcher.stitch/expand`: mip level zero, padding from the filtering option.
|
||||
fn stitch(entries: &[Entry], padding: u32, maximum: u32) -> Option<HashMap<String, Rect>> {
|
||||
let mut order: Vec<_> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (name, w, h))| {
|
||||
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
|
||||
(i, w + padding * 2, h + padding * 2, path, ns)
|
||||
})
|
||||
.collect();
|
||||
order.sort_by(|a, b| {
|
||||
b.2.cmp(&a.2)
|
||||
.then(b.1.cmp(&a.1))
|
||||
.then(a.3.cmp(b.3))
|
||||
.then(a.4.cmp(b.4))
|
||||
});
|
||||
let mut regions: Vec<Region> = Vec::new();
|
||||
let (mut width, mut height) = (0u32, 0u32);
|
||||
let pow2 = |x: u32| if x == 0 { 0 } else { x.next_power_of_two() };
|
||||
for (index, w, h, _, _) in order {
|
||||
if regions.iter_mut().any(|r| r.add(index, w, h)) {
|
||||
continue;
|
||||
}
|
||||
let (next_w, next_h) = (pow2(width.checked_add(w)?), pow2(height.checked_add(h)?));
|
||||
let (can_w, can_h) = (next_w <= maximum, next_h <= maximum);
|
||||
if !can_w && !can_h {
|
||||
return None;
|
||||
}
|
||||
let (grow_w, grow_h) = (
|
||||
can_w && pow2(width) != next_w,
|
||||
can_h && pow2(height) != next_h,
|
||||
);
|
||||
let horizontal = if grow_w != grow_h {
|
||||
grow_w
|
||||
} else {
|
||||
can_w && pow2(width) <= pow2(height)
|
||||
};
|
||||
let mut region = if horizontal {
|
||||
if height == 0 {
|
||||
height = next_h;
|
||||
}
|
||||
let r = Region::new(width, 0, next_w - width, height);
|
||||
width = next_w;
|
||||
r
|
||||
} else {
|
||||
let r = Region::new(0, height, width, next_h - height);
|
||||
height = next_h;
|
||||
r
|
||||
};
|
||||
// Pinned expand ignores add's result for a sprite wider than the device limit.
|
||||
region.add(index, w, h);
|
||||
regions.push(region);
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
for r in regions {
|
||||
r.gather(entries, [width, height], padding, &mut out);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
use super::*;
|
||||
|
||||
fn png(path: &Path, w: u32, h: u32, color: [u8; 4]) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = encoder.write_header().unwrap();
|
||||
writer
|
||||
.write_image_data(&color.repeat((w * h) as usize))
|
||||
.unwrap();
|
||||
writer.finish().unwrap();
|
||||
}
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-glint-5851-{name}-{}", std::process::id()));
|
||||
let write = |name: &str, data: &[u8]| {
|
||||
let path = root.join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, data).unwrap();
|
||||
};
|
||||
write("items.json", b"{}");
|
||||
write(
|
||||
"assets/minecraft/atlases/items.json",
|
||||
br#"{"sources":[
|
||||
{"type":"directory","source":"item","prefix":"item/"},
|
||||
{"type":"single","resource":"test:source","sprite":"test:item/enchanted_book"},
|
||||
{"type":"unstitch","resource":"test:sheet","divisor_x":2,"divisor_y":1,
|
||||
"regions":[{"sprite":"minecraft:item/compass_00","x":1,"y":0,"width":1,"height":1}]}]}"#,
|
||||
);
|
||||
for (name, w, h) in [
|
||||
("minecraft/item/enchanted_book", 16, 16),
|
||||
("minecraft/item/book", 8, 16),
|
||||
("minecraft/item/diamond_sword", 32, 16),
|
||||
("minecraft/item/clock_00", 16, 64),
|
||||
("test/source", 16, 16),
|
||||
("test/sheet", 16, 16),
|
||||
] {
|
||||
let (namespace, path) = name.split_once('/').unwrap();
|
||||
png(
|
||||
&root.join(format!("assets/{namespace}/textures/{path}.png")),
|
||||
w,
|
||||
h,
|
||||
[255; 4],
|
||||
);
|
||||
}
|
||||
write(
|
||||
"assets/minecraft/textures/item/clock_00.png.mcmeta",
|
||||
br#"{"animation":{"width":16,"height":16}}"#,
|
||||
);
|
||||
root
|
||||
}
|
||||
|
||||
fn state(sprite: &str, foil: FoilType, display: DisplayContext) -> ItemRenderState {
|
||||
let material = horizon_assets::model::Material {
|
||||
sprite: sprite.into(),
|
||||
force_translucent: false,
|
||||
};
|
||||
let baked = BakedItemModel {
|
||||
quads: vec![horizon_assets::bake::ModelQuad {
|
||||
positions: [
|
||||
[-0.5, 0.5, 0.0],
|
||||
[-0.5, -0.5, 0.0],
|
||||
[0.5, -0.5, 0.0],
|
||||
[0.5, 0.5, 0.0],
|
||||
],
|
||||
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
facing: Direction::South,
|
||||
cull: None,
|
||||
material: material.clone(),
|
||||
tint_index: -1,
|
||||
shade_direction_override: None,
|
||||
light_emission: 0,
|
||||
light: [0; 2],
|
||||
color: None,
|
||||
ambient_occlusion: None,
|
||||
render_type: None,
|
||||
shade: true,
|
||||
}],
|
||||
uses_block_light: false,
|
||||
particle: material,
|
||||
transforms: Default::default(),
|
||||
};
|
||||
ItemRenderState {
|
||||
display,
|
||||
layers: vec![LayerRender {
|
||||
geometry: LayerGeometry::Quads(Arc::new(baked)),
|
||||
tints: vec![],
|
||||
foil,
|
||||
matrix: Mat4::IDENTITY,
|
||||
particle: sprite.into(),
|
||||
uses_block_light: false,
|
||||
extents: Arc::new(vec![]),
|
||||
}],
|
||||
oversized_in_gui: false,
|
||||
animated: false,
|
||||
cit_glint: None,
|
||||
hdr_tier: 0,
|
||||
hdr_light: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gui_glint_matches_pinned_vanilla_atlas_input() {
|
||||
let root = fixture("gui");
|
||||
let renderer = ItemRenderer::load(&root).unwrap();
|
||||
renderer.prepare_glint_atlas(16384, (0, 2)).unwrap();
|
||||
// The real vanilla oracle also checks a rectangular texture, an animation frame,
|
||||
// an unstitch source and an equal-sized sprite in a second namespace.
|
||||
for (name, x, y, w, h) in [
|
||||
("item/enchanted_book", 1, 33, 16, 16),
|
||||
("item/book", 53, 1, 8, 16),
|
||||
("item/diamond_sword", 1, 1, 32, 16),
|
||||
("item/clock_00", 35, 1, 16, 16),
|
||||
("item/compass_00", 55, 33, 8, 16),
|
||||
("test:item/enchanted_book", 19, 33, 16, 16),
|
||||
("missingno", 37, 33, 16, 16),
|
||||
] {
|
||||
let r = renderer.glint_atlas.lock().unwrap()[name];
|
||||
assert_eq!(
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
[x, y, x + w, y + h].map(|n| n as f32 / 64.0)
|
||||
);
|
||||
}
|
||||
let sprites = HashMap::from([
|
||||
(
|
||||
"item/enchanted_book".into(),
|
||||
Rect {
|
||||
u0: 0.2,
|
||||
v0: 0.3,
|
||||
u1: 0.4,
|
||||
v1: 0.5,
|
||||
w: 16,
|
||||
h: 16,
|
||||
},
|
||||
),
|
||||
(
|
||||
"misc/enchanted_glint_item".into(),
|
||||
Rect {
|
||||
u0: 0.0,
|
||||
v0: 0.0,
|
||||
u1: 1.0,
|
||||
v1: 1.0,
|
||||
w: 128,
|
||||
h: 128,
|
||||
},
|
||||
),
|
||||
]);
|
||||
let render = |foil| {
|
||||
let mut batch = crate::hud::HudBatch::new([64, 64]);
|
||||
gui::draw(
|
||||
&state("minecraft:item/enchanted_book", foil, DisplayContext::Gui),
|
||||
&renderer,
|
||||
&sprites,
|
||||
&NoHost,
|
||||
&mut batch,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.75,
|
||||
);
|
||||
batch
|
||||
};
|
||||
let batch = render(FoilType::Standard);
|
||||
// Real 26.3 SpriteSourceList + SpriteResourceLoader + Stitcher + JOML, over this fixture:
|
||||
// atlas 64x64, book rectangle (1,33)..(17,49). HUD packing above deliberately differs.
|
||||
let expected = [
|
||||
[-0.5931978, 4.084038],
|
||||
[-0.94049406, 6.0536532],
|
||||
[1.0291214, 6.4009495],
|
||||
[1.3764176, 4.431334],
|
||||
];
|
||||
let mut seen = [false; 4];
|
||||
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
|
||||
let index = match vertex.pos {
|
||||
[0.0, 0.0] => 0,
|
||||
[0.0, 16.0] => 1,
|
||||
[16.0, 16.0] => 2,
|
||||
[16.0, 0.0] => 3,
|
||||
p => panic!("unexpected corner {p:?}"),
|
||||
};
|
||||
for (got, want) in vertex.glint_uv.into_iter().zip(expected[index]) {
|
||||
assert!((got - want).abs() < 1e-5, "glint {got} != vanilla {want}");
|
||||
}
|
||||
seen[index] = true;
|
||||
}
|
||||
assert!(seen.into_iter().all(|x| x));
|
||||
let plain = render(FoilType::None);
|
||||
let base: Vec<_> = batch
|
||||
.vertices
|
||||
.iter()
|
||||
.filter(|v| v.uv[0] < 2.0)
|
||||
.map(|v| (v.pos, v.uv, v.color))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
base,
|
||||
plain
|
||||
.vertices
|
||||
.iter()
|
||||
.map(|v| (v.pos, v.uv, v.color))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
for (display, foil) in [
|
||||
(DisplayContext::Ground, FoilType::Standard),
|
||||
(DisplayContext::FirstPersonRightHand, FoilType::Standard),
|
||||
(DisplayContext::Gui, FoilType::Special),
|
||||
] {
|
||||
let mut quads = Vec::new();
|
||||
state("minecraft:item/enchanted_book", foil, display).submit(
|
||||
&renderer,
|
||||
&sprites,
|
||||
&NoHost,
|
||||
Mat4::IDENTITY,
|
||||
&mut quads,
|
||||
);
|
||||
assert_eq!(quads.len(), 1);
|
||||
assert!(quads[0].gui_glint_uv.is_none());
|
||||
assert_eq!(
|
||||
quads[0].foil_uv,
|
||||
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]]
|
||||
);
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glint_atlas_boundaries() {
|
||||
let root = fixture("boundary");
|
||||
// Pinned vanilla over the same generated fixture; padding 1,2,4,8,16.
|
||||
for (i, (width, height, x, y)) in [
|
||||
(64, 64, 1, 33),
|
||||
(128, 64, 2, 34),
|
||||
(128, 64, 4, 36),
|
||||
(128, 64, 40, 40),
|
||||
(256, 128, 16, 80),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let atlas = glint_atlas::load(&root, 1 << i, 16384).unwrap();
|
||||
let r = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
[
|
||||
x as f32 / width as f32,
|
||||
y as f32 / height as f32,
|
||||
(x + 16) as f32 / width as f32,
|
||||
(y + 16) as f32 / height as f32
|
||||
]
|
||||
);
|
||||
}
|
||||
png(
|
||||
&root.join("assets/minecraft/textures/item/wide.png"),
|
||||
16384,
|
||||
1,
|
||||
[255; 4],
|
||||
);
|
||||
let atlas = glint_atlas::load(&root, 1, 32768).unwrap();
|
||||
let book = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[book.u0, book.v0, book.u1, book.v1],
|
||||
[1.0 / 32768.0, 33.0 / 64.0, 17.0 / 32768.0, 49.0 / 64.0]
|
||||
);
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
let book = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[book.u0, book.v0, book.u1, book.v1],
|
||||
[1.0 / 64.0, 33.0 / 128.0, 17.0 / 64.0, 49.0 / 128.0]
|
||||
);
|
||||
assert_eq!(atlas["item/wide"].u0, atlas["missingno"].u0);
|
||||
assert!(glint_atlas::load(&root, 1, 16).is_err());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_and_generated_sprites_match_vanilla() {
|
||||
let root = fixture("metadata");
|
||||
for name in ["invalid_size", "invalid_metadata", "not_animated"] {
|
||||
png(
|
||||
&root.join(format!("assets/minecraft/textures/item/{name}.png")),
|
||||
16,
|
||||
32,
|
||||
[255; 4],
|
||||
);
|
||||
}
|
||||
for (name, meta) in [
|
||||
("invalid_size", r#"{"animation":{"width":9,"height":16}}"#),
|
||||
("invalid_metadata", r#"{"animation":{"frames":"bad"}}"#),
|
||||
("not_animated", r#"{"texture":{"blur":true}}"#),
|
||||
] {
|
||||
fs::write(
|
||||
root.join(format!("assets/minecraft/textures/item/{name}.png.mcmeta")),
|
||||
meta,
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let path = root.join("assets/minecraft/atlases/items.json");
|
||||
let mut atlas: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
|
||||
atlas["sources"].as_array_mut().unwrap().extend([
|
||||
serde_json::json!({"type":"single","resource":"test:absent","sprite":"test:missing_alias"}),
|
||||
serde_json::json!({"type":"paletted_permutations","textures":["test:trim"],"palette_key":"test:key","permutations":{"red":"test:red"}}),
|
||||
]);
|
||||
fs::write(path, atlas.to_string()).unwrap();
|
||||
for (name, w, h, color) in [
|
||||
("trim", 8, 16, [255; 4]),
|
||||
("palettes/key", 1, 1, [255; 4]),
|
||||
("palettes/red", 1, 1, [255, 0, 0, 255]),
|
||||
] {
|
||||
png(
|
||||
&root.join(format!("assets/test/textures/{name}.png")),
|
||||
w,
|
||||
h,
|
||||
color,
|
||||
);
|
||||
}
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
// Real SpriteResourceLoader rejects both invalid animations; TextureAtlas resolves missingno.
|
||||
for name in ["item/invalid_size", "item/invalid_metadata"] {
|
||||
let r = atlas[name];
|
||||
assert_eq!(
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
[103.0 / 128.0, 1.0 / 64.0, 119.0 / 128.0, 17.0 / 64.0]
|
||||
);
|
||||
}
|
||||
let r = atlas["item/not_animated"];
|
||||
assert_eq!(
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
[1.0 / 128.0, 1.0 / 64.0, 17.0 / 128.0, 33.0 / 64.0]
|
||||
);
|
||||
let r = atlas["test:trim_red"];
|
||||
assert_eq!(
|
||||
[r.u0, r.v0, r.u1, r.v1],
|
||||
[19.0 / 128.0, 37.0 / 64.0, 27.0 / 128.0, 53.0 / 64.0]
|
||||
);
|
||||
assert!(!atlas.contains_key("test:missing_alias"));
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_texture_metadata_does_not_move_book_glint() {
|
||||
let root = fixture("texture-metadata");
|
||||
let image = root.join("assets/minecraft/textures/item/large.png");
|
||||
png(&image, 512, 256, [255; 4]);
|
||||
let metadata = image.with_extension("png.mcmeta");
|
||||
for invalid in [
|
||||
r#"{"texture":[]}"#,
|
||||
r#"{"texture":null}"#,
|
||||
r#"{"texture":{"mipmap_strategy":"issue5851_invalid"}}"#,
|
||||
r#"{"texture":{"blur":"true"}}"#,
|
||||
r#"{"texture":{"clamp":1}}"#,
|
||||
r#"{"texture":{"alpha_cutoff_bias":"0.5"}}"#,
|
||||
] {
|
||||
fs::write(&metadata, invalid).unwrap();
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
let rejected = atlas["item/large"];
|
||||
let missing = atlas["missingno"];
|
||||
assert_eq!(
|
||||
[rejected.u0, rejected.v0, rejected.u1, rejected.v1],
|
||||
[missing.u0, missing.v0, missing.u1, missing.v1],
|
||||
"{invalid}"
|
||||
);
|
||||
// Existing real-vanilla fixture: rejecting the extra image leaves its book unchanged.
|
||||
let book = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[book.u0, book.v0, book.u1, book.v1],
|
||||
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0],
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for strategy in ["auto", "mean", "cutout", "strict_cutout", "dark_cutout"] {
|
||||
fs::write(
|
||||
&metadata,
|
||||
serde_json::json!({"texture":{"blur":true,"clamp":false,
|
||||
"mipmap_strategy":strategy,"alpha_cutoff_bias":0.5}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
assert_eq!((atlas["item/large"].w, atlas["item/large"].h), (512, 256));
|
||||
}
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glint_atlas_reads_prepared_images_and_metadata_together() {
|
||||
let root = fixture("prepared-metadata");
|
||||
let image = root.join("assets/minecraft/textures/item/large.png");
|
||||
png(&image, 512, 256, [255; 4]);
|
||||
let metadata = image.with_extension("png.mcmeta");
|
||||
fs::write(&metadata, r#"{"texture":[]}"#).unwrap();
|
||||
fs::write(root.join("horizon-resources.json"), "{}").unwrap();
|
||||
horizon_core::prepared_files::write(&root).unwrap();
|
||||
// The renderer reads the prepared snapshot, even if loose files now differ.
|
||||
fs::write(&metadata, r#"{"texture":{"blur":true}}"#).unwrap();
|
||||
png(
|
||||
&root.join("assets/minecraft/textures/item/enchanted_book.png"),
|
||||
256,
|
||||
256,
|
||||
[0, 255, 0, 255],
|
||||
);
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
assert_eq!(atlas["item/large"].u0, atlas["missingno"].u0);
|
||||
let book = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[book.u0, book.v0, book.u1, book.v1],
|
||||
[1.0 / 64.0, 33.0 / 64.0, 17.0 / 64.0, 49.0 / 64.0]
|
||||
);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires the player's prepared pinned 26.3 resources in HORIZON_TEST_RESOURCES"]
|
||||
fn prepared_item_atlas_matches_pinned_vanilla() {
|
||||
let root =
|
||||
PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources"));
|
||||
let atlas = glint_atlas::load(&root, 1, 16384).unwrap();
|
||||
assert_eq!(atlas.len(), 896);
|
||||
let book = atlas["item/enchanted_book"];
|
||||
assert_eq!(
|
||||
[book.u0, book.v0, book.u1, book.v1],
|
||||
[437.0 / 1024.0, 163.0 / 512.0, 453.0 / 1024.0, 179.0 / 512.0]
|
||||
);
|
||||
let glint = Glint {
|
||||
millis: 0.0,
|
||||
speed: 0.0,
|
||||
alpha: 0.75,
|
||||
scale: 8.0,
|
||||
};
|
||||
let uv = glint.uv([book.u0, book.v0]);
|
||||
for (got, want) in uv.into_iter().zip([2.919935, 3.101028]) {
|
||||
assert!((got - want).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires an independently generated pinned vanilla atlas JSON and matching resources"]
|
||||
fn external_item_atlas_oracle() {
|
||||
let root = PathBuf::from(std::env::var_os("HORIZON_GLINT_ROOT").expect("resource root"));
|
||||
let expected: Value = serde_json::from_str(
|
||||
&fs::read_to_string(
|
||||
std::env::var_os("HORIZON_GLINT_EXPECTED").expect("vanilla atlas JSON"),
|
||||
)
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let bit = std::env::var("HORIZON_GLINT_ANISO_BITS")
|
||||
.unwrap()
|
||||
.parse()
|
||||
.unwrap();
|
||||
let sprites = glint_atlas::load(
|
||||
&root,
|
||||
1 << glint_padding(if bit == 0 { 0 } else { 2 }, bit),
|
||||
32768,
|
||||
)
|
||||
.unwrap();
|
||||
for (name, value) in expected["sprites"].as_object().unwrap() {
|
||||
let actual = sprites[sprite_name(name)];
|
||||
for (got, key) in [actual.u0, actual.v0, actual.u1, actual.v1]
|
||||
.into_iter()
|
||||
.zip(["u0", "v0", "u1", "v1"])
|
||||
{
|
||||
let want = value[key].as_f64().unwrap() as f32;
|
||||
assert!(
|
||||
(got - want).abs() < 1e-7,
|
||||
"{name} {key}: {got} != vanilla {want}"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(rejected) = expected["rejected"].as_array() {
|
||||
let missing = sprites["missingno"];
|
||||
for name in rejected {
|
||||
let actual = sprites[sprite_name(name.as_str().unwrap())];
|
||||
assert_eq!(
|
||||
[actual.u0, actual.v0, actual.u1, actual.v1],
|
||||
[missing.u0, missing.v0, missing.u1, missing.v1]
|
||||
);
|
||||
}
|
||||
let renderer = ItemRenderer::load(&root).unwrap();
|
||||
renderer
|
||||
.prepare_glint_atlas(32768, (if bit == 0 { 0 } else { 2 }, bit))
|
||||
.unwrap();
|
||||
let hud_sprites = HashMap::from([
|
||||
("item/enchanted_book".into(), missing),
|
||||
("misc/enchanted_glint_item".into(), missing),
|
||||
]);
|
||||
let mut batch = crate::hud::HudBatch::new([64, 64]);
|
||||
gui::draw(
|
||||
&state(
|
||||
"minecraft:item/enchanted_book",
|
||||
FoilType::Standard,
|
||||
DisplayContext::Gui,
|
||||
),
|
||||
&renderer,
|
||||
&hud_sprites,
|
||||
&NoHost,
|
||||
&mut batch,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.0,
|
||||
0.75,
|
||||
);
|
||||
let want = &expected["sprites"]["minecraft:item/enchanted_book"]["glint_uv_clock0"];
|
||||
let mut seen = [false; 4];
|
||||
for vertex in batch.vertices.iter().filter(|v| v.uv[0] >= 2.0) {
|
||||
let index = match vertex.pos {
|
||||
[0.0, 0.0] => 0,
|
||||
[0.0, 16.0] => 3,
|
||||
[16.0, 16.0] => 2,
|
||||
[16.0, 0.0] => 1,
|
||||
p => panic!("unexpected corner {p:?}"),
|
||||
};
|
||||
for (got, value) in vertex
|
||||
.glint_uv
|
||||
.into_iter()
|
||||
.zip(want[index].as_array().unwrap())
|
||||
{
|
||||
assert!((got - value.as_f64().unwrap() as f32).abs() < 1e-5);
|
||||
}
|
||||
seen[index] = true;
|
||||
}
|
||||
assert!(seen.into_iter().all(|v| v));
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ struct Point {
|
||||
p: Vec2,
|
||||
uv: [f32; 2],
|
||||
glint_uv: [f32; 2],
|
||||
cit_uv: [f32; 2],
|
||||
}
|
||||
fn lerp(a: Point, b: Point, t: f32) -> Point {
|
||||
Point {
|
||||
@@ -55,6 +56,7 @@ fn lerp(a: Point, b: Point, t: f32) -> Point {
|
||||
a.uv[0] + (b.uv[0] - a.uv[0]) * t,
|
||||
a.uv[1] + (b.uv[1] - a.uv[1]) * t,
|
||||
],
|
||||
cit_uv: std::array::from_fn(|k| a.cit_uv[k] + (b.cit_uv[k] - a.cit_uv[k]) * t),
|
||||
glint_uv: [
|
||||
a.glint_uv[0] + (b.glint_uv[0] - a.glint_uv[0]) * t,
|
||||
a.glint_uv[1] + (b.glint_uv[1] - a.glint_uv[1]) * t,
|
||||
@@ -155,7 +157,7 @@ fn emit_cit(batch: &mut HudBatch, fans: &[Vec<Point>], cit: CitDraw) {
|
||||
}
|
||||
let glint = &cit.rules[layer.rule].glint;
|
||||
let color = vertex_color(slot, intensity);
|
||||
let uv = |v: Point| layer_uv(v.glint_uv, cit.millis, glint.speed, glint.rotation, 8.0);
|
||||
let uv = |v: Point| layer_uv(v.cit_uv, cit.millis, glint.speed, glint.rotation, 8.0);
|
||||
let start = batch.vertices.len();
|
||||
for fan in fans {
|
||||
for k in 1..fan.len().saturating_sub(1) {
|
||||
@@ -405,6 +407,7 @@ fn draw_quads_with(
|
||||
p: v.pos.truncate(),
|
||||
uv: v.uv,
|
||||
glint_uv: v.glint_uv,
|
||||
cit_uv: v.cit_uv,
|
||||
})
|
||||
.collect();
|
||||
let color = lit_color(q, lights);
|
||||
@@ -585,6 +588,7 @@ mod tests {
|
||||
pos: [(0.0, y0), (0.0, y1), (8.0, y1), (8.0, y0)].map(|(x, y)| Vec3::new(x, y, z(y))),
|
||||
uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
foil_uv: [[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
|
||||
gui_glint_uv: None,
|
||||
normal: Vec3::Z,
|
||||
color,
|
||||
foil: Foil::None,
|
||||
@@ -725,8 +729,10 @@ mod tests {
|
||||
};
|
||||
let mut batch = HudBatch::new([320, 240]);
|
||||
batch.glint_alpha = 0.5;
|
||||
let mut quad = face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF);
|
||||
quad.gui_glint_uv = Some([[42.0, 42.0]; 4]);
|
||||
draw_quads_with(
|
||||
&[face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF)],
|
||||
&[quad],
|
||||
(Vec3::Z, Vec3::Z),
|
||||
&mut batch,
|
||||
[0.0, 0.0, 16.0, 16.0],
|
||||
@@ -742,6 +748,7 @@ mod tests {
|
||||
for v in &batch.vertices[12..] {
|
||||
// Slot 1 in red; full intensity at half the glint strength in alpha.
|
||||
assert_eq!(v.color.to_le_bytes(), [1, 0, 0, 128]);
|
||||
assert_eq!(v.glint_uv, v.pos, "CIT retains the sprite-local UVs");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,6 +769,7 @@ mod tests {
|
||||
p: Vec2::new(x, y),
|
||||
uv: [x, y],
|
||||
glint_uv: [0.0, 0.0],
|
||||
cit_uv: [0.0, 0.0],
|
||||
};
|
||||
let fan = clip_triangle(
|
||||
[p(-4.0, 0.0), p(8.0, 0.0), p(8.0, 8.0)],
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(super) struct Corner {
|
||||
pub pos: Vec3,
|
||||
pub uv: [f32; 2],
|
||||
pub glint_uv: [f32; 2],
|
||||
pub cit_uv: [f32; 2],
|
||||
}
|
||||
|
||||
pub(super) struct Face {
|
||||
@@ -39,6 +40,7 @@ fn split(face: Face, distances: &[f32]) -> (Face, Face) {
|
||||
let corner = Corner {
|
||||
pos: a.pos.lerp(b.pos, t),
|
||||
uv: std::array::from_fn(|k| a.uv[k] + (b.uv[k] - a.uv[k]) * t),
|
||||
cit_uv: std::array::from_fn(|k| a.cit_uv[k] + (b.cit_uv[k] - a.cit_uv[k]) * t),
|
||||
glint_uv: std::array::from_fn(|k| {
|
||||
a.glint_uv[k] + (b.glint_uv[k] - a.glint_uv[k]) * t
|
||||
}),
|
||||
@@ -77,7 +79,8 @@ pub(super) fn sort(quads: &[ItemQuad]) -> Vec<Face> {
|
||||
corners: std::array::from_fn::<_, 4, _>(|k| Corner {
|
||||
pos: q.pos[k],
|
||||
uv: q.uv[k],
|
||||
glint_uv: q.foil_uv[k],
|
||||
glint_uv: q.gui_glint_uv.unwrap_or(q.foil_uv)[k],
|
||||
cit_uv: q.foil_uv[k],
|
||||
})
|
||||
.to_vec(),
|
||||
})
|
||||
|
||||
@@ -136,6 +136,7 @@ fn draw(
|
||||
]
|
||||
}),
|
||||
foil_uv: poly.map(|v| [v[3], v[4]]),
|
||||
gui_glint_uv: None,
|
||||
normal,
|
||||
color,
|
||||
foil,
|
||||
|
||||
@@ -8,7 +8,10 @@ use crate::{containers::BlockEntity, items::ItemStack, world::World};
|
||||
use horizon_core::random::RandomSource;
|
||||
use horizon_protocol::{
|
||||
item::Hashed,
|
||||
packets::play::{self, Clientbound as C},
|
||||
packets::{
|
||||
configuration::CustomPayload,
|
||||
play::{self, Clientbound as C},
|
||||
},
|
||||
};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::collections::HashMap;
|
||||
@@ -222,7 +225,10 @@ impl Hub {
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a block container for a guest (ServerPlayer.openMenu).
|
||||
/// Open a block container for a guest (ServerPlayer.openMenu). `extra`,
|
||||
/// for a mod menu type, goes first in a `horizon:menu_extra` payload with
|
||||
/// the window and `pos` (`openMenu` with an extra-data writer).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_container(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
@@ -231,6 +237,7 @@ impl Hub {
|
||||
pos: (i32, i32, i32),
|
||||
slots: Slots,
|
||||
items: Vec<Option<ItemStack>>,
|
||||
extra: Option<Vec<u8>>,
|
||||
) {
|
||||
let Some(kind) = self
|
||||
.sync
|
||||
@@ -247,6 +254,20 @@ impl Hub {
|
||||
let g = &mut self.guests[i];
|
||||
g.next_window = g.next_window % 100 + 1;
|
||||
let window = g.next_window;
|
||||
if let Some(data) = extra {
|
||||
let message = horizon_protocol::menu_extra::Message {
|
||||
window,
|
||||
pos: [pos.0, pos.1, pos.2],
|
||||
data,
|
||||
};
|
||||
match horizon_protocol::menu_extra::encode(&message) {
|
||||
Ok(data) => g.send(&C::CustomPayload(CustomPayload {
|
||||
channel: horizon_protocol::menu_extra::CHANNEL.into(),
|
||||
data,
|
||||
})),
|
||||
Err(e) => log::warn!("Horizon: {menu_kind}'s extra data not sent: {e}"),
|
||||
}
|
||||
}
|
||||
g.send(&C::OpenScreen(play::OpenScreen {
|
||||
container: window,
|
||||
menu: kind,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -15,7 +15,7 @@ use horizon_world::nbt::{JavaString, Tag};
|
||||
/// filter took it). A mod may cancel it, which drops it for everybody (the sender is not told, so push
|
||||
/// a line to `replies` to answer them), or only answer. It cannot change the text: a signed message
|
||||
/// would no longer verify. Once for each message a player sends, on a server. Commands are
|
||||
/// `native_player_command`. API @API:player_chat@.
|
||||
/// `native_player_command`. API 399.
|
||||
#[inline(never)]
|
||||
pub fn native_player_chat(
|
||||
player: u128,
|
||||
|
||||
@@ -816,9 +816,21 @@ mod tests {
|
||||
let client = Connection::connect("127.0.0.1", port, Duration::from_secs(5)).unwrap();
|
||||
let server = Connection::from_stream(listener.accept().unwrap().0).unwrap();
|
||||
let served = serve(server, quiet, Duration::from_secs(30));
|
||||
// More than the socket's buffers take.
|
||||
let bodies = (0..64).map(|i| body(i, 1 << 20)).collect();
|
||||
served.send(bodies, Some(End::Disconnect));
|
||||
// Fill the socket until the outbox waits. A fixed amount may all go
|
||||
// through: Windows takes a write of any size whole while its buffer
|
||||
// has room.
|
||||
let full = || {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
served.pending.load(Ordering::Relaxed) > 0
|
||||
};
|
||||
for i in 0..=u8::MAX {
|
||||
if full() && full() && full() {
|
||||
break;
|
||||
}
|
||||
assert!(i < u8::MAX, "the socket took everything");
|
||||
served.send(vec![body(i, 1 << 20)], None);
|
||||
}
|
||||
served.send(Vec::new(), Some(End::Disconnect));
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
assert!(served.seen.try_recv().is_err(), "still writing");
|
||||
served.send(Vec::new(), Some(End::Close));
|
||||
|
||||
@@ -579,8 +579,39 @@ impl MobTypes {
|
||||
}
|
||||
}
|
||||
pub use horizon_client_core::who::Who;
|
||||
/// A native damage type's registry name and the tags consumed by mob combat.
|
||||
///
|
||||
/// Native code supplies a static name (no interning or leaked runtime strings)
|
||||
/// and flags matching its data-pack damage tags. Register the same name/data for
|
||||
/// player damage, death messages and loot predicates. Unset tags are false.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct NamedDamage {
|
||||
pub name: &'static str,
|
||||
pub tags: u32,
|
||||
}
|
||||
impl NamedDamage {
|
||||
pub const IS_FIRE: u32 = 1 << 0;
|
||||
pub const BYPASSES_ARMOR: u32 = 1 << 1;
|
||||
pub const NO_KNOCKBACK: u32 = 1 << 2;
|
||||
pub const BYPASSES_INVULNERABILITY: u32 = 1 << 3;
|
||||
pub const BYPASSES_EFFECTS: u32 = 1 << 4;
|
||||
pub const BYPASSES_RESISTANCE: u32 = 1 << 5;
|
||||
pub const PANIC_CAUSES: u32 = 1 << 6;
|
||||
pub const IS_PROJECTILE: u32 = 1 << 7;
|
||||
pub const IS_EXPLOSION: u32 = 1 << 8;
|
||||
pub const IS_FALL: u32 = 1 << 9;
|
||||
|
||||
pub const fn new(name: &'static str, tags: u32) -> Self {
|
||||
Self { name, tags }
|
||||
}
|
||||
pub const fn has(self, tag: u32) -> bool {
|
||||
self.tags & tag != 0
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum DamageKind {
|
||||
/// A registered native damage type; vanilla kinds keep their existing tags.
|
||||
Named(NamedDamage),
|
||||
PlayerAttack,
|
||||
/// `mace_smash`: a mace hit after a fall (the player attack tags too).
|
||||
MaceSmash,
|
||||
@@ -650,14 +681,23 @@ pub enum DamageKind {
|
||||
impl DamageKind {
|
||||
/// DamageTypeTags.BYPASSES_INVULNERABILITY
|
||||
pub fn bypasses_invulnerability(self) -> bool {
|
||||
if let Self::Named(named) = self {
|
||||
return named.has(NamedDamage::BYPASSES_INVULNERABILITY);
|
||||
}
|
||||
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_EFFECTS
|
||||
pub fn bypasses_effects(self) -> bool {
|
||||
if let Self::Named(named) = self {
|
||||
return named.has(NamedDamage::BYPASSES_EFFECTS);
|
||||
}
|
||||
matches!(self, DamageKind::Starve)
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_RESISTANCE
|
||||
pub fn bypasses_resistance(self) -> bool {
|
||||
if let Self::Named(named) = self {
|
||||
return named.has(NamedDamage::BYPASSES_RESISTANCE);
|
||||
}
|
||||
matches!(self, DamageKind::OutOfWorld | DamageKind::GenericKill)
|
||||
}
|
||||
}
|
||||
@@ -743,6 +783,9 @@ impl Damage {
|
||||
}
|
||||
/// DamageTypeTags.PANIC_CAUSES
|
||||
pub fn causes_panic(&self) -> bool {
|
||||
if let DamageKind::Named(named) = self.kind {
|
||||
return named.has(NamedDamage::PANIC_CAUSES);
|
||||
}
|
||||
!matches!(
|
||||
self.kind,
|
||||
DamageKind::Fall
|
||||
@@ -765,6 +808,9 @@ impl Damage {
|
||||
}
|
||||
/// DamageTypeTags.BYPASSES_ARMOR
|
||||
pub fn bypasses_armor(&self) -> bool {
|
||||
if let DamageKind::Named(named) = self.kind {
|
||||
return named.has(NamedDamage::BYPASSES_ARMOR);
|
||||
}
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Fall
|
||||
@@ -786,6 +832,9 @@ impl Damage {
|
||||
)
|
||||
}
|
||||
pub fn is_fire(&self) -> bool {
|
||||
if let DamageKind::Named(named) = self.kind {
|
||||
return named.has(NamedDamage::IS_FIRE);
|
||||
}
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::OnFire
|
||||
@@ -799,6 +848,9 @@ impl Damage {
|
||||
}
|
||||
/// DamageTypeTags.NO_KNOCKBACK
|
||||
pub fn no_knockback(&self) -> bool {
|
||||
if let DamageKind::Named(named) = self.kind {
|
||||
return named.has(NamedDamage::NO_KNOCKBACK);
|
||||
}
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Explosion
|
||||
@@ -829,6 +881,9 @@ impl Damage {
|
||||
)
|
||||
}
|
||||
pub fn is_projectile(&self) -> bool {
|
||||
if let DamageKind::Named(named) = self.kind {
|
||||
return named.has(NamedDamage::IS_PROJECTILE);
|
||||
}
|
||||
matches!(
|
||||
self.kind,
|
||||
DamageKind::Arrow
|
||||
@@ -1273,6 +1328,9 @@ pub struct Mob {
|
||||
/// `LivingEntity.getAbsorptionAmount`: golden hearts that soak damage before health.
|
||||
pub absorption: f32,
|
||||
pub last_damage: Option<(Damage, i64)>,
|
||||
/// `DamageSource.getDirectEntity` of `last_damage` when [`Damage`] cannot name it (a
|
||||
/// thrown potion, an area effect cloud): its entity type.
|
||||
pub last_direct: Option<&'static str>,
|
||||
/// `LivingEntity.combatTracker`: the hits taken lately.
|
||||
pub combat: combat::Tracker,
|
||||
/// Set by `remove`: the mob disappears without dying (conversion, despawn, pickup).
|
||||
@@ -2771,6 +2829,7 @@ impl Mob {
|
||||
last_hurt: 0.0,
|
||||
absorption: 0.0,
|
||||
last_damage: None,
|
||||
last_direct: None,
|
||||
combat: combat::Tracker::default(),
|
||||
removed: false,
|
||||
custom_name: None,
|
||||
@@ -3135,6 +3194,7 @@ impl Mob {
|
||||
self.extra.in_love = 0;
|
||||
}
|
||||
self.last_damage = Some((source, i64::from(self.age)));
|
||||
self.last_direct = None;
|
||||
if let Some(who) = source.attacker {
|
||||
self.ai.last_hurt_by = Some(who);
|
||||
self.ai.last_hurt_by_time = self.age as i32;
|
||||
@@ -3206,19 +3266,22 @@ impl Mob {
|
||||
if equine::is_equine(self.species()) {
|
||||
equine::after_hurt_server(self);
|
||||
}
|
||||
if self.species() == Species::GlowSquid {
|
||||
// GlowSquid.hurtServer dims the squid only when Squid.hurtServer took the
|
||||
// hit, which it reports only for a living entity's blow: environmental
|
||||
// damage (suffocation, a campfire, drying out) leaves the glow alone.
|
||||
if self.species() == Species::GlowSquid && self.ai.last_hurt_by.is_some() {
|
||||
aquatic::hurt_server(self);
|
||||
}
|
||||
true
|
||||
}
|
||||
/// A splash or cloud's harming effect on this mob: the potion's
|
||||
/// `indirect_magic` is in `#witch_resistant_to`, so a witch takes 15%.
|
||||
pub fn potion_harm(&self, amount: f32) -> f32 {
|
||||
if self.species() == Species::Witch {
|
||||
amount * 0.15
|
||||
} else {
|
||||
amount
|
||||
/// `damage` by a source whose direct entity is `direct` (an entity type id), which `source`
|
||||
/// cannot carry: `DamageSources.indirectMagic(potion or cloud, owner)`.
|
||||
pub fn damage_by(&mut self, amount: f32, source: Damage, direct: &'static str) -> bool {
|
||||
let hurt = self.damage(amount, source);
|
||||
if hurt {
|
||||
self.last_direct = Some(direct);
|
||||
}
|
||||
hurt
|
||||
}
|
||||
/// `Wolf.canArmorAbsorb`: it wears wolf armor and the damage type is not
|
||||
/// in `#bypasses_wolf_armor` (drowning, magic, thorns, in a wall, out of
|
||||
@@ -3554,7 +3617,8 @@ impl Mob {
|
||||
let effect = horizon_client_core::potions::namespaced(effect);
|
||||
// LivingEntity.addEffect: an effect the mob refuses is never added, so it
|
||||
// never joins the list the client draws or an effect tick reads.
|
||||
if !self.can_be_affected(effect) {
|
||||
// WitherBoss.addEffect returns false for every effect.
|
||||
if self.species() == Species::Wither || !self.can_be_affected(effect) {
|
||||
return;
|
||||
}
|
||||
// AbsorptionMobEffect.addAttributeModifiers: at least 4 hearts-halves per level.
|
||||
@@ -4708,6 +4772,41 @@ mod magic_absorb_tests {
|
||||
m.damage(20.0, own);
|
||||
assert_eq!(m.health, 10.0);
|
||||
}
|
||||
|
||||
/// `damage_by` with a thrown potion's `indirectMagic(potion, owner)`: the
|
||||
/// hit keeps the potion as its direct entity and is the thrower's (a
|
||||
/// player's sets `lastHurtByPlayer`, a dispenser's names nobody); the next
|
||||
/// hit that names no direct entity forgets it.
|
||||
#[test]
|
||||
fn a_potion_hit_keeps_its_direct_entity_and_owner() {
|
||||
let mut m = Mob::new(1, kind(), Vec3::ZERO, 0.0);
|
||||
let thrown = Damage::indirect_magic(Some(Who::Player), [0.0, 1.0, 0.0]);
|
||||
assert!(m.damage_by(1.0, thrown, "minecraft:splash_potion"));
|
||||
assert_eq!(m.last_direct, Some("minecraft:splash_potion"));
|
||||
let (last, _) = m.last_damage.unwrap();
|
||||
assert_eq!(last.kind, DamageKind::IndirectMagic);
|
||||
assert_eq!(last.attacker, Some(Who::Player));
|
||||
assert_eq!(m.ai.last_hurt_by_player, 100);
|
||||
m.invulnerable = 0;
|
||||
assert!(m.damage(1.0, Damage::of(DamageKind::Generic)));
|
||||
assert_eq!(m.last_direct, None);
|
||||
|
||||
let mut m = Mob::new(1, kind(), Vec3::ZERO, 0.0);
|
||||
let dispensed = Damage::indirect_magic(None, [0.0, 1.0, 0.0]);
|
||||
assert!(m.damage_by(1.0, dispensed, "minecraft:area_effect_cloud"));
|
||||
assert_eq!(m.last_direct, Some("minecraft:area_effect_cloud"));
|
||||
assert_eq!(m.ai.last_hurt_by_player, 0);
|
||||
}
|
||||
|
||||
/// A harming potion's hit on a witch through `damage_by` is cut to 15%
|
||||
/// once (`getDamageAfterMagicAbsorb`), not again by the potion code.
|
||||
#[test]
|
||||
fn a_witch_takes_15_percent_of_a_potion_hit() {
|
||||
let mut m = witch();
|
||||
let thrown = Damage::indirect_magic(Some(Who::Player), [0.0, 1.0, 0.0]);
|
||||
assert!(m.damage_by(20.0, thrown, "minecraft:splash_potion"));
|
||||
assert_eq!(m.health, 7.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4953,7 +5052,8 @@ mod effect_refusal_tests {
|
||||
/// effect the mob's type refuses never joins the list, so the cloud on its head,
|
||||
/// the `/effect` reply and an effect tick never see it. The refused ones are the
|
||||
/// `ignores_poison_and_regen` tag (which is `#minecraft:undead`), a silverfish's
|
||||
/// infested and a slime's oozing, plus the five classes that override the rule.
|
||||
/// infested and a slime's oozing, the classes that override the rule, and the
|
||||
/// wither, whose `addEffect` refuses every effect.
|
||||
#[test]
|
||||
fn an_effect_the_mobs_type_refuses_is_never_added() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -4976,7 +5076,6 @@ mod effect_refusal_tests {
|
||||
"minecraft:skeleton",
|
||||
"minecraft:phantom",
|
||||
"minecraft:zombie_horse",
|
||||
"minecraft:wither",
|
||||
] {
|
||||
for effect in ["minecraft:poison", "minecraft:regeneration"] {
|
||||
assert_eq!(refused(undead, effect), (false, 0), "{undead} {effect}");
|
||||
@@ -5010,11 +5109,29 @@ mod effect_refusal_tests {
|
||||
assert_eq!(refused(spider, "minecraft:poison"), (false, 0), "{spider}");
|
||||
}
|
||||
assert_eq!(refused("minecraft:cow", "minecraft:poison"), (true, 1));
|
||||
// WitherBoss.canBeAffected and WitherSkeleton.canBeAffected: the wither
|
||||
// effect only, which a zombie takes.
|
||||
for wither in ["minecraft:wither", "minecraft:wither_skeleton"] {
|
||||
assert_eq!(refused(wither, "minecraft:wither"), (false, 0), "{wither}");
|
||||
// WitherBoss.addEffect returns false: the boss takes no effect at all.
|
||||
for effect in [
|
||||
"minecraft:wither",
|
||||
"minecraft:strength",
|
||||
"minecraft:speed",
|
||||
"minecraft:poison",
|
||||
] {
|
||||
assert_eq!(
|
||||
refused("minecraft:wither", effect),
|
||||
(false, 0),
|
||||
"wither {effect}"
|
||||
);
|
||||
}
|
||||
// WitherSkeleton.canBeAffected refuses the wither effect only, which a
|
||||
// zombie takes.
|
||||
assert_eq!(
|
||||
refused("minecraft:wither_skeleton", "minecraft:wither"),
|
||||
(false, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
refused("minecraft:wither_skeleton", "minecraft:strength"),
|
||||
(true, 1)
|
||||
);
|
||||
assert_eq!(refused("minecraft:zombie", "minecraft:wither"), (true, 1));
|
||||
// Parched.canBeAffected: weakness only, slowness takes.
|
||||
assert_eq!(
|
||||
@@ -5026,8 +5143,109 @@ mod effect_refusal_tests {
|
||||
(true, 1)
|
||||
);
|
||||
}
|
||||
|
||||
/// Squid.hurtServer reports a hit only when a living entity dealt it, so
|
||||
/// environmental damage leaves a glow squid glowing; a mob's blow dims it.
|
||||
#[test]
|
||||
fn only_a_mobs_blow_dims_a_glow_squid() {
|
||||
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../.local/horizon-client")
|
||||
.join(horizon_core::VANILLA_VERSION);
|
||||
if !root.join("entity-types.json").exists() {
|
||||
return;
|
||||
}
|
||||
let types = MobTypes::load_for_server(&root).unwrap();
|
||||
let mut burned = mob(&types, "minecraft:glow_squid");
|
||||
burned.health = 10.0;
|
||||
assert!(burned.damage(1.0, Damage::of(DamageKind::Campfire)));
|
||||
assert_eq!(aquatic::dark_ticks(&burned), 0);
|
||||
let mut struck = mob(&types, "minecraft:glow_squid");
|
||||
struck.health = 10.0;
|
||||
assert!(struck.damage(1.0, Damage::mob(2, [0.0, 64.0, 0.0])));
|
||||
assert_eq!(aquatic::dark_ticks(&struck), 100);
|
||||
}
|
||||
}
|
||||
crate::impl_joined!(Mob);
|
||||
|
||||
#[cfg(test)]
|
||||
mod named_damage_tests {
|
||||
use super::*;
|
||||
|
||||
fn pig() -> Mob {
|
||||
let mut kind = super::sensor_scan_tests::kind();
|
||||
let def = Arc::make_mut(&mut kind);
|
||||
def.id = "minecraft:pig".into();
|
||||
def.species = Species::Pig;
|
||||
def.armor = 20.0;
|
||||
Mob::new(1, kind, Vec3::ZERO, 0.0)
|
||||
}
|
||||
fn fire(tags: u32) -> Damage {
|
||||
Damage::of(DamageKind::Named(NamedDamage::new(
|
||||
"example:fan_fire",
|
||||
NamedDamage::IS_FIRE | NamedDamage::BYPASSES_ARMOR | tags,
|
||||
)))
|
||||
}
|
||||
#[test]
|
||||
fn named_fire_uses_normal_immunity_and_protection_without_becoming_on_fire() {
|
||||
let mut resistant = pig();
|
||||
resistant.add_effect("minecraft:fire_resistance", -1, 0);
|
||||
assert!(!resistant.damage(2.0, fire(0)));
|
||||
assert_eq!((resistant.health, resistant.invulnerable), (10.0, 0));
|
||||
let mut immune = pig();
|
||||
Arc::make_mut(&mut immune.kind).flags.fire_immune = true;
|
||||
assert!(!immune.damage(2.0, fire(0)));
|
||||
let mut bare = pig();
|
||||
assert!(bare.damage(2.0, fire(0)));
|
||||
assert_eq!(bare.health, 8.0); // bypasses the 20 armor points
|
||||
assert!(bare.velocity.x != 0.0 || bare.velocity.z != 0.0);
|
||||
assert_eq!(bare.last_damage.unwrap().0.kind, fire(0).kind);
|
||||
let mut protected = pig();
|
||||
gear::set(
|
||||
&mut protected,
|
||||
gear::CHEST,
|
||||
vec![("minecraft:fire_protection".into(), 4)],
|
||||
);
|
||||
assert!(protected.damage(2.0, fire(0)));
|
||||
assert!((protected.health - 8.64).abs() < 0.00001);
|
||||
let mut still = pig();
|
||||
assert!(still.damage(2.0, fire(NamedDamage::NO_KNOCKBACK)));
|
||||
assert_eq!(still.velocity, Vec3::ZERO);
|
||||
}
|
||||
#[test]
|
||||
fn named_tags_control_resistance_and_specialist_enchantments() {
|
||||
let mut mob = pig();
|
||||
mob.add_effect("minecraft:resistance", -1, 0);
|
||||
assert!(mob.damage(2.0, fire(0)));
|
||||
assert!((mob.health - 8.4).abs() < 0.00001);
|
||||
let mut bypass = pig();
|
||||
bypass.add_effect("minecraft:resistance", -1, 0);
|
||||
assert!(bypass.damage(2.0, fire(NamedDamage::BYPASSES_RESISTANCE)));
|
||||
assert_eq!(bypass.health, 8.0);
|
||||
let mut no_effects = pig();
|
||||
no_effects.add_effect("minecraft:resistance", -1, 0);
|
||||
gear::set(
|
||||
&mut no_effects,
|
||||
gear::CHEST,
|
||||
vec![("minecraft:fire_protection".into(), 4)],
|
||||
);
|
||||
assert!(no_effects.damage(2.0, fire(NamedDamage::BYPASSES_EFFECTS)));
|
||||
assert_eq!(no_effects.health, 8.0);
|
||||
let mut armor = gear::Gear::default();
|
||||
armor.slots[gear::FEET] = vec![("minecraft:feather_falling".into(), 4)];
|
||||
armor.slots[gear::CHEST] = vec![("minecraft:blast_protection".into(), 4)];
|
||||
let kind = |tags| DamageKind::Named(NamedDamage::new("example:impact", tags));
|
||||
assert_eq!(gear::protection_points(&armor, kind(0)), 0.0);
|
||||
assert_eq!(
|
||||
gear::protection_points(&armor, kind(NamedDamage::IS_FALL)),
|
||||
12.0
|
||||
);
|
||||
assert_eq!(
|
||||
gear::protection_points(&armor, kind(NamedDamage::IS_EXPLOSION)),
|
||||
8.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod silent_voice_tests {
|
||||
use super::*;
|
||||
|
||||
Loaded 100 of 344 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user