mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 23:55:37 -10:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fd2e38592 | ||
|
|
2abe71ca92 | ||
|
|
f787306fce | ||
|
|
d8bf54d1e6 | ||
|
|
f165e7fa29 | ||
|
|
9d73f6a26a | ||
|
|
4a60f12112 | ||
|
|
192be7194f | ||
|
|
5f3caf9be4 | ||
|
|
571ef142c5 | ||
|
|
21845f7949 | ||
|
|
27a4dc65c9 | ||
|
|
deaab60ba9 | ||
|
|
c2c3cea8e3 | ||
|
|
be34bdfd3c | ||
|
|
e72b69caca | ||
|
|
d9d89df9d8 | ||
|
|
f8e59c3e3b | ||
|
|
1dcb48e1ec | ||
|
|
80570541d0 | ||
|
|
44699b4c51 | ||
|
|
370854a657 | ||
|
|
83703c28b2 | ||
|
|
1b016ba22e | ||
|
|
023cef202f | ||
|
|
fdc97df507 | ||
|
|
fd7c423476 | ||
|
|
731699144c | ||
|
|
1fc67d4d10 | ||
|
|
54ea398de4 | ||
|
|
71d150b028 | ||
|
|
6c58b77fed | ||
|
|
245d97a312 | ||
|
|
9aae4a6671 | ||
|
|
2497c21f48 | ||
|
|
195dceeb94 | ||
|
|
8dc176f4e6 | ||
|
|
13fe0905ba | ||
|
|
7d7fa98a44 | ||
|
|
fd2f881a32 | ||
|
|
4a90452d73 | ||
|
|
b9abf293aa | ||
|
|
f5a3990fbf | ||
|
|
190aa00a63 | ||
|
|
47e22facbb | ||
|
|
1d2b14b01d | ||
|
|
1bcc5ec3f9 | ||
|
|
476dabcb08 | ||
|
|
b61cff5625 | ||
|
|
37f6d169d7 | ||
|
|
ac4204f395 | ||
|
|
7714b1d37a | ||
|
|
5f61926401 | ||
|
|
98b40e8e5e | ||
|
|
8c893c55f0 | ||
|
|
30e4e488b2 | ||
|
|
e2ee8ad7d9 | ||
|
|
c59a4d6ffd | ||
|
|
fb05c1acfc | ||
|
|
53e8715d83 | ||
|
|
306dca0a21 | ||
|
|
26d32147ea | ||
|
|
0b2ed50a68 | ||
|
|
386d0560a1 | ||
|
|
359388eea7 | ||
|
|
4d6923514e | ||
|
|
afb34764ec | ||
|
|
7cbe3d49b0 | ||
|
|
83122825e5 | ||
|
|
c3af4fadeb | ||
|
|
ffaaa63836 | ||
|
|
f23a03f6c7 | ||
|
|
2719b22258 | ||
|
|
3da374ef18 | ||
|
|
10b318cd6b | ||
|
|
3877ae52d8 | ||
|
|
166073d4bc | ||
|
|
71e2401edc | ||
|
|
35fc116bd3 | ||
|
|
2a95475e8f | ||
|
|
47894e831f | ||
|
|
5e735bd030 | ||
|
|
da4591bc88 |
No files matched your search
@@ -1764,14 +1764,13 @@ fn stack_from_template(template: &Value) -> Option<StackView> {
|
||||
|
||||
/// `RangeSelectItemModel.lastIndexLessOrEqual`.
|
||||
fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Option<usize> {
|
||||
let haystack: Vec<f32> = entries.iter().map(|e| e.0).collect();
|
||||
let found = if haystack.len() < 16 {
|
||||
haystack
|
||||
let found = if entries.len() < 16 {
|
||||
entries
|
||||
.iter()
|
||||
.position(|h| *h > needle)
|
||||
.map_or(haystack.len() as i64 - 1, |i| i as i64 - 1)
|
||||
.position(|e| e.0 > needle)
|
||||
.map_or(entries.len() as i64 - 1, |i| i as i64 - 1)
|
||||
} else {
|
||||
match binary_search(&haystack, needle) {
|
||||
match binary_search(entries, needle) {
|
||||
Ok(index) => index as i64,
|
||||
Err(insertion) => insertion as i64 - 1,
|
||||
}
|
||||
@@ -1780,8 +1779,8 @@ fn last_index_less_or_equal(entries: &[(f32, ItemModelNode)], needle: f32) -> Op
|
||||
}
|
||||
/// `Arrays.binarySearch(float[], float)`, including its handling of equal
|
||||
/// values (-0.0 below 0.0, NaN above everything).
|
||||
fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, a.len() as i64 - 1);
|
||||
fn binary_search(entries: &[(f32, ItemModelNode)], key: f32) -> std::result::Result<usize, usize> {
|
||||
let (mut low, mut high) = (0i64, entries.len() as i64 - 1);
|
||||
let bits = |v: f32| -> i32 {
|
||||
if v.is_nan() {
|
||||
0x7fc0_0000
|
||||
@@ -1791,7 +1790,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
|
||||
};
|
||||
while low <= high {
|
||||
let mid = (low + high) >> 1;
|
||||
let value = a[mid as usize];
|
||||
let value = entries[mid as usize].0;
|
||||
if value < key {
|
||||
low = mid + 1;
|
||||
} else if value > key {
|
||||
|
||||
@@ -158,3 +158,61 @@ fn a_patched_stack_reads_its_patch_over_its_items_defaults() {
|
||||
}
|
||||
assert_eq!(view.to_map(), *merged);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_index_less_or_equal_linear_and_binary() {
|
||||
use super::last_index_less_or_equal;
|
||||
let make = |thresholds: &[f32]| -> Vec<(f32, ItemModelNode)> {
|
||||
thresholds
|
||||
.iter()
|
||||
.map(|&t| (t, ItemModelNode::Empty))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Empty
|
||||
assert_eq!(last_index_less_or_equal(&[], 0.0), None);
|
||||
|
||||
// Short (< 16): linear search
|
||||
let short = make(&[-1.0, 0.0, 1.0, 2.5, 5.0]);
|
||||
assert_eq!(last_index_less_or_equal(&short, -2.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&short, -1.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, -0.5), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&short, 0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&short, 2.5), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 3.0), Some(3));
|
||||
assert_eq!(last_index_less_or_equal(&short, 5.0), Some(4));
|
||||
assert_eq!(last_index_less_or_equal(&short, 10.0), Some(4));
|
||||
|
||||
// Long (>= 16): binary search
|
||||
let thresholds: Vec<f32> = (0..32).map(|i| i as f32 * 10.0).collect();
|
||||
let long = make(&thresholds);
|
||||
assert_eq!(last_index_less_or_equal(&long, -5.0), None);
|
||||
assert_eq!(last_index_less_or_equal(&long, 0.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 5.0), Some(0));
|
||||
assert_eq!(last_index_less_or_equal(&long, 10.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&long, 150.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 155.0), Some(15));
|
||||
assert_eq!(last_index_less_or_equal(&long, 310.0), Some(31));
|
||||
assert_eq!(last_index_less_or_equal(&long, 1000.0), Some(31));
|
||||
|
||||
// Short lists use upper-bound tie behavior, including signed zero.
|
||||
let duplicates = make(&[1.0, 1.0, 2.0]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 1.0), Some(1));
|
||||
|
||||
let zeroes = make(&[-0.0, 0.0]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(1));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(1));
|
||||
|
||||
// At 16 entries, vanilla returns Arrays.binarySearch's hit, not the last tie.
|
||||
// Expected indices were checked on the JVM.
|
||||
let duplicates = make(&[
|
||||
0.0, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 7.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 13.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&duplicates, 7.0), Some(7));
|
||||
|
||||
let zeroes = make(&[
|
||||
-8.0, -7.0, -6.0, -5.0, -4.0, -3.0, -2.0, -0.0, -0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 4.0, 5.0,
|
||||
]);
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, -0.0), Some(7));
|
||||
assert_eq!(last_index_less_or_equal(&zeroes, 0.0), Some(9));
|
||||
}
|
||||
@@ -192,6 +192,7 @@ fn packed_wanted() -> bool {
|
||||
pub(crate) mod animate;
|
||||
mod arena;
|
||||
pub mod capture;
|
||||
mod census;
|
||||
pub mod compositor;
|
||||
pub(crate) mod core_lines;
|
||||
pub(crate) mod core_sky;
|
||||
@@ -2547,160 +2548,6 @@ enum FirstFrame {
|
||||
fn on_off(on: bool) -> &'static str {
|
||||
if on { "on" } else { "off" }
|
||||
}
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
impl Renderer {
|
||||
pub async fn new(
|
||||
window: Arc<Window>,
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
//! What the renderer holds, for status, soak tests and memory reports (`horizon-ctl memory`,
|
||||
//! `scene_replay --memory`): resident sections and far tiles, and the bytes of its largest
|
||||
//! GPU resources and of its per-section bookkeeping.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::{
|
||||
Renderer, SectionGpu, SectionKey, TerrainVertex, arena, flat, lod, texture_bytes, visbuffer,
|
||||
};
|
||||
|
||||
impl Renderer {
|
||||
/// Sections with GPU buffers, for soak tests watching residency.
|
||||
pub fn resident_sections(&self) -> usize {
|
||||
self.sections.len()
|
||||
}
|
||||
/// Quads of the far terrain tiles with GPU buffers.
|
||||
pub fn lod_quads(&self) -> u64 {
|
||||
self.lods
|
||||
.values()
|
||||
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
|
||||
.sum()
|
||||
}
|
||||
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
|
||||
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
|
||||
let mut levels = Vec::new();
|
||||
for (key, tile) in &self.lods {
|
||||
let level = usize::from(key.level);
|
||||
if levels.len() <= level {
|
||||
levels.resize(level + 1, [0, 0]);
|
||||
}
|
||||
levels[level][0] += 1;
|
||||
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
|
||||
}
|
||||
levels
|
||||
}
|
||||
/// Whether solid quads go to the arena in the compact layout
|
||||
/// (`packed_quads`), and whether far tiles' own records are on
|
||||
/// (`lod::packed::far_packing`).
|
||||
pub fn packed_layout(&self) -> [bool; 2] {
|
||||
[self.packed_quads, crate::lod::packed::far_packing()]
|
||||
}
|
||||
/// Bytes of the arena the resident chunk sections hold, and of the one far
|
||||
/// tiles hold: the same split `gpu_census` reports, for `status`, so the
|
||||
/// compact layouts can be seen working.
|
||||
pub fn vertex_bytes_split(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
[
|
||||
self.sections.values().map(used).sum(),
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
]
|
||||
}
|
||||
/// Bytes of the section vertex arenas' buffers, and of the vertices
|
||||
/// resident sections hold in them.
|
||||
pub fn section_vertex_bytes(&self) -> [u64; 2] {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let vertices: u64 = self
|
||||
.sections
|
||||
.values()
|
||||
.chain(self.lods.values().map(|tile| &*tile.mesh))
|
||||
.map(|s| s.solid_slots + u64::from(s.translucent) * 4)
|
||||
.sum();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
|
||||
[arenas, vertices * stride]
|
||||
}
|
||||
/// Bytes of the renderer's largest GPU resources, by name (`scene_replay --memory`):
|
||||
/// the terrain arenas (their pages, and what resident sections and far tiles use of
|
||||
/// them), the quad indices, the block sprites with their mips, the LabPBR maps, flat
|
||||
/// draws' buffers and the visibility buffer's.
|
||||
pub fn gpu_census(&self) -> Vec<(&'static str, u64)> {
|
||||
let stride = std::mem::size_of::<TerrainVertex>() as u64;
|
||||
let used = |s: &SectionGpu| (s.solid_slots + u64::from(s.translucent) * 4) * stride;
|
||||
let textures = |t: &[wgpu::Texture]| t.iter().map(texture_bytes).sum::<u64>();
|
||||
let translucent = self.translucent_arena.as_ref();
|
||||
vec![
|
||||
("section_arena_pages", self.arena.bytes()),
|
||||
(
|
||||
"translucent_arena_pages",
|
||||
translucent.map_or(0, arena::Arena::bytes),
|
||||
),
|
||||
("sorted_index_pages", self.sorted_indices.bytes()),
|
||||
(
|
||||
"near_section_vertices",
|
||||
self.sections.values().map(used).sum(),
|
||||
),
|
||||
(
|
||||
"held_section_vertices",
|
||||
self.hold
|
||||
.sections()
|
||||
.filter_map(|held| held.gpu.as_ref())
|
||||
.map(used)
|
||||
.sum(),
|
||||
),
|
||||
(
|
||||
"far_tile_vertices",
|
||||
self.lods.values().map(|tile| used(&tile.mesh)).sum(),
|
||||
),
|
||||
("quad_indices", self.indices.size()),
|
||||
("block_sprites", textures(&self.sprite_textures)),
|
||||
(
|
||||
"pbr_maps",
|
||||
self.pbr_maps
|
||||
.as_ref()
|
||||
.map_or(0, |maps| maps.iter().flatten().map(texture_bytes).sum()),
|
||||
),
|
||||
(
|
||||
"flat_draws",
|
||||
self.flat.as_ref().map_or(0, flat::Flat::bytes),
|
||||
),
|
||||
(
|
||||
"visibility_buffer",
|
||||
self.visbuf.as_ref().map_or(0, visbuffer::Visbuffer::bytes),
|
||||
),
|
||||
]
|
||||
}
|
||||
/// Device memory the backend's allocator holds, by the label of what holds
|
||||
/// it, largest first, with its allocated and reserved totals (Vulkan and
|
||||
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
|
||||
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
|
||||
let report = self.device.generate_allocator_report()?;
|
||||
let mut by_label: HashMap<String, u64> = HashMap::new();
|
||||
for a in report.allocations {
|
||||
*by_label.entry(a.name).or_default() += a.size;
|
||||
}
|
||||
let mut labels: Vec<_> = by_label.into_iter().collect();
|
||||
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
|
||||
Some((
|
||||
report.total_allocated_bytes,
|
||||
report.total_reserved_bytes,
|
||||
labels,
|
||||
))
|
||||
}
|
||||
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
|
||||
/// resident sections, far tiles, block entities and light sources by section.
|
||||
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
|
||||
use std::mem::size_of;
|
||||
let per_section = |map_len: usize, lists: usize| {
|
||||
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
|
||||
+ lists * size_of::<([i32; 3], u32)>()
|
||||
};
|
||||
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
|
||||
per_section(
|
||||
map.capacity(),
|
||||
map.values().map(Vec::capacity).sum::<usize>(),
|
||||
)
|
||||
};
|
||||
let animated: usize = self
|
||||
.sections
|
||||
.values()
|
||||
.map(|s| s.animated_layers.len() * size_of::<u16>())
|
||||
.sum();
|
||||
[
|
||||
("resident_sections", self.sections.heap_bytes() + animated),
|
||||
(
|
||||
"far_tiles",
|
||||
self.lods.capacity()
|
||||
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
|
||||
),
|
||||
("block_entities", lists(&self.block_entities)),
|
||||
("light_sources", lists(&self.light_blocks)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -916,15 +916,7 @@ pub fn mob_to_nbt(
|
||||
Tag::Int(m.extra.more_carrot_ticks),
|
||||
);
|
||||
}
|
||||
"minecraft:goat" => {
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
}
|
||||
"minecraft:goat" => crate::mobs::goat::write_nbt(m, map),
|
||||
"minecraft:creeper" => {
|
||||
map.insert("Fuse".into(), Tag::Short(m.max_swell as i16));
|
||||
map.insert(
|
||||
@@ -2353,11 +2345,7 @@ pub fn mob_from_nbt_with_random(
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
crate::mobs::Species::Goat => {
|
||||
m.extra.screaming = flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
}
|
||||
crate::mobs::Species::Goat => crate::mobs::goat::read_nbt(&mut m, tag),
|
||||
_ => {}
|
||||
}
|
||||
// `Rabbit.readAdditionalSaveData`.
|
||||
|
||||
@@ -26,6 +26,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
horizon_client::telemetry::mark_start();
|
||||
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
|
||||
crash::on_report(horizon_client::telemetry::keep_crash);
|
||||
// Play with Friends' host side, until it moves to horizon-friends (spec #14167).
|
||||
#[cfg(feature = "friends")]
|
||||
game::register_friends();
|
||||
// Crates above the engine registered their hooks before this (horizon-official's `main`),
|
||||
// then the Horizon parts still in this crate; none registers after this point.
|
||||
game::player_hooks::register();
|
||||
|
||||
@@ -423,6 +423,7 @@ mod server_links;
|
||||
mod server_packs;
|
||||
mod server_travel;
|
||||
mod shader_packs;
|
||||
mod share_view;
|
||||
mod sheep_dye;
|
||||
mod shelf;
|
||||
mod shield;
|
||||
@@ -601,6 +602,8 @@ use resource_loader::{
|
||||
EntityModels, GameCpuResources, Loaded, LoaderMsg, PendingWorldEntry, WorldRestLoader,
|
||||
run_loading, write_report,
|
||||
};
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) use share_view::register_friends;
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::{HashMap, HashSet},
|
||||
@@ -2898,9 +2901,6 @@ struct Game<'a> {
|
||||
level_shapes: horizon_client::chunk::LevelShapes,
|
||||
/// This game is that server thread's (game/integrated.rs).
|
||||
serving: Option<integrated::Serving>,
|
||||
/// The open world is shared behind an invite code (game/friends.rs).
|
||||
#[cfg(feature = "friends")]
|
||||
friends: Option<friends::Sharing>,
|
||||
/// The Social Interactions screen and the players whose chat is hidden (game/social.rs).
|
||||
social: social::Social,
|
||||
world_options: Option<lan_screen::WorldOptions>,
|
||||
@@ -5731,7 +5731,6 @@ impl Game<'_> {
|
||||
if let Some(log) = self.world.block_log.as_mut() {
|
||||
log.extend(held);
|
||||
}
|
||||
span("tick_friends", || self.tick_friends());
|
||||
horizon_client::spans::time("tick_hand", || self.tick_hand());
|
||||
horizon_client::spans::time("tick_sleep", || self.tick_sleep());
|
||||
span("sleep_status", || self.announce_sleep_status());
|
||||
@@ -8321,22 +8320,6 @@ impl Game<'_> {
|
||||
}
|
||||
self.tracked_special_box(id as i32)
|
||||
}
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
|
||||
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
|
||||
pub(super) fn forget_atlas_slots(&mut self) {
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.trim_slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
}
|
||||
|
||||
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
/// Rebuild GPU-owned state after the backend reports device loss.
|
||||
fn recover_device(&mut self) -> Result<()> {
|
||||
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
|
||||
@@ -8399,34 +8382,6 @@ impl Game<'_> {
|
||||
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
|
||||
Ok(())
|
||||
}
|
||||
/// A renderer made again from the resources loaded takes over: what named the old one's
|
||||
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
|
||||
/// settings are applied again.
|
||||
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
|
||||
// A new renderer starts with the vanilla column and without the world border's wall.
|
||||
renderer.clear_sections(self.world.layout());
|
||||
self.border_renderer.invalidate();
|
||||
self.renderer = Some(renderer);
|
||||
self.forget_atlas_slots();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
|
||||
/// discarded, then every loaded section is rebuilt.
|
||||
fn all_sections_changed(&mut self) {
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections(self.world.layout());
|
||||
}
|
||||
self.streaming.reload_sections(&mut self.world);
|
||||
}
|
||||
/// A translated message for the player the game acts for: `overlay` is
|
||||
/// `sendOverlayMessage` (the action bar), else `sendSystemMessage` (chat).
|
||||
/// A guest gets a packet, so its own client translates it.
|
||||
@@ -11138,6 +11093,7 @@ impl Game<'_> {
|
||||
// the world's own ticks.
|
||||
horizon_client::social::set_presence_status(self.presence_status());
|
||||
horizon_client::social::tick();
|
||||
self.share_tick();
|
||||
// Minecraft.tick: ClientTickEvent.Pre before any of it.
|
||||
self.native_client_tick_pre();
|
||||
if self.title.is_none() && !self.pause {
|
||||
@@ -11878,8 +11834,6 @@ impl<'a> Game<'a> {
|
||||
test_defs_snapshot: false,
|
||||
test_highlights: HashMap::new(),
|
||||
game_tests: game_tests::GameTests::default(),
|
||||
#[cfg(feature = "friends")]
|
||||
friends: None,
|
||||
social: Default::default(),
|
||||
debug_keys: Default::default(),
|
||||
debug_charts: Default::default(),
|
||||
|
||||
@@ -247,8 +247,16 @@ impl Game<'_> {
|
||||
}
|
||||
/// `CreakingHeartBlock.playerWillDestroy`: the heart's creaking twitches to
|
||||
/// death and a natural heart drops experience. `onExplosionHit` does the
|
||||
/// same for the creaking (`by_player` false), without the experience.
|
||||
/// same for the creaking, and awards the experience only when its
|
||||
/// blast's indirect source is a player (`creaking_heart_broken_by`).
|
||||
pub(super) fn creaking_heart_broken(&mut self, pos: [i32; 3], by_player: bool) {
|
||||
let award = by_player && !self.creative && !self.spectator();
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
/// `creaking_heart_broken` once the breaker is known: `award` is
|
||||
/// `tryAwardExperience`'s player check (a player who neither builds
|
||||
/// instantly, `preventsBlockDrops`, nor spectates).
|
||||
pub(super) fn creaking_heart_broken_by(&mut self, pos: [i32; 3], award: bool) {
|
||||
let key = (pos[0], pos[1], pos[2]);
|
||||
if !matches!(
|
||||
self.world.block_entities.get(&key),
|
||||
@@ -271,16 +279,20 @@ impl Game<'_> {
|
||||
.state(state)
|
||||
.and_then(|s| s.properties.get("natural").cloned())
|
||||
.is_some_and(|v| v == "true");
|
||||
if by_player && natural && !self.creative && !self.spectator() {
|
||||
if award && natural {
|
||||
let xp = 20 + self.world.random.next_int_bound(5).unwrap_or(0) as u32;
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
// Block.popExperience: the amount is drawn, then nothing pops
|
||||
// with `block_drops` off.
|
||||
if self.ui.rules.bool("block_drops") {
|
||||
self.spawn_orbs(
|
||||
[
|
||||
f64::from(pos[0]) + 0.5,
|
||||
f64::from(pos[1]) + 0.5,
|
||||
f64::from(pos[2]) + 0.5,
|
||||
],
|
||||
xp,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.world
|
||||
.block_entities
|
||||
|
||||
@@ -1341,10 +1341,18 @@ impl Game<'_> {
|
||||
}
|
||||
let name = self.res.facts().get(state).name.clone();
|
||||
let is_tnt = &*name == "minecraft:tnt";
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies (no
|
||||
// experience, as Horizon does not track a TNT's owner).
|
||||
// CreakingHeartBlock.onExplosionHit: its creaking dies, and a blast
|
||||
// whose indirect source is a player (a primed TNT's owner) awards
|
||||
// the heart's experience (`tryAwardExperience`).
|
||||
if &*name == "minecraft:creaking_heart" {
|
||||
self.creaking_heart_broken(pos, false);
|
||||
// tryAwardExperience checks the TNT owner's own abilities.
|
||||
let award = match self.combat.blast_guest {
|
||||
Some(conn) => self
|
||||
.command_guest(conn)
|
||||
.is_some_and(|g| !g.creative() && !g.spectator()),
|
||||
None => self.combat.blast_owner_player && !self.creative && !self.spectator(),
|
||||
};
|
||||
self.creaking_heart_broken_by(pos, award);
|
||||
}
|
||||
// Block.onExplosionHit: the drops copy from the block entity, which
|
||||
// then spills its contents as the block turns to air.
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
|
||||
//! loopback address. The joining side is horizon-friends' (`game`), registered by
|
||||
//! horizon-official.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
|
||||
//!
|
||||
//! It sees the game only through `horizon_hooks::share` (the engine's `ShareView`) and
|
||||
//! `Hooks::lan_closed`, and keeps what it shares in its own static,
|
||||
//! so it can leave the engine for horizon-friends (spec #14167). The screen and the
|
||||
//! `horizon-ctl friends` command below still reach it from `Game` until then.
|
||||
use super::Game;
|
||||
use horizon_client::{Result, hud::HudBatch};
|
||||
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
|
||||
use horizon_hooks::{
|
||||
Hooks,
|
||||
share::{LanOptions, ShareView},
|
||||
};
|
||||
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Mutex, MutexGuard, PoisonError};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod screen;
|
||||
@@ -18,7 +29,7 @@ mod screen;
|
||||
use screen::{self as friends_screen, Action, FriendsView};
|
||||
pub use screen::{TITLE, title};
|
||||
|
||||
pub(super) struct Sharing {
|
||||
struct Sharing {
|
||||
host: Host,
|
||||
/// Sharing opened the world to LAN, so stopping closes it again.
|
||||
opened_lan: bool,
|
||||
@@ -31,12 +42,52 @@ pub(super) struct Sharing {
|
||||
paths: (u32, u32),
|
||||
}
|
||||
|
||||
/// The world shared, if one is. The game has one window player who can share (the engine
|
||||
/// calls the hooks for it only), so one is all there is.
|
||||
static SHARING: Mutex<Option<Sharing>> = Mutex::new(None);
|
||||
/// Whether [`SHARING`] holds one: a tick with nothing shared is this one load.
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn sharing() -> MutexGuard<'static, Option<Sharing>> {
|
||||
SHARING.lock().unwrap_or_else(PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Ends the sharing, if there is one. The lock is let go before the caller touches the
|
||||
/// game: closing LAN calls [`Hooks::lan_closed`], which takes it again.
|
||||
fn take() -> Option<Sharing> {
|
||||
let taken = sharing().take();
|
||||
ON.store(false, Ordering::Relaxed);
|
||||
taken
|
||||
}
|
||||
|
||||
/// Registered by the engine's entry while this module is part of it.
|
||||
pub struct Friends;
|
||||
pub static HOOKS: Friends = Friends;
|
||||
|
||||
/// No `Hooks::share` yet: the code and sessions are read while the screen is drawn or
|
||||
/// `horizon-ctl friends status` asks, as before. The screen's page uses it (#14167 step 2a).
|
||||
impl Hooks for Friends {
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
fn lan_closed(&self) {
|
||||
if ON.load(Ordering::Relaxed)
|
||||
&& let Some(mut sharing) = take()
|
||||
{
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const HELP: &[&str] = &[
|
||||
"friends host share the open world behind an invite code",
|
||||
"friends stop stop sharing",
|
||||
"friends status the code, how friends reach this host, the guests",
|
||||
];
|
||||
|
||||
/// A string in the player's language, or the English one.
|
||||
fn tr(world: &dyn ShareView, key: &str, english: &str) -> String {
|
||||
world.tr(key).unwrap_or_else(|| english.to_owned())
|
||||
}
|
||||
|
||||
/// `HORIZON_STUN_SERVERS`, else `friendsStun` in horizon.txt, else the defaults.
|
||||
fn stun_servers() -> Vec<String> {
|
||||
if std::env::var_os("HORIZON_STUN_SERVERS").is_some() {
|
||||
@@ -53,101 +104,99 @@ fn env_flag(name: &str, default: bool) -> bool {
|
||||
std::env::var(name).map_or(default, |v| v != "0")
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Start sharing the open world.
|
||||
pub(super) fn friends_start(&mut self) -> Result<()> {
|
||||
if self.friends.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
// Single player on its server thread shares that server, as Open to
|
||||
// LAN publishes it; another server can't be shared.
|
||||
if self.title.is_some() || (self.remote.is_some() && self.integrated.is_none()) {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let opened_lan = self.lan_port().is_none();
|
||||
if opened_lan {
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
self.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
})?;
|
||||
}
|
||||
let port = self.lan_port().ok_or("the world isn't open to LAN")?;
|
||||
let host = match Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
}) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
self.lan_close(true);
|
||||
}
|
||||
return Err(e.into());
|
||||
/// Start sharing the open world.
|
||||
fn start(world: &mut dyn ShareView) -> std::result::Result<(), String> {
|
||||
if ON.load(Ordering::Relaxed) {
|
||||
return Ok(());
|
||||
}
|
||||
if !world.can_share() {
|
||||
return Err("open a singleplayer world first".into());
|
||||
}
|
||||
let (port, opened_lan) = match world.lan_port() {
|
||||
Some(port) => (port, false),
|
||||
// ShareToLanScreen's defaults, as the world options screen offers them.
|
||||
None => (world.open_lan(LanOptions::default())?, true),
|
||||
};
|
||||
let host = Host::start(HostConfig {
|
||||
lan: std::net::SocketAddr::from(([127, 0, 0, 1], port)),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
game: GAME_VERSION.to_owned(),
|
||||
relay: horizon_client::net::friends_relay_setting(),
|
||||
path: PathMode::from_env(),
|
||||
port: std::env::var("HORIZON_FRIENDS_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(DEFAULT_PORT),
|
||||
reach: reach::Options {
|
||||
// HORIZON_FRIENDS_UPNP=0 leaves the router alone.
|
||||
map_port: env_flag("HORIZON_FRIENDS_UPNP", true),
|
||||
stun: stun_servers(),
|
||||
// Both ends on one machine (tests).
|
||||
loopback: env_flag("HORIZON_FRIENDS_LOOPBACK", false),
|
||||
},
|
||||
});
|
||||
let host = match host {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
if opened_lan {
|
||||
world.close_lan();
|
||||
}
|
||||
};
|
||||
self.friends = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
return Err(e.to_string());
|
||||
}
|
||||
};
|
||||
*sharing() = Some(Sharing {
|
||||
host,
|
||||
opened_lan,
|
||||
code: None,
|
||||
wrong: 0,
|
||||
notice: None,
|
||||
copied: None,
|
||||
paths: (0, 0),
|
||||
});
|
||||
ON.store(true, Ordering::Relaxed);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
pub(super) fn friends_stop(&mut self) {
|
||||
let Some(mut sharing) = self.friends.take() else {
|
||||
/// Stop sharing: the code ends (direct port closed, router mapping
|
||||
/// deleted); the world closes to LAN again if sharing opened it.
|
||||
fn stop(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(mut sharing) = take() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
world.close_lan();
|
||||
} else {
|
||||
let text = tr(
|
||||
world,
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, while the screen is drawn and when `friends status` asks.
|
||||
fn tick(world: &mut dyn ShareView) {
|
||||
if !ON.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
{
|
||||
let mut guard = sharing();
|
||||
let Some(sharing) = guard.as_mut() else {
|
||||
return;
|
||||
};
|
||||
sharing.host.stop();
|
||||
if sharing.opened_lan {
|
||||
self.lan_close(true);
|
||||
} else {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.stopped",
|
||||
"Stopped sharing the invite code.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The LAN server is closing: the code goes with it.
|
||||
pub(super) fn friends_lan_closed(&mut self) {
|
||||
if let Some(mut sharing) = self.friends.take() {
|
||||
sharing.host.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// New codes and sessions, once a tick and while the pause menu is drawn.
|
||||
pub(super) fn tick_friends(&mut self) {
|
||||
let Some(sharing) = self.friends.as_mut() else {
|
||||
return;
|
||||
};
|
||||
let mut announce = false;
|
||||
let mut stopped = None;
|
||||
for event in sharing.host.poll() {
|
||||
match event {
|
||||
HostEvent::Code(code) => {
|
||||
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
world,
|
||||
"horizon.friends.short_code_changed",
|
||||
"Someone kept trying a wrong short code, so it changed.",
|
||||
));
|
||||
@@ -158,7 +207,8 @@ impl Game<'_> {
|
||||
}
|
||||
HostEvent::WrongCode { count } => {
|
||||
sharing.wrong = count;
|
||||
sharing.notice = Some(lang::get_or(
|
||||
sharing.notice = Some(tr(
|
||||
world,
|
||||
"horizon.friends.wrong_code",
|
||||
"Someone tried a wrong code.",
|
||||
));
|
||||
@@ -167,7 +217,8 @@ impl Game<'_> {
|
||||
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
|
||||
HostEvent::Disconnected { .. } => {}
|
||||
HostEvent::RelayStopped => {
|
||||
let text = lang::get_or(
|
||||
let text = tr(
|
||||
world,
|
||||
"horizon.friends.relay_stopped",
|
||||
"Too many wrong short codes, so the relay is off until you share again. Friends can still join with the full code.",
|
||||
);
|
||||
@@ -176,164 +227,272 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(text) = stopped {
|
||||
self.system_message(Text::literal(text));
|
||||
}
|
||||
if announce {
|
||||
self.system_message(Text::literal(lang::get_or(
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
)));
|
||||
}
|
||||
}
|
||||
if let Some(text) = stopped {
|
||||
world.chat(&text);
|
||||
}
|
||||
if announce {
|
||||
let text = tr(
|
||||
world,
|
||||
"horizon.friends.code_ready",
|
||||
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
|
||||
);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn friends_copy(&mut self) {
|
||||
let Some(code) = self.friends.as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
fn copy(world: &mut dyn ShareView) {
|
||||
if !world.can_share() {
|
||||
return;
|
||||
}
|
||||
let Some(code) = sharing().as_ref().and_then(|s| s.code.clone()) else {
|
||||
return;
|
||||
};
|
||||
world.copy(&code);
|
||||
if let Some(s) = sharing().as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
fn status(world: &dyn ShareView) -> FriendsView {
|
||||
// Another server, or a splitscreen guest in P1's world: nothing to share.
|
||||
if !world.can_share() {
|
||||
return FriendsView::default();
|
||||
}
|
||||
let guard = sharing();
|
||||
let Some(s) = guard.as_ref() else {
|
||||
return FriendsView {
|
||||
shown: true,
|
||||
..FriendsView::default()
|
||||
};
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.clipboard.set(&code);
|
||||
if let Some(s) = self.friends.as_mut() {
|
||||
s.copied = Some(Instant::now());
|
||||
}
|
||||
};
|
||||
let mut guests = Vec::new();
|
||||
world.players(&mut |name| guests.push(name.to_owned()));
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
tr(
|
||||
world,
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
|
||||
guests,
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends host|stop|status`.
|
||||
fn control(
|
||||
word: Option<&str>,
|
||||
world: &mut dyn ShareView,
|
||||
) -> std::result::Result<serde_json::Value, String> {
|
||||
use serde_json::json;
|
||||
match word {
|
||||
Some("host") => {
|
||||
start(world)?;
|
||||
Ok(json!({ "ok": true, "lan_port": world.lan_port() }))
|
||||
}
|
||||
Some("stop") => {
|
||||
stop(world);
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
Some("status") => {
|
||||
tick(world);
|
||||
let view = status(world);
|
||||
let guard = sharing();
|
||||
let sharing = guard.as_ref().filter(|_| view.sharing);
|
||||
let state = sharing.map(|s| s.host.state());
|
||||
Ok(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": sharing.map_or(0, |s| s.host.sessions()),
|
||||
"direct": sharing.map_or(0, |s| s.paths.0),
|
||||
"relayed": sharing.map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": world.lan_port(),
|
||||
}))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// Play with Friends. The world is paused under it in singleplayer, so
|
||||
/// new codes and sessions are read while it is drawn.
|
||||
pub(super) fn draw_friends_screen(&mut self, batch: &mut HudBatch) {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
let world = &mut self.share_view();
|
||||
tick(world);
|
||||
let view = status(world);
|
||||
horizon_client::widgets::begin_screen(|| {
|
||||
horizon_client::widgets::ScreenInfo::new("friends", friends_screen::TITLE)
|
||||
});
|
||||
friends_screen::draw(batch, &self.gui.atlas, self.cursor, &view, true);
|
||||
}
|
||||
|
||||
pub(super) fn friends_screen_click(&mut self) {
|
||||
let view = self.friends_view();
|
||||
let view = status(&self.share_view());
|
||||
let Some(action) =
|
||||
friends_screen::click(&self.gui.atlas, self.gui_size(), self.cursor, &view)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
self.audio.play_button_click();
|
||||
if action == Action::Done {
|
||||
self.screen = super::Screen::Paused;
|
||||
return;
|
||||
}
|
||||
let world = &mut self.share_view();
|
||||
match action {
|
||||
Action::Share => {
|
||||
if let Err(e) = self.friends_start() {
|
||||
self.system_message(Text::literal(
|
||||
lang::get_or(
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e.to_string(), 1),
|
||||
));
|
||||
if let Err(e) = start(world) {
|
||||
let text = tr(
|
||||
world,
|
||||
"horizon.friends.share_failed",
|
||||
"Couldn't share the world: %s",
|
||||
)
|
||||
.replacen("%s", &e, 1);
|
||||
world.chat(&text);
|
||||
}
|
||||
}
|
||||
Action::Copy => self.friends_copy(),
|
||||
Action::Stop => self.friends_stop(),
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
Action::Copy => copy(world),
|
||||
Action::Stop => stop(world),
|
||||
Action::Help => {
|
||||
if let Some(url) = &view.help {
|
||||
super::packs::open_uri(url);
|
||||
world.open_url(url);
|
||||
}
|
||||
}
|
||||
Action::Done => self.screen = super::Screen::Paused,
|
||||
Action::Done => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pause menu offers Play with Friends (a singleplayer world).
|
||||
pub(super) fn friends_shown(&self) -> bool {
|
||||
self.friends_view().shown
|
||||
}
|
||||
|
||||
/// What Play with Friends shows.
|
||||
pub(super) fn friends_view(&self) -> FriendsView {
|
||||
let guests = match &self.lan {
|
||||
Some(h) => h.hub.guests.iter().map(|g| g.name.clone()).collect(),
|
||||
// On the server thread: the other players the server lists.
|
||||
None if self.integrated.is_some() => {
|
||||
let me = self.options.player_name();
|
||||
let mut names = self.remote_online_names().unwrap_or_default();
|
||||
names.retain(|n| *n != me);
|
||||
names
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
let Some(s) = &self.friends else {
|
||||
return FriendsView {
|
||||
shown: self.remote.is_none() || self.integrated.is_some(),
|
||||
..FriendsView::default()
|
||||
};
|
||||
};
|
||||
let state = s.host.state();
|
||||
let mut status = if s.code.is_none() {
|
||||
lang::get_or(
|
||||
"horizon.friends.finding_route",
|
||||
"Finding how friends can reach you…",
|
||||
)
|
||||
} else {
|
||||
state.summary.clone()
|
||||
};
|
||||
if let Some(short) = &state.short {
|
||||
status.push_str(&format!(" Short code for the relay: {short}."));
|
||||
}
|
||||
if let Some(problem) = &state.relay_problem {
|
||||
status.push(' ');
|
||||
status.push_str(problem);
|
||||
}
|
||||
let reachable = matches!(
|
||||
state.verdict,
|
||||
Some(reach::Verdict::Mapped(_) | reach::Verdict::Public)
|
||||
) || state.short.is_some();
|
||||
FriendsView {
|
||||
shown: true,
|
||||
sharing: true,
|
||||
code: s.code.clone(),
|
||||
status,
|
||||
reachable,
|
||||
help: (!reachable && s.code.is_some()).then(|| reach::HELP_URL.to_owned()),
|
||||
guests,
|
||||
notice: s.notice.clone(),
|
||||
copied: s
|
||||
.copied
|
||||
.is_some_and(|t| t.elapsed() < Duration::from_secs(2)),
|
||||
}
|
||||
self.can_share()
|
||||
}
|
||||
|
||||
/// `horizon-ctl friends ...`.
|
||||
pub(super) fn friends_control(&mut self, words: &[&str]) -> Result<Option<serde_json::Value>> {
|
||||
use serde_json::json;
|
||||
if words[0] != "friends" {
|
||||
return Ok(None);
|
||||
}
|
||||
match words.get(1).copied() {
|
||||
Some("host") => {
|
||||
self.friends_start()?;
|
||||
Ok(Some(json!({ "ok": true, "lan_port": self.lan_port() })))
|
||||
Ok(Some(control(
|
||||
words.get(1).copied(),
|
||||
&mut self.share_view(),
|
||||
)?))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A world as the engine shows it, recording what the crate did to it.
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
can_share: bool,
|
||||
lan: Option<u16>,
|
||||
refuse_lan: bool,
|
||||
did: Vec<String>,
|
||||
}
|
||||
|
||||
impl ShareView for Fake {
|
||||
fn can_share(&self) -> bool {
|
||||
self.can_share
|
||||
}
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.lan
|
||||
}
|
||||
fn open_lan(&mut self, options: LanOptions) -> std::result::Result<u16, String> {
|
||||
self.did.push(format!("open_lan {options:?}"));
|
||||
if self.refuse_lan {
|
||||
return Err("no free port".into());
|
||||
}
|
||||
Some("stop") => {
|
||||
self.friends_stop();
|
||||
Ok(Some(json!({ "ok": true })))
|
||||
}
|
||||
Some("status") => {
|
||||
self.tick_friends();
|
||||
let view = self.friends_view();
|
||||
let state = self.friends.as_ref().map(|s| s.host.state());
|
||||
Ok(Some(json!({
|
||||
"sharing": view.sharing,
|
||||
"code": view.code,
|
||||
"short": state.as_ref().and_then(|s| s.short.as_ref().map(ToString::to_string)),
|
||||
"summary": view.status,
|
||||
"reachable": view.reachable,
|
||||
"verdict": state.as_ref().and_then(|s| s.verdict.as_ref().map(|v| format!("{v:?}"))),
|
||||
"direct_port": state.as_ref().map(|s| s.port),
|
||||
"sessions": self.friends.as_ref().map_or(0, |s| s.host.sessions()),
|
||||
"direct": self.friends.as_ref().map_or(0, |s| s.paths.0),
|
||||
"relayed": self.friends.as_ref().map_or(0, |s| s.paths.1),
|
||||
"guests": view.guests,
|
||||
"lan_port": self.lan_port(),
|
||||
})))
|
||||
}
|
||||
_ => Err("friends host|stop|status".into()),
|
||||
self.lan = Some(25565);
|
||||
Ok(25565)
|
||||
}
|
||||
fn close_lan(&mut self) {
|
||||
self.did.push("close_lan".into());
|
||||
self.lan = None;
|
||||
}
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
each("Alex");
|
||||
}
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.did.push(format!("chat {text}"));
|
||||
}
|
||||
fn copy(&mut self, text: &str) {
|
||||
self.did.push(format!("copy {text}"));
|
||||
}
|
||||
fn open_url(&mut self, url: &str) {
|
||||
self.did.push(format!("open_url {url}"));
|
||||
}
|
||||
fn tr(&self, _key: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_world_that_cannot_be_shared_is_left_alone() {
|
||||
// Another server, or a splitscreen guest in P1's world.
|
||||
let mut guest = Fake {
|
||||
lan: Some(25565),
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(
|
||||
control(Some("host"), &mut guest).unwrap_err(),
|
||||
"open a singleplayer world first"
|
||||
);
|
||||
stop(&mut guest);
|
||||
copy(&mut guest);
|
||||
assert!(guest.did.is_empty(), "{:?}", guest.did);
|
||||
assert!(!status(&guest).shown);
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sharing_opens_lan_as_open_to_lan_does_and_a_refusal_shares_nothing() {
|
||||
let mut world = Fake {
|
||||
can_share: true,
|
||||
refuse_lan: true,
|
||||
..Fake::default()
|
||||
};
|
||||
assert_eq!(start(&mut world).unwrap_err(), "no free port");
|
||||
assert_eq!(
|
||||
world.did,
|
||||
["open_lan LanOptions { guest_commands: false, force_game_mode: true }"]
|
||||
);
|
||||
// Offered, not shared: the guests show once sharing does.
|
||||
let view = status(&world);
|
||||
assert!(view.shown && !view.sharing && view.guests.is_empty());
|
||||
assert!(!ON.load(Ordering::Relaxed));
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,6 @@ pub fn title() -> String {
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn friends_lan_closed(&mut self) {}
|
||||
|
||||
pub(super) fn tick_friends(&mut self) {}
|
||||
|
||||
/// Only reached by naming the screen (`horizon-ctl`): back to the pause menu.
|
||||
pub(super) fn draw_friends_screen(&mut self, _batch: &mut HudBatch) {
|
||||
self.screen = Screen::Paused;
|
||||
|
||||
@@ -1490,7 +1490,7 @@ impl Game<'_> {
|
||||
None => self.unpublish(),
|
||||
};
|
||||
if closed {
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
}
|
||||
if closed && announce && self.integrated.is_some() {
|
||||
self.system_message(Text::translate(
|
||||
@@ -1500,7 +1500,7 @@ impl Game<'_> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
self.native_players_leaving();
|
||||
let Some(mut host) = self.lan.take() else {
|
||||
return;
|
||||
|
||||
@@ -445,14 +445,14 @@ impl Game<'_> {
|
||||
// is the same block as before, so its placement does not schedule
|
||||
// it, and without this it stayed powered for good.
|
||||
b.schedule_tick(level, ticks, BlockPos::new(p[0], p[1], p[2]), 8);
|
||||
if level.redstone_experiments {
|
||||
b.lightning_rod_update_neighbors(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
state,
|
||||
);
|
||||
}
|
||||
// `updateNeighbours`: unconditional; the experiments only decide
|
||||
// whether the update carries an orientation.
|
||||
b.lightning_rod_update_neighbors(
|
||||
level,
|
||||
ticks,
|
||||
BlockPos::new(p[0], p[1], p[2]),
|
||||
state,
|
||||
);
|
||||
});
|
||||
}
|
||||
// Level event 3002 with the rod's axis: sparks along it.
|
||||
@@ -831,6 +831,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `LightningRodBlock.onLightningStrike` updates the neighbours of the block
|
||||
/// in front of the rod whether or not redstone experiments are on (#14371):
|
||||
/// a wire beside that block is strongly powered by the struck rod.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
|
||||
fn a_struck_rod_updates_the_wire_beside_the_block_behind_it() {
|
||||
let mut game = super::super::native_machines::tests::server_game();
|
||||
let registry = game.pack.assets.registry;
|
||||
let stone = registry.default_state("minecraft:stone").unwrap();
|
||||
let wire = registry.default_state("minecraft:redstone_wire").unwrap();
|
||||
let mut rod = registry
|
||||
.state(registry.default_state("minecraft:lightning_rod").unwrap())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
rod.properties.insert("facing".into(), "up".into());
|
||||
let rod = registry.id(&rod).unwrap();
|
||||
// The rod on stone at (8, 64, 8); wire on stone beside that block.
|
||||
game.world.set(8, 64, 8, stone).unwrap();
|
||||
game.world.set(9, 63, 8, stone).unwrap();
|
||||
game.world.set(9, 64, 8, wire).unwrap();
|
||||
game.world.set(8, 65, 8, rod).unwrap();
|
||||
assert!(
|
||||
!game
|
||||
.level
|
||||
.enabled_features
|
||||
.iter()
|
||||
.any(|feature| feature == "minecraft:redstone_experiments")
|
||||
);
|
||||
game.strike_rod([8.5, 66.0, 8.5]);
|
||||
let power = registry
|
||||
.state(game.world.block(9, 64, 8))
|
||||
.unwrap()
|
||||
.properties
|
||||
.get("power")
|
||||
.cloned();
|
||||
assert_eq!(power.as_deref(), Some("15"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn viewer_box_reaches_15_around_and_down_and_21_up() {
|
||||
let bolt = [0.0, 64.0, 0.0];
|
||||
|
||||
@@ -489,6 +489,29 @@ impl Containers<'_> {
|
||||
}
|
||||
}
|
||||
/// `canTakeItemThroughFace` (furnaces and brewing stands restrict it).
|
||||
/// `Container.canTakeItem` for a hopper's slot of `source`: the vanilla
|
||||
/// shelf rule needs to know where the item is going.
|
||||
fn can_take_into(
|
||||
&self,
|
||||
view: &View,
|
||||
slot: usize,
|
||||
stack: &ItemStack,
|
||||
face: Direction,
|
||||
target: &View,
|
||||
) -> bool {
|
||||
if view.kind == Kind::Bookshelf {
|
||||
// `ChiseledBookShelfBlockEntity.canTakeItem`: the target must have
|
||||
// a free slot or a matching stack with room (`hasAnyMatching`).
|
||||
return (0..target.len()).any(|i| match self.get(target, i) {
|
||||
None => true,
|
||||
Some(other) => {
|
||||
other.same_item_same_components(stack)
|
||||
&& other.count + stack.count <= self.max_stack(other)
|
||||
}
|
||||
});
|
||||
}
|
||||
self.can_take(view, slot, stack, face)
|
||||
}
|
||||
fn can_take(&self, view: &View, slot: usize, stack: &ItemStack, face: Direction) -> bool {
|
||||
if let Kind::Composter(level) = view.kind {
|
||||
// OutputContainer.canTakeItemThroughFace.
|
||||
@@ -1443,7 +1466,7 @@ impl Game<'_> {
|
||||
let Some(stack) = c.insertion_item(source, slot).map(Cow::into_owned) else {
|
||||
continue;
|
||||
};
|
||||
if !c.can_take(source, slot, &stack, Direction::Down) {
|
||||
if !c.can_take_into(source, slot, &stack, Direction::Down, hopper) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -2482,3 +2505,65 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "machines_chest_cat_tests.rs"]
|
||||
mod machines_chest_cat_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod can_take_tests {
|
||||
use super::*;
|
||||
use crate::game::native_machines::tests::server_game;
|
||||
use horizon_client::containers::ChiseledBookshelf;
|
||||
|
||||
/// `ChiseledBookShelfBlockEntity.canTakeItem`: a hopper takes a book only
|
||||
/// when it has a free slot or a matching stack with room.
|
||||
#[test]
|
||||
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
|
||||
fn a_full_hopper_cannot_take_a_book_from_a_chiseled_shelf() {
|
||||
let mut game = server_game();
|
||||
let shelf_pos = (2, 4, 3);
|
||||
let hopper_pos = (2, 5, 3);
|
||||
let shelf_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:chiseled_bookshelf")
|
||||
.unwrap();
|
||||
let hopper_state = game
|
||||
.pack
|
||||
.assets
|
||||
.registry
|
||||
.default_state("minecraft:hopper")
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(shelf_pos.0, shelf_pos.1, shelf_pos.2, shelf_state)
|
||||
.unwrap();
|
||||
game.world
|
||||
.set(hopper_pos.0, hopper_pos.1, hopper_pos.2, hopper_state)
|
||||
.unwrap();
|
||||
let book = ItemStack::new("minecraft:book", 1);
|
||||
let mut shelf = ChiseledBookshelf::default();
|
||||
shelf.items[0] = Some(book.clone());
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(shelf_pos, BlockEntity::ChiseledBookshelf(Box::new(shelf)));
|
||||
let stone = ItemStack::new("minecraft:stone", 64);
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(hopper_pos, BlockEntity::Chest(vec![Some(stone.clone()); 5]));
|
||||
let shelf_view = game.containers().view(shelf_pos, false).expect("shelf");
|
||||
let full_hopper = game.containers().view(hopper_pos, false).expect("hopper");
|
||||
assert!(
|
||||
!game
|
||||
.containers()
|
||||
.can_take_into(&shelf_view, 0, &book, Direction::Down, &full_hopper),
|
||||
"a full hopper takes no book"
|
||||
);
|
||||
game.world
|
||||
.block_entities
|
||||
.insert(hopper_pos, BlockEntity::Chest(vec![None; 5]));
|
||||
let empty_hopper = game.containers().view(hopper_pos, false).expect("hopper");
|
||||
assert!(
|
||||
game.containers()
|
||||
.can_take_into(&shelf_view, 0, &book, Direction::Down, &empty_hopper),
|
||||
"an empty hopper takes the book"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1572,7 +1572,7 @@ impl Game<'_> {
|
||||
self.left = false;
|
||||
self.right = false;
|
||||
// The shared world is closing: its invite code ends with it.
|
||||
self.friends_lan_closed();
|
||||
self.lan_closed_hooks();
|
||||
// Single player's server thread saves the world before the title shows.
|
||||
let single_player = match self.integrated.take() {
|
||||
Some(server) => {
|
||||
|
||||
@@ -221,6 +221,53 @@ impl Game<'_> {
|
||||
self.streaming.radius
|
||||
}
|
||||
}
|
||||
|
||||
/// The renderer for the window, with the current block sprites, GUI
|
||||
/// atlas and world border texture.
|
||||
/// A new renderer's atlas holds only the pack's sprites: what was drawn into it at run time
|
||||
/// (skins, maps, the themed UI's glyphs and corners) has to be drawn again.
|
||||
pub(super) fn forget_atlas_slots(&mut self) {
|
||||
self.skin_slots.forget();
|
||||
self.maps.slots.forget();
|
||||
self.trim_slots.forget();
|
||||
self.map_patches.clear();
|
||||
self.kept_block_entities.get_mut().clear();
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
}
|
||||
|
||||
pub(super) fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
|
||||
self.renderer_for(window, &self.pack, &self.gui)
|
||||
}
|
||||
|
||||
/// A renderer made again from the resources loaded takes over: what named the old one's
|
||||
/// atlas slots is forgotten, the world is meshed again, and the shader pack and the
|
||||
/// settings are applied again.
|
||||
pub(super) fn adopt_renderer(&mut self, mut renderer: Renderer) {
|
||||
// A new renderer starts with the vanilla column and without the world border's wall.
|
||||
renderer.clear_sections(self.world.layout());
|
||||
self.border_renderer.invalidate();
|
||||
self.renderer = Some(renderer);
|
||||
self.forget_atlas_slots();
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
let context = self.worker_context();
|
||||
self.streaming.reload_resources(&mut self.world, context);
|
||||
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
|
||||
&mut self.native_shaders.picked,
|
||||
));
|
||||
self.apply_ui_settings();
|
||||
}
|
||||
|
||||
/// `LevelExtractor.allChanged`: displayed sections and mesh work are
|
||||
/// discarded, then every loaded section is rebuilt.
|
||||
pub(super) fn all_sections_changed(&mut self) {
|
||||
self.clear_lod();
|
||||
self.pending_uploads.clear();
|
||||
if let Some(renderer) = self.renderer.as_mut() {
|
||||
renderer.clear_sections(self.world.layout());
|
||||
}
|
||||
self.streaming.reload_sections(&mut self.world);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! The window's world as `horizon_hooks::share` shows it to the crates that share it past the
|
||||
//! LAN (Play with Friends), and where the engine calls `Hooks::share` and `Hooks::lan_closed`.
|
||||
use super::{Game, lan::LanSettings};
|
||||
use horizon_client::text::Text;
|
||||
use horizon_hooks::share::{LanOptions, ShareView};
|
||||
|
||||
/// The window's own player: not the integrated server's thread, not a splitscreen guest (it
|
||||
/// plays P1's world and can't share it; its leaving doesn't close it).
|
||||
fn shares(game: &Game<'_>) -> bool {
|
||||
!game.serving() && !game.options.split_guest
|
||||
}
|
||||
|
||||
/// Play with Friends' host side (game/friends.rs) hears the hooks, until it moves to
|
||||
/// horizon-friends (spec #14167). Before the hooks are sealed.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(crate) fn register_friends() {
|
||||
if horizon_hooks::register(&super::friends::HOOKS).is_err() {
|
||||
log::warn!("Play with Friends registered after the game started: sharing is off");
|
||||
}
|
||||
}
|
||||
|
||||
impl Game<'_> {
|
||||
/// `Hooks::share`, once a client tick (`Minecraft.runTick`, so also while paused).
|
||||
pub(super) fn share_tick(&mut self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::share(&mut World(self));
|
||||
}
|
||||
}
|
||||
|
||||
/// `Hooks::lan_closed`: the world stopped being open to LAN, or the player left it.
|
||||
pub(super) fn lan_closed_hooks(&self) {
|
||||
if shares(self) {
|
||||
horizon_hooks::engine::lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared, as Open
|
||||
/// to LAN publishes it; another server can't be.
|
||||
pub(super) fn can_share(&self) -> bool {
|
||||
self.title.is_none() && (self.remote.is_none() || self.integrated.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'g> Game<'g> {
|
||||
/// The view of this window's world, for the engine's own Friends screen and command until
|
||||
/// they move to the crate.
|
||||
#[cfg(feature = "friends")]
|
||||
pub(super) fn share_view(&mut self) -> World<'_, 'g> {
|
||||
World(self)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct World<'a, 'g>(&'a mut Game<'g>);
|
||||
|
||||
impl ShareView for World<'_, '_> {
|
||||
fn can_share(&self) -> bool {
|
||||
self.0.can_share()
|
||||
}
|
||||
|
||||
fn lan_port(&self) -> Option<u16> {
|
||||
self.0.lan_port()
|
||||
}
|
||||
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String> {
|
||||
self.0
|
||||
.lan_open(LanSettings {
|
||||
port: horizon_client::lan::available_port(),
|
||||
guest_commands: options.guest_commands,
|
||||
force_game_mode: options.force_game_mode,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
self.0
|
||||
.lan_port()
|
||||
.ok_or_else(|| "the world isn't open to LAN".to_owned())
|
||||
}
|
||||
|
||||
fn close_lan(&mut self) {
|
||||
self.0.lan_close(true);
|
||||
}
|
||||
|
||||
fn players(&self, each: &mut dyn FnMut(&str)) {
|
||||
let g = &self.0;
|
||||
if let Some(host) = &g.lan {
|
||||
for guest in &host.hub.guests {
|
||||
each(&guest.name);
|
||||
}
|
||||
} else if g.integrated.is_some() {
|
||||
// On the server thread: the other players the server lists.
|
||||
let me = g.options.player_name();
|
||||
for name in g.remote_online_names().unwrap_or_default() {
|
||||
if name != me {
|
||||
each(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chat(&mut self, text: &str) {
|
||||
self.0.system_message(Text::literal(text));
|
||||
}
|
||||
|
||||
fn copy(&mut self, text: &str) {
|
||||
use horizon_client::clipboard::Clipboard;
|
||||
self.0.clipboard.set(text);
|
||||
}
|
||||
|
||||
fn open_url(&mut self, url: &str) {
|
||||
// The system browser, as the folder buttons open their folders.
|
||||
super::packs::open_uri(url);
|
||||
}
|
||||
|
||||
fn tr(&self, key: &str) -> Option<String> {
|
||||
horizon_client::lang::has(key).then(|| horizon_client::lang::get(key))
|
||||
}
|
||||
}
|
||||
@@ -627,14 +627,14 @@ pub(super) fn count_down_cooldown(m: &mut Mob) {
|
||||
}
|
||||
}
|
||||
/// `LONG_JUMP_COOLDOWN_TICKS`.
|
||||
fn jump_cooldown(m: &Mob) -> Option<i64> {
|
||||
pub(crate) fn jump_cooldown(m: &Mob) -> Option<i64> {
|
||||
m.extra
|
||||
.memories
|
||||
.iter()
|
||||
.find(|x| x.0 == LONG_JUMP_COOLDOWN)
|
||||
.map(|x| x.1)
|
||||
}
|
||||
fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
|
||||
pub(crate) fn set_jump_cooldown(m: &mut Mob, ticks: Option<i64>) {
|
||||
let memories = &mut m.extra.memories;
|
||||
let at = memories.iter().position(|x| x.0 == LONG_JUMP_COOLDOWN);
|
||||
match (at, ticks) {
|
||||
|
||||
@@ -555,6 +555,115 @@ fn finish_ram(m: &mut Mob, events: &mut Vec<MobEvent>) {
|
||||
m.extra.goat.phase = Phase::Done;
|
||||
}
|
||||
|
||||
/// `Goat.addAdditionalSaveData` and the `GoatAi` brain memory Horizon writes
|
||||
/// with it: the screaming flag, the missing horns and the long jump cooldown
|
||||
/// (`LONG_JUMP_COOLDOWN_TICKS`, an integer memory in the brain).
|
||||
pub fn write_nbt(m: &Mob, map: &mut horizon_world::nbt::Compound) {
|
||||
use horizon_world::nbt::Tag;
|
||||
map.insert(
|
||||
"IsScreamingGoat".into(),
|
||||
Tag::Byte(i8::from(m.extra.screaming)),
|
||||
);
|
||||
let [left, right] = m.extra.lost_horns;
|
||||
map.insert("HasLeftHorn".into(), Tag::Byte(i8::from(!left)));
|
||||
map.insert("HasRightHorn".into(), Tag::Byte(i8::from(!right)));
|
||||
let brain = map
|
||||
.entry("Brain".into())
|
||||
.or_insert_with(|| Tag::Compound(Default::default()));
|
||||
if !matches!(brain, Tag::Compound(_)) {
|
||||
*brain = Tag::Compound(Default::default());
|
||||
}
|
||||
let Tag::Compound(brain) = brain else { return };
|
||||
let memories = brain
|
||||
.entry("memories".into())
|
||||
.or_insert_with(|| Tag::Compound(Default::default()));
|
||||
if !matches!(memories, Tag::Compound(_)) {
|
||||
*memories = Tag::Compound(Default::default());
|
||||
}
|
||||
let Tag::Compound(memories) = memories else {
|
||||
return;
|
||||
};
|
||||
match crate::mobs::frog::jump_cooldown(m) {
|
||||
Some(ticks) => {
|
||||
memories.insert(
|
||||
"minecraft:long_jump_cooling_down".into(),
|
||||
Tag::compound([("value", Tag::Int(ticks as i32))]),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
memories.remove(&"minecraft:long_jump_cooling_down".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `Goat.readAdditionalSaveData` and `Brain.Packed`'s memory: the three own
|
||||
/// fields and the ticks left of the long jump cooldown.
|
||||
pub fn read_nbt(m: &mut Mob, tag: &horizon_world::nbt::Tag) {
|
||||
m.extra.screaming = crate::entity_nbt::flag(tag, "IsScreamingGoat");
|
||||
let horn = |key| crate::save::number(tag.get(key)).is_some_and(|v| v == 0);
|
||||
m.extra.lost_horns = [horn("HasLeftHorn"), horn("HasRightHorn")];
|
||||
let cooldown = tag
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:long_jump_cooling_down"))
|
||||
.and_then(|m| crate::save::number(m.get("value")));
|
||||
crate::mobs::frog::set_jump_cooldown(m, cooldown);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod long_jump_save_tests {
|
||||
use super::*;
|
||||
use crate::entity_nbt::{mob_from_nbt, mob_to_nbt};
|
||||
use horizon_world::nbt::Tag;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn goat() -> Mob {
|
||||
let mut kind = crate::mobs::sensor_scan_tests::kind();
|
||||
let k = Arc::make_mut(&mut kind);
|
||||
k.id = "minecraft:goat".into();
|
||||
k.species = crate::mobs::Species::Goat;
|
||||
Mob::new(7, kind, Vec3::new(0.5, 0.0, 0.5), 0.0)
|
||||
}
|
||||
|
||||
/// `GoatAi`'s `LONG_JUMP_COOLDOWN_TICKS` is a brain memory, and
|
||||
/// `Brain.Packed` saves every memory: `mob_to_nbt`/`mob_from_nbt` are the
|
||||
/// pair `Game::mob_tree_nbt` and `Game::load_mob` go through. Horizon
|
||||
/// counted the cooldown in the goat's memories and wrote no `Brain`, so a
|
||||
/// goat that had just long jumped came back ready to jump again.
|
||||
#[test]
|
||||
fn a_goat_keeps_its_long_jump_cooldown_through_a_save() {
|
||||
let mut m = goat();
|
||||
assert_eq!(crate::mobs::frog::jump_cooldown(&m), None);
|
||||
crate::mobs::frog::set_jump_cooldown(&mut m, Some(57));
|
||||
let saved = mob_to_nbt(&m, [1, 2, 3, 4], None, None, |_| None);
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:long_jump_cooling_down")),
|
||||
Some(&Tag::compound([("value", Tag::Int(57))])),
|
||||
"the save names the ticks left"
|
||||
);
|
||||
let types = std::collections::HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
|
||||
let back = mob_from_nbt(&saved, &types, 2, None).unwrap();
|
||||
assert_eq!(
|
||||
crate::mobs::frog::jump_cooldown(&back),
|
||||
Some(57),
|
||||
"the reloaded cooldown"
|
||||
);
|
||||
// No cooldown writes no memory, even over a compound that names one.
|
||||
let saved = mob_to_nbt(&goat(), [1, 2, 3, 4], Some(&saved), None, |_| None);
|
||||
assert_eq!(
|
||||
saved
|
||||
.get("Brain")
|
||||
.and_then(|b| b.get("memories"))
|
||||
.and_then(|b| b.get("minecraft:long_jump_cooling_down")),
|
||||
None,
|
||||
"no cooldown, no key"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod sound_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -763,6 +763,12 @@ impl Mob {
|
||||
/// nautiluses keep their own course.
|
||||
fn pushed_by_fluid(&self) -> bool {
|
||||
use super::Species as S;
|
||||
// Drowned.isPushedByFluid: a drowned swimming after its target is not
|
||||
// pushed by the current, one walking the bottom is (`extra.flag` holds
|
||||
// the `isSwimming` that `Drowned.updateSwimming` sets).
|
||||
if self.species() == S::Drowned && self.extra.flag {
|
||||
return false;
|
||||
}
|
||||
!matches!(
|
||||
self.species(),
|
||||
S::Tadpole
|
||||
|
||||
@@ -355,6 +355,39 @@ fn a_ridden_horse_floats_in_deep_water() {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Drowned.isPushedByFluid`: a drowned answers `!isSwimming()`, so the current
|
||||
/// of a stream pushes a drowned walking its bottom and leaves one swimming after
|
||||
/// its target alone, where every land mob feels it.
|
||||
#[test]
|
||||
fn a_swimming_drowned_is_not_pushed_by_the_current() {
|
||||
use crate::mobs::Species;
|
||||
let mob = |species| {
|
||||
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
|
||||
kind.species = species;
|
||||
Mob::new(1, std::sync::Arc::new(kind), Vec3::ZERO, 0.0)
|
||||
};
|
||||
// Drowned.updateSwimming keeps `isSwimming` on `extra.flag`.
|
||||
let mut swimming = mob(Species::Drowned);
|
||||
swimming.extra.flag = true;
|
||||
assert!(
|
||||
!swimming.pushed_by_fluid(),
|
||||
"a swimming drowned holds still"
|
||||
);
|
||||
swimming.extra.flag = false;
|
||||
assert!(swimming.pushed_by_fluid(), "one on the bottom is pushed");
|
||||
// The water mobs answer false either way.
|
||||
for quiet in [
|
||||
Species::Cod,
|
||||
Species::Squid,
|
||||
Species::Turtle,
|
||||
Species::Nautilus,
|
||||
] {
|
||||
assert!(!mob(quiet).pushed_by_fluid(), "{quiet:?}");
|
||||
}
|
||||
// A land mob is pushed.
|
||||
assert!(mob(Species::Zombie).pushed_by_fluid());
|
||||
}
|
||||
|
||||
/// `Entity.collide` takes `getEntityCollisions` too: a zombie dropping
|
||||
/// onto a living shulker or a happy ghast held still (a player on its
|
||||
/// back) lands on it; a dead shulker, a happy ghast that is not held, or
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod pages;
|
||||
pub mod players;
|
||||
mod registry;
|
||||
pub mod settings;
|
||||
pub mod share;
|
||||
pub mod tunnel;
|
||||
|
||||
use registry::Registry;
|
||||
@@ -89,6 +90,14 @@ pub trait Hooks: Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// Once a client tick (20 a second, also while the game is paused or on the title screen),
|
||||
/// with the window's world ([`share`]). Only the window's own player calls it.
|
||||
fn share(&self, _world: &mut dyn share::ShareView) {}
|
||||
|
||||
/// The window's world stopped being open to LAN: Open to LAN's stop, or the player left the
|
||||
/// world. Called at that moment, by the window's own player only.
|
||||
fn lan_closed(&self) {}
|
||||
|
||||
/// A game window opened: once, before the first frame, for every run that has a window,
|
||||
/// automated ones included ([`Client::automated`]); never for a dedicated server.
|
||||
fn client_started(&self, _client: &Client) {}
|
||||
@@ -301,6 +310,20 @@ pub mod engine {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::share`].
|
||||
pub fn share(world: &mut dyn share::ShareView) {
|
||||
for hooks in all() {
|
||||
hooks.share(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Hooks::lan_closed`].
|
||||
pub fn lan_closed() {
|
||||
for hooks in all() {
|
||||
hooks.lan_closed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether any hook is registered: without one the engine builds no view to hand them.
|
||||
pub fn any() -> bool {
|
||||
!all().is_empty()
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Sharing the open world past the LAN (Play with Friends' host side): what the engine lets a
|
||||
//! crate do with the world a window plays. The engine implements [`ShareView`] and hands it to
|
||||
//! [`Hooks::share`](crate::Hooks::share) once a client tick; [`Hooks::lan_closed`](crate::Hooks::lan_closed)
|
||||
//! says the world stopped being open to LAN.
|
||||
//!
|
||||
//! Only the window's own player calls them (a splitscreen guest can't share P1's world, and
|
||||
//! its leaving doesn't close it), never the integrated server's thread or a dedicated server.
|
||||
|
||||
/// How [`ShareView::open_lan`] opens the world, as Open to LAN's screen does. The default is
|
||||
/// what `ShareToLanScreen` offers: no commands for guests, the world's game mode forced on them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub struct LanOptions {
|
||||
/// Guests may run commands (`ShareToLanScreen`'s "Allow Cheats").
|
||||
pub guest_commands: bool,
|
||||
/// Guests play in the world's game mode.
|
||||
pub force_game_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for LanOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
guest_commands: false,
|
||||
force_game_mode: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's world, as a crate sharing it sees it. Nothing here allocates per call unless a
|
||||
/// method says so.
|
||||
pub trait ShareView {
|
||||
/// A singleplayer world runs on this window's server thread, so it can be shared. False on
|
||||
/// the title screen and on another server.
|
||||
fn can_share(&self) -> bool;
|
||||
/// The port the world is open to LAN on, if it is.
|
||||
fn lan_port(&self) -> Option<u16>;
|
||||
/// Opens the world to LAN on a free port, as Open to LAN does; the port, or why not.
|
||||
fn open_lan(&mut self, options: LanOptions) -> Result<u16, String>;
|
||||
/// Closes the world to LAN, saying so in chat as Open to LAN's stop does.
|
||||
fn close_lan(&mut self);
|
||||
/// The names of the other players in the world (not this one's). For a screen or a command,
|
||||
/// not every tick.
|
||||
fn players(&self, each: &mut dyn FnMut(&str));
|
||||
/// A line in this player's chat, as the game's own system messages; `text` is already in
|
||||
/// the player's language ([`ShareView::tr`]).
|
||||
fn chat(&mut self, text: &str);
|
||||
/// Puts `text` on the system clipboard.
|
||||
fn copy(&mut self, text: &str);
|
||||
/// Opens `url` in the system's browser.
|
||||
fn open_url(&mut self, url: &str);
|
||||
/// The translation of `key` in the player's language, if the game has one.
|
||||
fn tr(&self, key: &str) -> Option<String>;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ pub mod hooks {
|
||||
};
|
||||
pub use horizon_hooks::{
|
||||
Activity, Client, Dimension, GameMode, GetTheGame, Hooks, Play, Started, TooLate, register,
|
||||
seat, tunnel,
|
||||
seat, share, tunnel,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -123,12 +123,32 @@ impl Inflater {
|
||||
/// The zlib stream at the front of `input`, which must hold exactly `size` bytes.
|
||||
fn inflate(&mut self, input: &[u8], size: usize) -> Result<Vec<u8>> {
|
||||
self.0.reset(true);
|
||||
// One byte more than declared: a longer stream fills it.
|
||||
let mut out = Vec::with_capacity(size + 1);
|
||||
let status = self
|
||||
.0
|
||||
.decompress_vec(input, &mut out, FlushDecompress::Finish)
|
||||
.map_err(|e| Error::new(format!("Badly compressed packet - {e}")))?;
|
||||
// One byte more than declared: a longer stream fills it. Reserved as the output grows, not
|
||||
// all at once: a few bytes declaring 8 MiB would otherwise allocate 8 MiB per frame.
|
||||
// DEFLATE expands at most about 1032:1.
|
||||
let limit = size + 1;
|
||||
let mut out =
|
||||
Vec::with_capacity(limit.min(input.len().saturating_mul(1032).saturating_add(64)));
|
||||
let status = loop {
|
||||
let consumed = usize::try_from(self.0.total_in()).unwrap_or(input.len());
|
||||
let status = self
|
||||
.0
|
||||
.decompress_vec(
|
||||
input.get(consumed..).unwrap_or_default(),
|
||||
&mut out,
|
||||
FlushDecompress::Finish,
|
||||
)
|
||||
.map_err(|e| Error::new(format!("Badly compressed packet - {e}")))?;
|
||||
// Done, past the limit, or stopped with room left (the input ran out).
|
||||
if matches!(status, Status::StreamEnd)
|
||||
|| out.len() >= limit
|
||||
|| out.len() < out.capacity()
|
||||
{
|
||||
break status;
|
||||
}
|
||||
let more = out.len().max(4096).min(limit - out.len());
|
||||
out.reserve_exact(more);
|
||||
};
|
||||
if out.len() != size || !matches!(status, Status::StreamEnd) {
|
||||
return Err(Error::new(format!(
|
||||
"Badly compressed packet - actual length {} does not match declared size {size}",
|
||||
@@ -228,6 +248,28 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_bodies_inflate_whole_and_short_streams_are_refused() {
|
||||
// 2 MiB of zeros deflate to a few KiB: inflated whole as the buffer grows.
|
||||
let body = vec![0u8; 2 << 20];
|
||||
let frame = contents(&body, Some(256)).unwrap();
|
||||
let mut inflater = Inflater::default();
|
||||
let Body::Inflated(out) = super::body(&frame, Some(256), &mut inflater).unwrap() else {
|
||||
panic!("compressed");
|
||||
};
|
||||
assert!(out == body);
|
||||
// A few bytes declaring 8 MiB: refused (and no longer 8 MiB reserved for them).
|
||||
let mut lying = Vec::new();
|
||||
write_varint(&mut lying, MAX_UNCOMPRESSED as i32);
|
||||
lying.extend_from_slice(&frame[3..20]);
|
||||
assert!(super::body(&lying, Some(256), &mut inflater).is_err());
|
||||
// The same inflater still reads a good frame after.
|
||||
assert!(matches!(
|
||||
super::body(&frame, Some(256), &mut inflater).unwrap(),
|
||||
Body::Inflated(_)
|
||||
));
|
||||
}
|
||||
|
||||
/// Split one complete frame off the front of `buffer`, if available.
|
||||
fn split(buffer: &mut Vec<u8>) -> Result<Option<Vec<u8>>> {
|
||||
let Some((start, len)) = header(buffer)? else {
|
||||
|
||||
@@ -67,6 +67,10 @@ impl Watched {
|
||||
/// signal trampoline's, the one frame the system puts between the handler and the interrupted
|
||||
/// code (named `__restore_rt`, `_sigtramp`, or after whatever symbol precedes it). Unchanged when
|
||||
/// the handler's frame isn't found.
|
||||
#[cfg_attr(
|
||||
not(any(target_vendor = "apple", target_os = "linux", target_os = "android")),
|
||||
allow(dead_code)
|
||||
)]
|
||||
fn after_handler(text: &str) -> String {
|
||||
fn frame_name(line: &str) -> Option<&str> {
|
||||
let (n, name) = line.trim().split_once(": ")?;
|
||||
@@ -157,12 +161,16 @@ mod imp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// The busy thread below serves the test that reads another thread's stack, which only the
|
||||
// systems with a signal path run.
|
||||
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc,
|
||||
};
|
||||
|
||||
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
|
||||
#[inline(never)]
|
||||
fn spin(mut n: u64) -> u64 {
|
||||
for _ in 0..1_000_000 {
|
||||
@@ -173,6 +181,7 @@ mod tests {
|
||||
|
||||
/// Its time goes to a call: Apple's arm64 unwinder loses the frame the signal interrupted
|
||||
/// (it resumes from the return address), while every caller's frame stays.
|
||||
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
|
||||
#[inline(never)]
|
||||
fn busy_in_a_named_frame(stop: &AtomicBool) {
|
||||
let mut n = 0u64;
|
||||
|
||||
+3
-1
@@ -287,7 +287,9 @@ build pays nothing.
|
||||
|
||||
The hooks today: `started` (once, when someone plays: not a dedicated server, a benchmark or an automated run),
|
||||
`published` (the world opened to or closed from LAN or friends, on change), `shutdown` (the window closed, the
|
||||
world saved) and `tunnel` (the resolver of `friend:` addresses, Play with Friends'). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
world saved), `tunnel` (the resolver of `friend:` addresses, Play with Friends'), `share` (each client tick, the window's world
|
||||
through `share::ShareView`: open it to LAN, its players, chat, the clipboard) and `lan_closed` (it stopped being open
|
||||
to LAN). **A hook you need and don't find:** add a method with an empty default to `Hooks` and a function in
|
||||
`horizon_hooks::engine` that the engine calls where vanilla does the matching thing; give it plain data or a type
|
||||
the engine owns, never one from above. A hook that needs engine types the platform doesn't expose yet (a draw
|
||||
context for HUD and chat overlays, a settings page) comes with that type's platform API (M6). An extension point
|
||||
|
||||
@@ -55,9 +55,11 @@ reads codes and opens the tunnel, which the join (`net.rs`) then reaches on a lo
|
||||
horizon-friends' `game` side offers it, and the official executable (`horizon-official`) registers it;
|
||||
horizon-client's own executable has none, so there a `friend:` address is refused and the title screen's
|
||||
Friends icon, which opens Join a Friend, is inactive (`net::joins_friends`). Hosting needs no hook: the
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. It still lives in
|
||||
horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine.
|
||||
host end connects to the world's LAN port over loopback, so tunnelled guests are LAN guests. Hosting sees the
|
||||
game only through `horizon_hooks::share::ShareView` (open the world to LAN, its players, chat, the clipboard) and
|
||||
`Hooks::lan_closed`, and keeps what it shares in its own static. It still
|
||||
lives in horizon-client (`game/friends.rs`, `game/friends/screen.rs`, the `friends` feature, which comes with
|
||||
`horizon-extras`) until the pause menu and `horizon-ctl` offer entries to crates above the engine (#14167).
|
||||
|
||||
**Codes.** Crockford base32 (`0-9 A-Z` without `I L O U`; input ignores case, spaces and dashes and reads
|
||||
`O` as `0`, `I`/`L` as `1`) in groups of five, with a 16-bit checksum so a typo is caught before
|
||||
|
||||
+10
-4
@@ -184,8 +184,14 @@ pub fn device_code(ep: &Endpoints, client_id: &str) -> Result<DeviceCode, String
|
||||
.get("interval")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(5)
|
||||
.max(1),
|
||||
expires_at: now() + v.get("expires_in").and_then(Value::as_u64).unwrap_or(900),
|
||||
.clamp(1, 60),
|
||||
// The service's numbers, bounded: Instant + a huge interval panics the sign-in thread.
|
||||
expires_at: now().saturating_add(
|
||||
v.get("expires_in")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(900)
|
||||
.min(3600),
|
||||
),
|
||||
device_code,
|
||||
}),
|
||||
_ => Err(oauth_error(&v)),
|
||||
@@ -259,7 +265,7 @@ pub fn wait_for_sign_in(
|
||||
}
|
||||
match poll(ep, client_id, code)? {
|
||||
Poll::Pending => {}
|
||||
Poll::SlowDown => interval += 5,
|
||||
Poll::SlowDown => interval = (interval + 5).min(60),
|
||||
Poll::Done(tokens) => return Ok(tokens),
|
||||
}
|
||||
}
|
||||
@@ -345,7 +351,7 @@ fn minecraft_login(
|
||||
let (token, expires_in) = minecraft_token(ep, &format!("XBL3.0 x={uhs};{xsts_token}"))?;
|
||||
Ok(Minecraft {
|
||||
token,
|
||||
expires_at: now() + expires_in,
|
||||
expires_at: now().saturating_add(expires_in.min(7 * 86_400)),
|
||||
xuid,
|
||||
})
|
||||
}
|
||||
|
||||
+4
-2
@@ -7,7 +7,8 @@
|
||||
"702fe59163c6ee6578607daa85811d9bc9c7cc40",
|
||||
"61fff1316bd50312624a858bbb5eb18fa45208ed",
|
||||
"5df1795e16eea19d75b923d66ecca9ce183353c5",
|
||||
"80608ff6753705b6a82e2cb86a5937d4d233abd4"
|
||||
"80608ff6753705b6a82e2cb86a5937d4d233abd4",
|
||||
"9f67e10ed013deeaad20ac3c800652765d493408"
|
||||
],
|
||||
"accepted_manifest_note": "Other 26.3 version JSONs Mojang republished: only assetIndex differs (non-English translations), jars and libraries are identical. A launcher may still hold any of them.",
|
||||
"accepted_asset_index_sha1": [
|
||||
@@ -15,7 +16,8 @@
|
||||
"1e4e4a68dece1c0f51b8f93b45da8fe8da51bcd3",
|
||||
"0c8f43b28f837869c1e54a6aeb22c396a1548946",
|
||||
"917ff6d74b9f635c46bac0d28857206bbcc1ffe4",
|
||||
"331e5c80937c9cd79647ab34bdc1cbb5ef09f456"
|
||||
"331e5c80937c9cd79647ab34bdc1cbb5ef09f456",
|
||||
"e99f9973047785b5a9d52eaf72020b97545053c9"
|
||||
],
|
||||
"accepted_asset_index_note": "The asset indexes (id 34) of those republished version JSONs: a launcher keeps one 34.json, the last it downloaded, whichever version JSON it reads.",
|
||||
"client_sha1": "e877b6a07acd633fb3bb475002175cec036e7b87",
|
||||
|
||||
@@ -7866,6 +7866,43 @@ def explosion_player_experience():
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def explosion_creaking_heart_experience():
|
||||
"""#11330: `CreakingHeartBlock.onExplosionHit` awards a natural heart's experience (`tryAwardExperience`)
|
||||
when the blast's indirect source is a player (a primed TNT's `owner`); an ownerless blast awards none. The
|
||||
amount is random (20 to 24), so only whether orbs came out is compared. A creative owner gets none
|
||||
(`preventsBlockDrops`), so the player is in survival for the owned blasts, then back in creative. A TNT
|
||||
whose `owner` is a zombie's UUID is no player's (none), and with `block_drops` off `popExperience` drops
|
||||
nothing."""
|
||||
# Entities tick once the forced chunk's 2-column neighbourhood is loaded (ChunkMap.prepareEntityTickingChunk).
|
||||
x0, y0, z0 = 4096 - 416, 260, 3584
|
||||
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks',
|
||||
'chunks': [[(x0 >> 4) + i, (z0 >> 4) + k] for i in range(-2, 3) for k in range(-2, 3)]},
|
||||
cmd('/tick freeze', False)]
|
||||
near = f'x={x0},y={y0 - 4},z={z0},dx=16,dy=16,dz=16'
|
||||
for owner, mode, drops in ((None, 'survival', True), ('@p', 'creative', True), ('@p', 'survival', True),
|
||||
('zombie', 'survival', True), ('@p', 'survival', False)):
|
||||
ops += [cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air', False),
|
||||
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} bedrock', False),
|
||||
cmd(f'/setblock {x0 + 8} {y0} {z0 + 8} creaking_heart[natural=true]', expect_result=1),
|
||||
cmd(f'/summon tnt {x0 + 8}.5 {y0 + 1} {z0 + 8}.5 {{fuse:1,Tags:["heart_tnt"]}}', expect_result=1)]
|
||||
if owner == 'zombie':
|
||||
ops.append(cmd(f'/summon zombie {x0 + 1}.5 {y0} {z0 + 1}.5 '
|
||||
f'{{NoAI:1b,Silent:1b,PersistenceRequired:1b,Tags:["heart_owner"]}}',
|
||||
expect_result=1))
|
||||
owner = f'@e[type=zombie,tag=heart_owner,{near},limit=1]'
|
||||
if owner:
|
||||
ops.append(cmd(f'/data modify entity @e[type=tnt,tag=heart_tnt,limit=1] owner set from entity {owner} UUID'))
|
||||
ops.append(cmd(f'/gamemode {mode} @p', False))
|
||||
ops.append(cmd(f'/gamerule block_drops {str(drops).lower()}', False))
|
||||
ops += [cmd('/tick unfreeze', False), {'op': 'tick', 'n': 2}, cmd('/tick freeze', False),
|
||||
cmd(f'/execute if block {x0 + 8} {y0} {z0 + 8} air', expect_result=1),
|
||||
cmd(f'/execute if entity @e[type=experience_orb,{near}]', comparison='positive_result'),
|
||||
cmd(f'/kill @e[type=!player,{near}]', False), cmd(f'/kill @e[type=item,{near}]', False)]
|
||||
return ops + setup('/gamemode creative @p', '/gamerule block_drops true', '/tick unfreeze',
|
||||
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 + 6} {z0 + 15} air',
|
||||
f'/forceload remove {x0} {z0}')
|
||||
|
||||
|
||||
def groups():
|
||||
try:
|
||||
from .spectral_duration_corpus import cases as spectral_saved_duration
|
||||
@@ -8064,6 +8101,7 @@ def groups():
|
||||
('dispensed item escape', dispensed_item_escape()),
|
||||
('command block last output', command_block_last_output()),
|
||||
('explosion player experience', explosion_player_experience()),
|
||||
('explosion creaking heart experience', explosion_creaking_heart_experience()),
|
||||
('comparator setblock output', comparator_setblock_output()),
|
||||
('glide fall accumulation', glide_fall_accumulation()),
|
||||
('living short timers', living_short_timers()),
|
||||
|
||||
@@ -85,7 +85,7 @@ LevelExtractor.extractBlockDestroyAnimation port crates/horizon-client/src/game/
|
||||
LevelExtractor.extractGizmos port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos the frame's debug gizmos
|
||||
LevelExtractor.extractPlayerState inline crates/horizon-client/src/game.rs::Game::render player and hands read from the game state at draw time
|
||||
LevelExtractor.applyFrustum port crates/horizon-client-render/src/gfx/resident.rs::Resident::visible the frustum decides which sections are visible
|
||||
LevelExtractor.setLevel inline crates/horizon-client/src/game.rs::Game::all_sections_changed a new level rebuilds every loaded section (allChanged); the null-level branch is not separate
|
||||
LevelExtractor.setLevel inline crates/horizon-client/src/game/render_settings.rs::Game::all_sections_changed a new level rebuilds every loaded section (allChanged); the null-level branch is not separate
|
||||
LevelExtractor.blockChanged port crates/horizon-client-render/src/world.rs::World::set a block edit marks its 3x3x3 neighbourhood; gap: minor: the player-changed flag (update bit 8) is not kept
|
||||
LevelExtractor.setBlockDirty port crates/horizon-client-render/src/world.rs::World::set marks the 3x3x3 section neighbourhood of an edited block
|
||||
LevelExtractor.setBlocksDirty inline crates/horizon-client-render/src/world.rs::World::set each block of the range goes through the same 3x3x3 marking
|
||||
|
||||
|
@@ -82,7 +82,7 @@ Goat.getHurtSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds scream
|
||||
Goat.getDeathSound port crates/horizon-client/src/mobs/ai.rs::voice_sounds screaming death event selected by IsScreamingGoat
|
||||
Goat.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::goat_spawn called for a live spawn by spawning.rs::finalize; Ctx::finalize has its own arm with the same draws for a structure mob: the ram and long jump cooldowns, screaming when nextDouble is below 0.02, ageBoundaryReached, and for an adult a lost horn one time in ten (nextFloat below 0.1, the side by nextBoolean)
|
||||
Goat.getDefaultDimensions port crates/horizon-client/src/mobs/living.rs::Mob::dims baby box (0.45 x 0.65, eye 0.59375) from the species table; no long jump pose scale since there is no long jump
|
||||
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn
|
||||
Goat.addAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_to_nbt IsScreamingGoat, HasLeftHorn, HasRightHorn; the GoatAi LONG_JUMP_COOLDOWN_TICKS brain memory is saved too
|
||||
Goat.readAdditionalSaveData port crates/horizon-client/src/entity_nbt.rs::mob_from_nbt IsScreamingGoat and both horn flags (default true)
|
||||
Goat.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: minor: the two horn flags are synced; the screaming flag is not
|
||||
Goat.checkGoatSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules goats_spawnable_on below and bright enough
|
||||
|
||||
|
@@ -134,6 +134,7 @@ WardenSpawnTracker.hasNearbyWarden inline crates/horizon-client/src/game/sculk.r
|
||||
WardenSpawnTracker.getNearbyPlayers inline crates/horizon-client/src/game/sculk.rs::Game::try_to_warn gap: minor: only the player who set off the shrieker is counted, not other players within 16 blocks
|
||||
WardenSpawnTracker.copyData inline crates/horizon-client-render/src/sculk.rs::WardenSpawnTracker::try_warn every tracker takes the highest one's level, cooldown and tick count
|
||||
Drowned.checkDrownedSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules water below, spawner or deep enough, dark enough, frequency roll
|
||||
Drowned.isPushedByFluid port crates/horizon-client/src/mobs/living.rs::Mob::pushed_by_fluid `!isSwimming()`: a drowned swimming after its target is not carried by the current
|
||||
Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
|
||||
Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
|
||||
Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
|
||||
|
||||
|
@@ -108,7 +108,7 @@ FarmlandBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs:
|
||||
FarmlandBlock.tick port crates/horizon-world/src/level/tick.rs::Sim::tick_block Farmland arm turns the block to its base when it cannot survive
|
||||
FarmlandBlock.randomTick port crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick moisture falls or rises with water in reach and rain above, else turns to base
|
||||
FarmlandBlock.turnToBaseBlock port crates/horizon-world/src/level/tick.rs::Sim::turn_to_base_block the base block is set and Block.pushEntitiesUp goes out as the push_entities_up block event
|
||||
FarmlandBlock.isNearWater inline crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick gap: minor: the 9x2x9 water search is inline and tests the water fluid only, not canBeHydrated
|
||||
FarmlandBlock.isNearWater inline crates/horizon-world/src/level/tick.rs::Sim::farmland_random_tick the 9x2x9 inline search matches vanilla's predicate, `state.getFluidState().is(FluidTags.WATER)` (sources and flowing water alike), not a block tag
|
||||
FenceBlock.isSameFence inline crates/horizon-world/src/level/shapes.rs::Sim::fence_connects_to the same-fence test (fence tag and wooden match) is inline in fence_connects_to
|
||||
FenceBlock.getStateForPlacement port crates/horizon-world/src/level/place.rs::Sim::placement_class Fence arm: each horizontal side connects per neighbour and waterlogged from the spot
|
||||
FenceBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class Fence arm: waterlogged tick and the horizontal side connection update
|
||||
|
||||
|
@@ -32,7 +32,7 @@ ChestBlockEntity.recheckOpen port crates/horizon-world/src/level/tick.rs::Sim::t
|
||||
ChestLidController.tickLid port crates/horizon-client-render/src/block_entity_anim.rs::ChestLid::tick lid openness step of 0.1 per tick
|
||||
ChiseledBookShelfBlockEntity.removeItem port crates/horizon-client/src/game/machines.rs::Game::take_one removes one item from the slot
|
||||
ChiseledBookShelfBlockEntity.setItem port crates/horizon-client/src/game/chiseled_bookshelf.rs::Game::use_chiseled_bookshelf player insert path; slot update via chiseled_bookshelf_update_state
|
||||
ChiseledBookShelfBlockEntity.canTakeItem port crates/horizon-client/src/game/machines.rs::Containers::can_take gap: minor: the target-has-room check is left to take_one and put_back
|
||||
ChiseledBookShelfBlockEntity.canTakeItem port crates/horizon-client/src/game/machines.rs::Containers::can_take the target's room: a free slot or a matching stack with room (`hasAnyMatching`); the hopper asks `can_take_into`, which applies it before `can_take`
|
||||
ChiseledBookShelfBlockEntity.applyImplicitComponents port crates/horizon-client/src/block_entity_item.rs::place Items as container component
|
||||
ChiseledBookShelfBlockEntity.collectImplicitComponents port crates/horizon-client/src/block_entity_item.rs::collect Items as container component
|
||||
CommandBlockEntity.onModeSwitch port crates/horizon-client/src/game/command_blocks.rs::Game::apply_command_block mode switch; the AUTO schedule follows the setAutomatic step
|
||||
|
||||
|
Reference in new issue
Block a user