mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge mouse controls (main merged) into controller setup
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
9d53e1b519
31 files changed
+1443
-183
No files matched your search
@@ -84,6 +84,18 @@ pub struct BlockEntities {
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
/// The positions of the block entities that draw a bounding box (structure and test
|
||||
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
|
||||
/// Like `mods`, one taken out stays listed until `put` or `remove`.
|
||||
boxes: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
/// A structure or test instance block: the block entities whose box the frame may draw.
|
||||
fn shows_box(entity: &BlockEntity) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
@@ -225,6 +237,20 @@ impl BlockEntities {
|
||||
pub fn has_mods(&self) -> bool {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The structure and test instance blocks (a frame draws their boxes), in no order.
|
||||
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
|
||||
}
|
||||
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
|
||||
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
|
||||
if entity.is_some_and(shows_box) {
|
||||
self.boxes.insert(pos);
|
||||
} else {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
@@ -250,10 +276,16 @@ impl BlockEntities {
|
||||
/// block entity may get a slot; one replacing another keeps its slot.
|
||||
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
|
||||
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
|
||||
let new_box = shows_box(&entity);
|
||||
let old = self.map.insert(pos, entity);
|
||||
if old.is_none() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_box {
|
||||
self.boxes.insert(pos);
|
||||
} else if old.as_ref().is_some_and(shows_box) {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
@@ -275,6 +307,9 @@ impl BlockEntities {
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if absent && shows_box(entity) {
|
||||
self.boxes.insert(pos);
|
||||
}
|
||||
entity
|
||||
}
|
||||
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
|
||||
@@ -284,6 +319,7 @@ impl BlockEntities {
|
||||
if matches!(removed, Some(BlockEntity::Mod(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
self.boxes.remove(pos);
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
@@ -301,6 +337,7 @@ impl BlockEntities {
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
self.note_box(pos, Some(&entity));
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -312,13 +349,14 @@ impl BlockEntities {
|
||||
/// (`clearAllBlockEntities` of unloaded chunks).
|
||||
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
|
||||
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
|
||||
let mods = &mut self.mods;
|
||||
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
|
||||
self.map.retain(|p, e| {
|
||||
let kept = keep(p, e);
|
||||
if !kept {
|
||||
*freed |= live.remove(p).is_some();
|
||||
pending.remove(p);
|
||||
forget_mod(mods, *p);
|
||||
boxes.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
@@ -326,6 +364,7 @@ impl BlockEntities {
|
||||
pub fn clear(&mut self) {
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -343,6 +382,7 @@ impl BlockEntities {
|
||||
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
|
||||
for (p, e) in entities {
|
||||
let new_is_mod = matches!(e, BlockEntity::Mod(_));
|
||||
self.note_box(p, Some(&e));
|
||||
let old = self.map.insert(p, e);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
@@ -560,4 +600,43 @@ mod tests {
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_list_exactly_the_structure_and_test_blocks() {
|
||||
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
|
||||
let listed = |b: &BlockEntities| {
|
||||
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut b = level();
|
||||
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
|
||||
b.insert(p, test_block());
|
||||
b.insert(q, hopper());
|
||||
assert_eq!(listed(&b), [p]);
|
||||
// Replaced by another kind, and back.
|
||||
b.insert(p, hopper());
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(q, test_block());
|
||||
b.get_or_insert_with(r, test_block);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
// Taken out: not shown until put back; put back as another kind: gone.
|
||||
let taken = b.take(&q).unwrap();
|
||||
assert_eq!(listed(&b), [r]);
|
||||
b.put(q, taken);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
let taken = b.take(&r).unwrap();
|
||||
drop(taken);
|
||||
b.put(r, hopper());
|
||||
assert_eq!(listed(&b), [q]);
|
||||
b.remove(&q);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
|
||||
assert_eq!(listed(&b), [p]);
|
||||
b.retain(|pos, _| *pos != p);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(r, test_block());
|
||||
b.clear();
|
||||
assert!(b.boxes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,7 @@ mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod stars;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
@@ -753,6 +754,9 @@ pub struct Renderer {
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
/// The shader modules, layouts and pipelines kept for the next renderer on
|
||||
/// this device ([`reuse`]).
|
||||
reuse: Arc<reuse::Reuse>,
|
||||
/// Frames are finished and submitted on its submit thread ([`submit`]).
|
||||
pub queue: submit::Queue,
|
||||
/// The last frame went to the submit thread; whether it had no window surface.
|
||||
@@ -924,6 +928,8 @@ pub struct Renderer {
|
||||
/// last asked (`take_dropped_sections`).
|
||||
sections_dropped: bool,
|
||||
sorted_indices: arena::Arena,
|
||||
/// Buffers of released arena pages, freed a few frames later on a thread of their own.
|
||||
graveyard: arena::Graveyard<wgpu::Buffer>,
|
||||
translucent_sorter: translucency::Sorter,
|
||||
translucency_cursor: usize,
|
||||
/// `DrawIndexedIndirectArgs` for this frame's solid then translucent draws.
|
||||
@@ -1940,10 +1946,15 @@ impl Renderer {
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
// A renderer on the same device takes the previous one's shader
|
||||
// modules, layouts and pipelines where they come out the same.
|
||||
let reuse = previous.map_or_else(Default::default, |r| r.reuse.clone());
|
||||
let generation = reuse.begin();
|
||||
let kept = reuse.on(&device);
|
||||
let pipelines_step = crate::spans::startup::step("renderer.pipelines_textures");
|
||||
let terrain_step = crate::spans::startup::step("renderer.terrain_pipelines");
|
||||
let gpu_timer = timing::GpuTimer::new(&device, &queue);
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon terrain"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
|
||||
});
|
||||
@@ -1967,7 +1978,7 @@ impl Renderer {
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
];
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -2025,7 +2036,7 @@ impl Renderer {
|
||||
),
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon terrain layout"),
|
||||
bind_group_layouts: &[Some(&bindings)],
|
||||
..Default::default()
|
||||
@@ -2061,7 +2072,7 @@ impl Renderer {
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2209,7 +2220,7 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let depth_reset = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand depth reset"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2601,11 +2612,11 @@ impl Renderer {
|
||||
)?;
|
||||
hud.atlas_texture = Some(atlas.clone());
|
||||
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
|
||||
let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let entity_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon entities"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
|
||||
});
|
||||
let entity_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let entity_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Entity bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -2756,12 +2767,12 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let entity_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Entity layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let cloud_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let cloud_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Cloud bindings"),
|
||||
entries: &[entry(
|
||||
0,
|
||||
@@ -2787,7 +2798,7 @@ impl Renderer {
|
||||
resource: cloud_uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let cloud_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Cloud layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings), Some(&cloud_bindings)],
|
||||
..Default::default()
|
||||
@@ -2814,7 +2825,7 @@ impl Renderer {
|
||||
clouds,
|
||||
fragment: &str,
|
||||
write_mask| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(if clouds {
|
||||
&cloud_layout
|
||||
@@ -3076,10 +3087,10 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
};
|
||||
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
|
||||
let additive_pipeline = kept.create_render_pipeline(&additive_descriptor);
|
||||
// `RenderPipelines.DRAGON_RAYS` uses DepthStencilState.DEFAULT:
|
||||
// unlike eyes, a ray hides the faces and rays behind it.
|
||||
let dragon_rays_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let dragon_rays_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Dragon death rays"),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
@@ -3099,7 +3110,7 @@ impl Renderer {
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})];
|
||||
let hdr_mark_lightning_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HDR lightning mark"),
|
||||
vertex: wgpu::VertexState {
|
||||
entry_point: Some("vs_lightning"),
|
||||
@@ -3114,7 +3125,7 @@ impl Renderer {
|
||||
}),
|
||||
..additive_descriptor.clone()
|
||||
});
|
||||
let lightning_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let lightning_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Lightning"),
|
||||
vertex: wgpu::VertexState {
|
||||
entry_point: Some("vs_lightning"),
|
||||
@@ -3123,7 +3134,7 @@ impl Renderer {
|
||||
},
|
||||
..additive_descriptor
|
||||
});
|
||||
let celestial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let celestial_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Celestial bodies"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3218,7 +3229,7 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let border_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let border_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World border bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -3277,19 +3288,19 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let border_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let border_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("World border layout"),
|
||||
bind_group_layouts: &[Some(&border_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let border_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon world border"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()),
|
||||
});
|
||||
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
|
||||
// RenderPipelines.WORLD_BORDER: BlendFunction.OVERLAY, no culling,
|
||||
// depth writes with a polygon offset of 3 towards the camera.
|
||||
let border_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let border_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("World border"),
|
||||
layout: Some(&border_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3342,7 +3353,7 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let screen_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let screen_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon screen effects"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("screen.wgsl").into()),
|
||||
});
|
||||
@@ -3381,7 +3392,7 @@ impl Renderer {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3551,6 +3562,13 @@ impl Renderer {
|
||||
let panorama =
|
||||
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
|
||||
drop(pipelines_step);
|
||||
reuse.finish(generation);
|
||||
if previous.is_some() {
|
||||
log::info!(
|
||||
"Horizon renderer: rebuilt on the same device ({} shader modules, layouts and pipelines kept)",
|
||||
reuse.len()
|
||||
);
|
||||
}
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!(
|
||||
"pipelines ready (multi-draw {}, occlusion culling {}, GPU timer {})",
|
||||
@@ -3581,6 +3599,7 @@ impl Renderer {
|
||||
last_encode: std::time::Duration::ZERO,
|
||||
window,
|
||||
device,
|
||||
reuse,
|
||||
queue: submit::Queue::new(queue, submit::wanted()),
|
||||
deferred: None,
|
||||
device_lost,
|
||||
@@ -3695,6 +3714,7 @@ impl Renderer {
|
||||
translucent_batching,
|
||||
sections_dropped: false,
|
||||
sorted_indices,
|
||||
graveyard: Default::default(),
|
||||
translucent_sorter: translucency::Sorter::default(),
|
||||
translucency_cursor: 0,
|
||||
indirect,
|
||||
@@ -4443,14 +4463,15 @@ impl Renderer {
|
||||
self.lod_pending = None;
|
||||
self.detail_columns.clear();
|
||||
self.detail_revision += 1;
|
||||
self.arena.clear();
|
||||
// A dimension change drops every page at once: freed later, off this thread.
|
||||
self.graveyard.add(self.arena.clear());
|
||||
if let Some(arena) = &mut self.translucent_arena {
|
||||
arena.clear();
|
||||
self.graveyard.add(arena.clear());
|
||||
}
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.clear();
|
||||
}
|
||||
self.sorted_indices.clear();
|
||||
self.graveyard.add(self.sorted_indices.clear());
|
||||
self.translucency_cursor = 0;
|
||||
if layout == self.layout {
|
||||
self.occlusion.clear();
|
||||
@@ -5341,12 +5362,14 @@ impl Renderer {
|
||||
if !frame.title {
|
||||
self.follow_translucent_layout();
|
||||
}
|
||||
self.arena.trim();
|
||||
let released = self.arena.trim();
|
||||
self.graveyard.add(released);
|
||||
let left = self.compact_arena();
|
||||
if let Some(arena) = &mut self.translucent_arena {
|
||||
arena.trim();
|
||||
self.graveyard.add(arena.trim());
|
||||
}
|
||||
self.sorted_indices.trim();
|
||||
self.graveyard.add(self.sorted_indices.trim());
|
||||
arena::free_later(self.graveyard.advance());
|
||||
self.shrink_translucent(left);
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.trim(|page| self.arena.has_page(page));
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -545,14 +545,17 @@ impl Arena {
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
}
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range.
|
||||
pub fn trim(&mut self) {
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
|
||||
/// the buffers of the pages it emptied, for a [`Graveyard`].
|
||||
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
|
||||
let mut released = Vec::new();
|
||||
for page in &mut self.pages {
|
||||
if let Some(p) = page
|
||||
&& p.free.0.len() == 1
|
||||
&& p.free.0.get(&0) == Some(&p.capacity)
|
||||
if page
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
|
||||
&& let Some(p) = page.take()
|
||||
{
|
||||
*page = None;
|
||||
released.push(p.buffer);
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
@@ -562,13 +565,67 @@ impl Arena {
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
}
|
||||
released
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
/// Drops every page; gives back their buffers, for a [`Graveyard`].
|
||||
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
|
||||
self.closing = None;
|
||||
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
|
||||
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
|
||||
/// thread holds its last reference.
|
||||
const GRAVE_FRAMES: usize = 4;
|
||||
|
||||
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
|
||||
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
|
||||
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
|
||||
pub struct Graveyard<T> {
|
||||
graves: VecDeque<Vec<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Graveyard<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
graves: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Graveyard<T> {
|
||||
/// Released this frame.
|
||||
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
|
||||
if self.graves.is_empty() {
|
||||
self.graves.push_back(Vec::new());
|
||||
}
|
||||
if let Some(grave) = self.graves.back_mut() {
|
||||
grave.extend(items);
|
||||
}
|
||||
}
|
||||
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
|
||||
pub fn advance(&mut self) -> Vec<T> {
|
||||
self.graves.push_back(Vec::new());
|
||||
if self.graves.len() > GRAVE_FRAMES {
|
||||
self.graves.pop_front().unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
|
||||
/// unloaded chunks; should the thread not start, they drop here.
|
||||
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Buffer freeing".into())
|
||||
.spawn(move || drop(items));
|
||||
}
|
||||
|
||||
/// `create_buffer` with an out-of-memory failure returned instead of raised
|
||||
/// (raised errors are fatal).
|
||||
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
|
||||
@@ -587,6 +644,24 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
|
||||
#[test]
|
||||
fn graveyard_frees_after_its_frames() {
|
||||
let mut graves = Graveyard::default();
|
||||
graves.add([1, 2]);
|
||||
let mut freed = Vec::new();
|
||||
for frame in 0..GRAVE_FRAMES + 2 {
|
||||
if frame == 1 {
|
||||
graves.add([3]);
|
||||
}
|
||||
freed.push(graves.advance());
|
||||
}
|
||||
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
|
||||
expected[GRAVE_FRAMES - 1] = vec![1, 2];
|
||||
expected[GRAVE_FRAMES] = vec![3];
|
||||
assert_eq!(freed, expected);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
device_with(wgpu::Features::empty())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Pipelines kept between the renderers built on one device. The start builds
|
||||
//! three (the boot screen's, the title artwork's, the world's) and a resource
|
||||
//! reload or a graphics option builds another; each compiled every shader
|
||||
//! module and pipeline again, though most come out identical (the boot and
|
||||
//! title renderers' entirely, the world's entity pipelines). On a driver that
|
||||
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
|
||||
//! of the main thread before the title showed.
|
||||
//!
|
||||
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
|
||||
//! labels, sources, constants, formats and states, and the handles a descriptor
|
||||
//! names (shader modules, layouts) by their ids, so a descriptor that names a
|
||||
//! kept handle matches only the same handle. Kept objects stay alive, so their
|
||||
//! ids are never reused while they are keys.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, PoisonError},
|
||||
};
|
||||
|
||||
/// One map of kept objects with the generation each was last asked for in.
|
||||
struct Kept<T> {
|
||||
map: Mutex<HashMap<String, (T, u32)>>,
|
||||
}
|
||||
impl<T> Default for Kept<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Clone> Kept<T> {
|
||||
/// The object kept under `key`, else `make`'s, kept. The lock is not
|
||||
/// held while `make` compiles.
|
||||
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
|
||||
if let Some((object, used)) = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_mut(&key)
|
||||
{
|
||||
*used = generation;
|
||||
return object.clone();
|
||||
}
|
||||
let object = make();
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(key, (object.clone(), generation));
|
||||
object
|
||||
}
|
||||
fn keep_only(&self, generation: u32) {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.retain(|_, (_, used)| *used == generation);
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader modules, layouts and pipelines of the device's renderers.
|
||||
#[derive(Default)]
|
||||
pub struct Reuse {
|
||||
generation: std::sync::atomic::AtomicU32,
|
||||
shaders: Kept<wgpu::ShaderModule>,
|
||||
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
|
||||
pipeline_layouts: Kept<wgpu::PipelineLayout>,
|
||||
render: Kept<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Reuse {
|
||||
/// A renderer starts building: what it asks for is kept, the rest is let
|
||||
/// go by [`Reuse::finish`].
|
||||
pub fn begin(&self) -> u32 {
|
||||
self.generation
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.wrapping_add(1)
|
||||
}
|
||||
/// The renderer of `generation` is built: objects only older renderers
|
||||
/// used are dropped here (the renderers still alive hold their own).
|
||||
pub fn finish(&self, generation: u32) {
|
||||
self.shaders.keep_only(generation);
|
||||
self.bind_group_layouts.keep_only(generation);
|
||||
self.pipeline_layouts.keep_only(generation);
|
||||
self.render.keep_only(generation);
|
||||
}
|
||||
/// How many objects are kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.shaders.len()
|
||||
+ self.bind_group_layouts.len()
|
||||
+ self.pipeline_layouts.len()
|
||||
+ self.render.len()
|
||||
}
|
||||
fn current(&self) -> u32 {
|
||||
self.generation.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `device`'s `create_*` calls through this cache.
|
||||
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
|
||||
On {
|
||||
reuse: self,
|
||||
device,
|
||||
generation: self.current(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A device whose shader modules, layouts and render pipelines come from a
|
||||
/// [`Reuse`] when one with the same descriptor is kept.
|
||||
pub struct On<'a> {
|
||||
reuse: &'a Reuse,
|
||||
device: &'a wgpu::Device,
|
||||
generation: u32,
|
||||
}
|
||||
impl On<'_> {
|
||||
pub fn create_shader_module(
|
||||
&self,
|
||||
descriptor: wgpu::ShaderModuleDescriptor<'_>,
|
||||
) -> wgpu::ShaderModule {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.shaders.get(key, self.generation, || {
|
||||
self.device.create_shader_module(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_bind_group_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
|
||||
) -> wgpu::BindGroupLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.bind_group_layouts.get(key, self.generation, || {
|
||||
self.device.create_bind_group_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_pipeline_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
|
||||
) -> wgpu::PipelineLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.pipeline_layouts.get(key, self.generation, || {
|
||||
self.device.create_pipeline_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_render_pipeline(
|
||||
&self,
|
||||
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.render.get(key, self.generation, || {
|
||||
self.device.create_render_pipeline(descriptor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Kept;
|
||||
|
||||
/// A key asked for again gets the kept object without making one; what
|
||||
/// the latest generation did not ask for is let go when it finishes.
|
||||
#[test]
|
||||
fn kept_objects_are_shared_until_a_build_stops_asking() {
|
||||
let kept = Kept::<u32>::default();
|
||||
let made = std::cell::Cell::new(0);
|
||||
let make = |value| {
|
||||
made.set(made.get() + 1);
|
||||
value
|
||||
};
|
||||
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
|
||||
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
|
||||
kept.keep_only(1);
|
||||
assert_eq!(kept.len(), 2);
|
||||
// The next build asks for "a" only.
|
||||
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
|
||||
kept.keep_only(2);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
|
||||
assert_eq!(made.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -283,7 +283,7 @@ impl SectionMesh {
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
@@ -889,7 +889,7 @@ impl Padded {
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
@@ -1144,7 +1144,9 @@ impl Mesher<'_> {
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
|
||||
@@ -20,9 +20,6 @@ const MIN_SECONDS: f32 = 60.0;
|
||||
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
|
||||
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// When the menu bundle became ready, so the time to the title screen measures
|
||||
/// the menu and not the world bundle loaded behind it.
|
||||
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
|
||||
@@ -46,14 +43,6 @@ pub fn mark_start() {
|
||||
STARTED.get_or_init(Instant::now);
|
||||
}
|
||||
|
||||
/// The menu bundle is ready: the title screen can draw. The first writer wins.
|
||||
pub fn mark_ui_ready() {
|
||||
if let Some(started) = STARTED.get() {
|
||||
let ms = started.elapsed().as_millis() as u32;
|
||||
let _ = UI_READY_MS.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_core_ready() {
|
||||
mark_ready(&CORE_READY_MS);
|
||||
}
|
||||
@@ -242,12 +231,9 @@ impl Session {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
crate::spans::startup::mark("title.shown");
|
||||
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
|
||||
self.to_title_ms = if ui_ms != u32::MAX {
|
||||
Some(ui_ms)
|
||||
} else {
|
||||
STARTED.get().map(|s| s.elapsed().as_millis() as u32)
|
||||
};
|
||||
// The frame itself: the menu bundle can be ready seconds before
|
||||
// the renderer that draws it (pipelines compiling on a slow driver).
|
||||
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//! [--threads N] [--rounds 3] [--mesh-threads N] [--json out.json]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
//! this thread's CPU time in the workers' path (`Mesher::mesh_reusing` with a
|
||||
//! recycled mesh and scratch, then `sort_solid`), each section's best of
|
||||
//! `--rounds`, so other work on the machine changes it less; then the wall time
|
||||
//! of `--mesh-threads` threads meshing them all at once. The checksum of the
|
||||
//! meshes (vertices and visibility) shows that an A/B pair of builds made the
|
||||
//! same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
@@ -20,7 +23,7 @@ use horizon_client::{
|
||||
chunk::HEIGHT,
|
||||
graphics::LeafCullingMode,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, TerrainVertex, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
@@ -32,7 +35,10 @@ use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -131,43 +137,176 @@ fn main() -> Result<(), Error> {
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
let one = mesh_rounds(&context, seed, ®ions, rounds)?;
|
||||
let mut sorted = one.per_section.clone();
|
||||
sorted.sort_unstable();
|
||||
let pct = |p: f64| {
|
||||
let i = ((sorted.len() as f64 - 1.0) * p).round() as usize;
|
||||
sorted.get(i).map_or(0.0, |d| d.as_secs_f64() * 1e6)
|
||||
};
|
||||
let total: Duration = one.per_section.iter().sum();
|
||||
let filled: Vec<Duration> = one
|
||||
.per_section
|
||||
.iter()
|
||||
.zip(&one.vertices_per_section)
|
||||
.filter(|&(_, &v)| v > 0)
|
||||
.map(|(d, _)| *d)
|
||||
.collect();
|
||||
let filled_total: Duration = filled.iter().sum();
|
||||
let bytes = one.vertices * std::mem::size_of::<TerrainVertex>();
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
"meshed {} sections ({} with geometry, {} vertices, {:.1} MB of vertices, checksum {:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
filled.len(),
|
||||
one.vertices,
|
||||
bytes as f64 / 1e6,
|
||||
one.checksum,
|
||||
total.as_secs_f64() * 1e3,
|
||||
total.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
println!(
|
||||
"per section (best of {rounds} rounds, us): p50 {:.1} p90 {:.1} p99 {:.1} max {:.1}; with geometry: mean {:.1}; {:.0} sections/s per core",
|
||||
pct(0.5),
|
||||
pct(0.9),
|
||||
pct(0.99),
|
||||
pct(1.0),
|
||||
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
|
||||
regions.len() as f64 / total.as_secs_f64().max(1e-9),
|
||||
);
|
||||
// Every worker meshing at once: the throughput a world being explored gets,
|
||||
// memory bandwidth and the allocator included.
|
||||
let mesh_threads: usize = flag(&args, "--mesh-threads", threads);
|
||||
let parallel = mesh_parallel(&context, seed, ®ions, mesh_threads)?;
|
||||
println!(
|
||||
"{mesh_threads} threads meshed {} sections in {:.1} ms of wall time: {:.0} sections/s",
|
||||
regions.len(),
|
||||
parallel.as_secs_f64() * 1e3,
|
||||
regions.len() as f64 / parallel.as_secs_f64().max(1e-9)
|
||||
);
|
||||
if let Some(path) = args
|
||||
.iter()
|
||||
.position(|a| a == "--json")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
{
|
||||
let json = format!(
|
||||
concat!(
|
||||
"{{\"sections\":{},\"filled\":{},\"vertices\":{},\"vertex_bytes\":{},",
|
||||
"\"checksum\":\"{:016x}\",\"cpu_ms\":{:.3},\"p50_us\":{:.2},\"p90_us\":{:.2},",
|
||||
"\"p99_us\":{:.2},\"max_us\":{:.2},\"filled_mean_us\":{:.2},",
|
||||
"\"mesh_threads\":{},\"parallel_per_s\":{:.1}}}\n"
|
||||
),
|
||||
regions.len(),
|
||||
filled.len(),
|
||||
one.vertices,
|
||||
bytes,
|
||||
one.checksum,
|
||||
total.as_secs_f64() * 1e3,
|
||||
pct(0.5),
|
||||
pct(0.9),
|
||||
pct(0.99),
|
||||
pct(1.0),
|
||||
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
|
||||
mesh_threads,
|
||||
regions.len() as f64 / parallel.as_secs_f64().max(1e-9),
|
||||
);
|
||||
std::fs::write(path, json)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Rounds {
|
||||
/// Each section's CPU time, the least of the rounds.
|
||||
per_section: Vec<Duration>,
|
||||
vertices_per_section: Vec<usize>,
|
||||
vertices: usize,
|
||||
checksum: u64,
|
||||
}
|
||||
|
||||
/// Meshes every section `rounds` times on this thread the way a worker does:
|
||||
/// one mesher (its face cache kept), one scratch, the mesh buffers recycled,
|
||||
/// and the solid quads sorted by side.
|
||||
fn mesh_rounds(
|
||||
context: &WorkerContext,
|
||||
seed: i64,
|
||||
regions: &[(Region, i32)],
|
||||
rounds: usize,
|
||||
) -> Result<Rounds, Error> {
|
||||
let mut mesher = mesher(context, seed);
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
let mut per_section = vec![Duration::MAX; regions.len()];
|
||||
let mut vertices_per_section = vec![0; regions.len()];
|
||||
let mut checksum = 0;
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut hash = DefaultHasher::new();
|
||||
for (i, (region, sy)) in regions.iter().enumerate() {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mut mesh = mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
|
||||
mesh.sort_solid();
|
||||
let spent = thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
per_section[i] = per_section[i].min(spent);
|
||||
vertices_per_section[i] = mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
mesh.visibility.hash(&mut hash);
|
||||
recycled = mesh;
|
||||
}
|
||||
checksum = hash.finish();
|
||||
}
|
||||
let vertices = vertices_per_section.iter().sum();
|
||||
Ok(Rounds {
|
||||
per_section,
|
||||
vertices_per_section,
|
||||
vertices,
|
||||
checksum,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wall time for `threads` workers to mesh every section once between them.
|
||||
fn mesh_parallel(
|
||||
context: &WorkerContext,
|
||||
seed: i64,
|
||||
regions: &[(Region, i32)],
|
||||
threads: usize,
|
||||
) -> Result<Duration, Error> {
|
||||
let next = AtomicUsize::new(0);
|
||||
let started = Instant::now();
|
||||
thread::scope(|scope| {
|
||||
for _ in 0..threads.max(1) {
|
||||
scope.spawn(|| {
|
||||
let mut mesher = mesher(context, seed);
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some((region, sy)) = regions.get(i) else {
|
||||
break;
|
||||
};
|
||||
let mut mesh =
|
||||
mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
|
||||
mesh.sort_solid();
|
||||
recycled = mesh;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(started.elapsed())
|
||||
}
|
||||
@@ -1920,9 +1920,22 @@ impl UiResources {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::build_over(root, defaults)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root, defaults)),
|
||||
// The title screen waits for both: the panorama's six faces decode
|
||||
// beside the atlas.
|
||||
std::thread::scope(|scope| {
|
||||
let panorama = scope.spawn(|| {
|
||||
startup::time("ui.panorama", || {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
Ok(Self {
|
||||
atlas: atlas?,
|
||||
panorama: Arc::new(panorama),
|
||||
})
|
||||
})
|
||||
}
|
||||
/// What a server's game holds, since it draws nothing: the font, which books
|
||||
@@ -2610,6 +2623,8 @@ struct Game<'a> {
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
/// CushionModel's baked layer.
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
|
||||
entity_models: EntityModels,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
@@ -18324,6 +18339,7 @@ impl<'a> Game<'a> {
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
cushion_model: prepared.cushion_model,
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
@@ -18530,8 +18546,41 @@ struct GameCpuResources {
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
bee_stinger_model: Option<horizon_client::mobs::Part>,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
entity_models: EntityModels,
|
||||
creative_screen: Creative,
|
||||
}
|
||||
/// Baked layers of entities that may first show up deep into a session. They come from the
|
||||
/// document parsed once at startup: parsing entity-models.json (2.4 MB) when the first one
|
||||
/// appeared stalled that frame by 40 ms of CPU and the file read.
|
||||
#[derive(Default)]
|
||||
struct EntityModels {
|
||||
/// LeashKnotModel.
|
||||
leash_knot: Option<horizon_client::mobs::Part>,
|
||||
/// WindChargeModel.
|
||||
wind_charge: Option<horizon_client::mobs::Part>,
|
||||
/// ShulkerBulletModel.
|
||||
shulker_bullet: Option<horizon_client::mobs::Part>,
|
||||
/// SkullModel of the wither skull.
|
||||
wither_skull: Option<horizon_client::mobs::Part>,
|
||||
/// EvokerFangsModel.
|
||||
evoker_fangs: Option<Arc<horizon_client::mobs::Part>>,
|
||||
/// BannerModel's pole and flag (a raid captain's banner).
|
||||
banner: Option<(horizon_client::mobs::Part, horizon_client::mobs::Part)>,
|
||||
}
|
||||
impl EntityModels {
|
||||
fn load(document: Option<&horizon_client::mobs::EntityModelDocument>) -> Self {
|
||||
let model = |key| document.and_then(|document| document.model(key));
|
||||
Self {
|
||||
leash_knot: model("minecraft:leash_knot#main"),
|
||||
wind_charge: model("minecraft:wind_charge#main"),
|
||||
shulker_bullet: model("minecraft:shulker_bullet#main"),
|
||||
wither_skull: model("minecraft:wither_skull#main"),
|
||||
evoker_fangs: model("minecraft:evoker_fangs#main").map(Arc::new),
|
||||
banner: model("minecraft:standing_banner#main")
|
||||
.zip(model("minecraft:standing_banner#flag")),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl GameCpuResources {
|
||||
fn load(root: &std::path::Path) -> Result<Self> {
|
||||
let document = horizon_client::mobs::EntityModelDocument::load(root).ok();
|
||||
@@ -18552,6 +18601,7 @@ impl GameCpuResources {
|
||||
arrow_model: model("minecraft:arrow#main"),
|
||||
bee_stinger_model: model("minecraft:bee_stinger#main"),
|
||||
trident_model: model("minecraft:trident#main"),
|
||||
entity_models: EntityModels::load(document.as_ref()),
|
||||
creative_screen: Creative::load(root)?,
|
||||
})
|
||||
}
|
||||
@@ -19242,7 +19292,6 @@ fn load_resources(
|
||||
let parallel = threads > 1;
|
||||
let mut offer = |ui: UiResources, root: &std::path::Path| {
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
"Horizon: UI bundle ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
@@ -19348,23 +19397,6 @@ fn load_resources(
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// Finish fallible CPU preparation before starting the tail, so an error
|
||||
// cannot detach a tail worker that races the next pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let game_cpu = menu
|
||||
.then(|| startup::time("handoff.game_cpu", || GameCpuResources::load(&root)))
|
||||
.transpose()?;
|
||||
let renderer_cpu = menu.then(|| {
|
||||
startup::time("handoff.renderer_textures", || {
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
if menu {
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// The block entity renderer's face visibility: the meshes' own, whose
|
||||
// shapes already hold their faces, not a second load of the same tables.
|
||||
let visibility = pack.context.tables.visibility.clone();
|
||||
@@ -19423,6 +19455,37 @@ fn load_resources(
|
||||
.map_err(|_| "world resources already loaded")?;
|
||||
None
|
||||
};
|
||||
// The world tables load (above) while the handoff's CPU work is done: the
|
||||
// world renderer's textures beside the game's own tables. A failure here
|
||||
// waits for the tail, so no tail worker outlives its pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let (game_cpu, renderer_cpu) = if menu {
|
||||
let (game_cpu, renderer_cpu) = std::thread::scope(|scope| {
|
||||
let textures = Step::spawn(scope, parallel, || {
|
||||
startup::time("handoff.renderer_textures", || {
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
let game_cpu = startup::time("handoff.game_cpu", || GameCpuResources::load(&root));
|
||||
(game_cpu, textures.join())
|
||||
});
|
||||
let game_cpu = match game_cpu {
|
||||
Ok(game_cpu) => game_cpu,
|
||||
Err(error) => {
|
||||
if let Some(tail) = rest_loader {
|
||||
let _ = tail.handle.join();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
(Some(game_cpu), Some(renderer_cpu))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Loaded {
|
||||
game_cpu,
|
||||
renderer_cpu,
|
||||
@@ -20041,10 +20104,26 @@ struct Loading {
|
||||
delivered: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
shown: bool,
|
||||
/// When the menu bundle arrived (`set_ui`), and whether a title frame with
|
||||
/// it was presented since: the world renderer waits for that frame
|
||||
/// (`ready_for_handoff`).
|
||||
ui_at: Option<Instant>,
|
||||
title_drawn: bool,
|
||||
menu: startup_menu::StartupMenu,
|
||||
error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
/// How long the handoff waits for the first title frame before building the
|
||||
/// world renderer anyway (a hidden window may never be asked to redraw).
|
||||
const TITLE_FRAME_WAIT: Duration = Duration::from_secs(1);
|
||||
impl Loading {
|
||||
/// The world renderer is built on the main thread, so building it before
|
||||
/// the title is on the screen holds the title back by that build. Wait for
|
||||
/// one title frame first, unless a world is waiting to open.
|
||||
fn ready_for_handoff(&self) -> bool {
|
||||
self.title_drawn
|
||||
|| self.menu.pending.is_some()
|
||||
|| self.ui_at.is_none_or(|at| at.elapsed() >= TITLE_FRAME_WAIT)
|
||||
}
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
@@ -20155,6 +20234,7 @@ impl Loading {
|
||||
);
|
||||
self.menu.set_root(root, &self.settings);
|
||||
self.gui = Some(gui);
|
||||
self.title_drawn = false;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.apply_menu_settings();
|
||||
let Some(window) = self.window.clone() else {
|
||||
@@ -20180,6 +20260,8 @@ impl Loading {
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
// The title frame's wait starts once its renderer is built.
|
||||
self.ui_at = Some(Instant::now());
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
@@ -20226,6 +20308,7 @@ impl Loading {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
match renderer.render(&frame) {
|
||||
Ok(true) if self.gui.is_some() && self.menu.pending.is_none() => {
|
||||
self.title_drawn = true;
|
||||
self.menu.perf.title_shown()
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -20363,7 +20446,7 @@ impl ApplicationHandler for App {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
match if loading.menu.reload_packs {
|
||||
match if loading.menu.reload_packs || !loading.ready_for_handoff() {
|
||||
None
|
||||
} else {
|
||||
loading.loaded.take()
|
||||
@@ -20447,6 +20530,8 @@ fn run_loading(
|
||||
delivered: false,
|
||||
handle: Some(loader),
|
||||
shown: false,
|
||||
ui_at: None,
|
||||
title_drawn: false,
|
||||
menu,
|
||||
error: None,
|
||||
};
|
||||
@@ -20750,7 +20835,11 @@ mod startup_cpu_tests {
|
||||
r#"{
|
||||
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
|
||||
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {},
|
||||
"minecraft:leash_knot#main": {}, "minecraft:wind_charge#main": {},
|
||||
"minecraft:shulker_bullet#main": {}, "minecraft:wither_skull#main": {},
|
||||
"minecraft:evoker_fangs#main": {}, "minecraft:standing_banner#main": {},
|
||||
"minecraft:standing_banner#flag": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -20771,6 +20860,10 @@ mod startup_cpu_tests {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(prepared.crystal_model.is_some());
|
||||
assert!(prepared.cushion_model.is_some());
|
||||
let models = &prepared.entity_models;
|
||||
assert!(models.leash_knot.is_some() && models.wind_charge.is_some());
|
||||
assert!(models.shulker_bullet.is_some() && models.wither_skull.is_some());
|
||||
assert!(models.evoker_fangs.is_some() && models.banner.is_some());
|
||||
assert!(prepared.dragon_model.is_some());
|
||||
assert!(prepared.arrow_model.is_some());
|
||||
assert!(prepared.trident_model.is_some());
|
||||
|
||||
@@ -67,6 +67,8 @@ pub(super) enum Target {
|
||||
Tnt(u32),
|
||||
/// A falling block, by its entity id (`minecraft:falling_block`).
|
||||
Falling(u32),
|
||||
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
|
||||
Rocket(u32),
|
||||
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
|
||||
Spawner(u32),
|
||||
}
|
||||
@@ -144,6 +146,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Spawner(id)) = t
|
||||
else {
|
||||
return None;
|
||||
@@ -535,6 +538,11 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
Target::Rocket(id) => self
|
||||
.rockets
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| [r.position.x, r.position.y, r.position.z]),
|
||||
Target::Spawner(id) => self
|
||||
.ominous_spawners
|
||||
.iter()
|
||||
@@ -588,6 +596,7 @@ impl Game<'_> {
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
Target::Rocket(_) => "minecraft:firework_rocket".into(),
|
||||
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
|
||||
}
|
||||
}
|
||||
@@ -880,6 +889,7 @@ impl Game<'_> {
|
||||
}
|
||||
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
|
||||
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
|
||||
candidates.extend(self.rockets.iter().map(|r| Target::Rocket(r.id)));
|
||||
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
|
||||
candidates
|
||||
}
|
||||
@@ -914,6 +924,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
Target::Falling(id),
|
||||
Target::Rocket(id),
|
||||
Target::Spawner(id),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -938,6 +949,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
|
||||
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
|
||||
}
|
||||
}
|
||||
@@ -1576,6 +1588,12 @@ impl Game<'_> {
|
||||
f.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Rocket(id) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.position = v;
|
||||
r.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
if let Some(s) = self.ominous_spawners.iter_mut().find(|s| s.id == id) {
|
||||
s.position = [v.x, v.y, v.z];
|
||||
@@ -2080,6 +2098,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
|
||||
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
@@ -3756,6 +3775,7 @@ mod look_at_tests {
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
Target::Falling(7),
|
||||
Target::Rocket(7),
|
||||
Target::Spawner(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
|
||||
@@ -212,6 +212,11 @@ impl Game<'_> {
|
||||
);
|
||||
crate::game::persist::with_raw(tag, raw)
|
||||
}
|
||||
Target::Rocket(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let r = self.rockets.iter().find(|r| r.id == id)?;
|
||||
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
let s = self.ominous_spawners.iter().find(|s| s.id == id)?;
|
||||
let [x, y, z] = s.position;
|
||||
@@ -842,6 +847,8 @@ impl Game<'_> {
|
||||
Some(Target::Tnt(id))
|
||||
} else if self.falling.iter().any(|f| f.id == id) {
|
||||
Some(Target::Falling(id))
|
||||
} else if self.rockets.iter().any(|r| r.id == id) {
|
||||
Some(Target::Rocket(id))
|
||||
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
|
||||
Some(Target::Spawner(id))
|
||||
} else {
|
||||
|
||||
@@ -6475,14 +6475,7 @@ impl Game<'_> {
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = BULLET.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:shulker_bullet#main",
|
||||
)
|
||||
});
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
@@ -6534,14 +6527,7 @@ impl Game<'_> {
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = SKULL.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:wither_skull#main",
|
||||
)
|
||||
});
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
|
||||
@@ -28,11 +28,7 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
if self.fangs.model.is_none() {
|
||||
self.fangs.model = horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:evoker_fangs#main",
|
||||
)
|
||||
.map(Arc::new);
|
||||
self.fangs.model = self.entity_models.evoker_fangs.clone();
|
||||
}
|
||||
self.fangs.list.push(Fangs {
|
||||
id,
|
||||
|
||||
@@ -29,7 +29,8 @@ enum RocketHit {
|
||||
}
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
/// Its entity id: from the entity counter on the server, the server's on
|
||||
/// a client.
|
||||
pub id: u32,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
@@ -223,8 +224,10 @@ impl Game<'_> {
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
+ r.next_int_bound(7).unwrap_or(0);
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
Rocket {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
|
||||
@@ -598,12 +598,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The knots: a 6 by 8 by 6 pixel block on the fence post.
|
||||
static KNOT: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = KNOT.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(&self.options.resources, "minecraft:leash_knot#main")
|
||||
});
|
||||
let Some(model) = model else { return };
|
||||
let Some(model) = self.entity_models.leash_knot.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/lead_knot/lead_knot").copied() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -173,18 +173,11 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_client::mobs::{Part, anim::Poses, load_model};
|
||||
static MODELS: std::sync::OnceLock<Option<(Part, Part)>> = std::sync::OnceLock::new();
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
if !horizon_client::mobs::raider::is_captain(mob) {
|
||||
return;
|
||||
}
|
||||
let Some((pole, flag)) = MODELS.get_or_init(|| {
|
||||
let root = &self.options.resources;
|
||||
Some((
|
||||
load_model(root, "minecraft:standing_banner#main")?,
|
||||
load_model(root, "minecraft:standing_banner#flag")?,
|
||||
))
|
||||
}) else {
|
||||
let Some((pole, flag)) = &self.entity_models.banner else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
|
||||
@@ -116,6 +116,7 @@ fn target_id(t: Target) -> Option<u32> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Spawner(id) => Some(id),
|
||||
Target::Player | Target::Guest(_) => None,
|
||||
}
|
||||
|
||||
@@ -458,7 +458,12 @@ impl Game<'_> {
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
|
||||
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let mut rocket = super::fireworks::Rocket::from_nbt(r);
|
||||
rocket.id = id;
|
||||
self.rockets.push(rocket);
|
||||
self.remember(id, uuid, tag);
|
||||
}
|
||||
}
|
||||
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
|
||||
@@ -1173,14 +1178,13 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(f.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for r in &self.rockets {
|
||||
if keys.contains(&chunk_of(r.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:firework_rocket", r.position);
|
||||
out.entry(chunk_of(r.position)).or_default().push(r.to_nbt(
|
||||
uuid,
|
||||
self.level.player_uuid,
|
||||
raw.as_ref(),
|
||||
));
|
||||
for i in 0..self.rockets.len() {
|
||||
if keys.contains(&chunk_of(self.rockets[i].position)) {
|
||||
let (id, at) = (self.rockets[i].id, self.rockets[i].position);
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist.raw.get(&id));
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
|
||||
@@ -181,6 +181,7 @@ impl Game<'_> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// It keeps its UUID with itself.
|
||||
Target::Spawner(id) => self
|
||||
@@ -224,6 +225,7 @@ impl Game<'_> {
|
||||
| Target::Projectile(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
// A server's player: the saved compound the guest's file is written from.
|
||||
|
||||
@@ -126,10 +126,11 @@ pub(super) struct Split<'a> {
|
||||
window1_shown: bool,
|
||||
/// Where each numbered window was when it closed, to open there again (this session).
|
||||
window_rects: std::collections::HashMap<u8, (LogicalPosition<f64>, LogicalSize<f64>)>,
|
||||
/// The monitors with their names, left to right, and the one Window 1 is on: read once a
|
||||
/// frame (`refresh_monitors`).
|
||||
/// The monitors with their names, left to right, and the one Window 1 is on: read while
|
||||
/// splitscreen is on, once a second (`refresh_monitors`), and when it last read them.
|
||||
screens: Vec<(String, MonitorHandle)>,
|
||||
main_screen: Option<String>,
|
||||
screens_read: Option<Instant>,
|
||||
/// `HORIZON_SPLIT_WINDOWS`: each seat's window, `1`, `2`... or `m<monitor>`.
|
||||
window_plan: Vec<String>,
|
||||
/// The views the players were last fitted to.
|
||||
@@ -187,6 +188,7 @@ impl<'a> Split<'a> {
|
||||
window_rects: Default::default(),
|
||||
screens: Vec::new(),
|
||||
main_screen: None,
|
||||
screens_read: None,
|
||||
window_plan: std::env::var("HORIZON_SPLIT_WINDOWS")
|
||||
.map(|w| w.split(',').map(|s| s.trim().to_owned()).collect())
|
||||
.unwrap_or_default(),
|
||||
@@ -710,8 +712,17 @@ impl ApplicationHandler for Split<'_> {
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.refresh_monitors();
|
||||
let on = self.host.settings.extras.splitscreen || self.forced;
|
||||
// Only splitscreen places windows on monitors. Listing them is a round trip to the
|
||||
// window server (macOS: 0.1-0.3 ms, more on a busy machine), so not every frame.
|
||||
if (on || !self.surfaces.is_empty())
|
||||
&& self
|
||||
.screens_read
|
||||
.is_none_or(|at| at.elapsed() >= Duration::from_secs(1))
|
||||
{
|
||||
self.screens_read = Some(Instant::now());
|
||||
self.refresh_monitors();
|
||||
}
|
||||
self.host.split_hud.menu = self.guests.iter().any(|g| g.name.is_some());
|
||||
// Off, or the last other player gone: P1 draws on its window again.
|
||||
if !self.surfaces.is_empty()
|
||||
|
||||
@@ -1241,11 +1241,30 @@ mod tests {
|
||||
delivered: false,
|
||||
handle: None,
|
||||
shown: false,
|
||||
ui_at: None,
|
||||
title_drawn: false,
|
||||
menu: StartupMenu::default(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_renderer_waits_for_the_first_title_frame() {
|
||||
let (_send, receiver) = std::sync::mpsc::channel();
|
||||
let mut loading = loading(receiver);
|
||||
// No menu bundle yet: nothing to wait for.
|
||||
assert!(loading.ready_for_handoff());
|
||||
// The title artwork is up but no frame of it was presented.
|
||||
loading.ui_at = Some(Instant::now());
|
||||
assert!(!loading.ready_for_handoff());
|
||||
loading.title_drawn = true;
|
||||
assert!(loading.ready_for_handoff());
|
||||
// A window never asked to redraw doesn't hold the start for long.
|
||||
loading.title_drawn = false;
|
||||
loading.ui_at = Instant::now().checked_sub(Duration::from_secs(2));
|
||||
assert!(loading.ready_for_handoff());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_is_usable_while_the_core_worker_is_blocked() {
|
||||
let root = std::env::temp_dir().join("horizon-startup-menu-no-world-resources");
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Game<'_> {
|
||||
pub(super) fn bounding_box_gizmos(&self, camera: [f64; 3], g: &mut Gizmos) {
|
||||
let game_master = self.can_use_game_master_blocks() || self.spectator();
|
||||
let facts = self.res.facts();
|
||||
for (pos, entity) in &self.world.block_entities {
|
||||
for (pos, entity) in self.world.block_entities.boxes() {
|
||||
let centre = [pos.0, pos.1, pos.2].map(|v| f64::from(v) + 0.5);
|
||||
let dist: f64 = (0..3).map(|i| (centre[i] - camera[i]).powi(2)).sum();
|
||||
let block = [pos.0, pos.1, pos.2];
|
||||
|
||||
@@ -694,12 +694,9 @@ impl Game<'_> {
|
||||
partial: f32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
static MODEL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = MODEL.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(&self.options.resources, "minecraft:wind_charge#main")
|
||||
});
|
||||
let Some(model) = model else { return };
|
||||
let Some(model) = self.entity_models.wind_charge.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
|
||||
+1
@@ -253,6 +253,7 @@ name = "horizon-telemetry-server"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"flate2",
|
||||
"horizon-telemetry",
|
||||
"http-body-util",
|
||||
"rusqlite",
|
||||
|
||||
@@ -19,6 +19,8 @@ rusqlite = { version = "0.37", features = ["bundled"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
ruzstd = "0.9"
|
||||
# The export answers gzip (pure Rust; ureq already pulls it in).
|
||||
flate2 = { version = "1", default-features = false, features = ["rust_backend"] }
|
||||
# Opens the hub's bug reports as GitHub issues (blocking, run off the async workers; rustls).
|
||||
ureq = { version = "2.12", features = ["json"] }
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ It runs at **https://telemetry.playhorizon.dev** (`DEFAULT_ENDPOINT` in `crates/
|
||||
| `DELETE /v1/installs/<id>` | Deletes everything that install sent. Its share of the daily totals is rebuilt at once. |
|
||||
| `GET /v1/aggregate?days=30` | The totals the website reads. Cached 10 minutes, with `Access-Control-Allow-Origin` set to the website. |
|
||||
| `GET /v1/benchmarks/summary` | Players' benchmark uploads grouped by GPU, CPU, render distance and game, for the website's Benchmarks page (below). Cached 10 minutes, same `Access-Control-Allow-Origin`. |
|
||||
| `GET /v1/export/benchmarks?after=<id>&limit=100` | Owner-only: the raw benchmark reports, oldest first, `{"reports": [{"id", "day", "version", "report"}], "next": <id>\|null}` (pass `next` as `after`). No install ID. Needs `Authorization: Bearer <TELEMETRY_EXPORT_TOKEN>`: 401 without it, 404 while the server has no token, gzip if the client accepts it, never cached. |
|
||||
| `GET /v1/export/hardware?days=30&offset=0&limit=200` | Owner-only, same token: the play sessions of the last `days` per machine (CPU, threads, physical cores, RAM, GPU, drive, OS…), busiest first, with installs, sessions, hours, mean p50/p99 and the frame buckets. No minimum number of installs; no install ID. `next_offset` pages. |
|
||||
| `POST /v1/live` | A game's "I'm playing" ping (JSON, at most 512 bytes). Returns 204. Returns 400 for a bad ping, 413 for one too large, 429 over the address's limit, 503 when it holds `LIVE_MAX_SESSIONS` already. |
|
||||
| `GET /v1/live` | How many play now, exactly: `playing`, `lan`, `versions` (the 12 most played, the rest under `other`), `platforms`. Made again at most every 15 seconds, with the same `Access-Control-Allow-Origin`. |
|
||||
| `GET /v1/live/history?range=24h\|7d\|30d\|all&series=players\|official` | The live count (`players`, the default) or the official server's player count (`official`) over time for playhorizon.dev/stats: `series` of `[start, average, peak]`, the range's `peak` and the `all_time` one with their minute, the `latest` minute, the average per platform ([docs/telemetry.md](../../docs/telemetry.md)). Made again at most every minute (`Cache-Control: max-age=60`), on the live count's rate limit. 400 for another range or series. |
|
||||
@@ -91,6 +93,7 @@ All are environment variables:
|
||||
- `HORIZON_TELEMETRY_HUB_KEEP_DAYS` (90)
|
||||
- `HORIZON_TELEMETRY_GITHUB_TOKEN`: a fine-grained token with Issues read and write on the repository only (unset: `POST /hub/report` answers 503 and the form points to GitHub and Discord)
|
||||
- `HORIZON_TELEMETRY_GITHUB_REPO` (`horizon-rust/horizon`)
|
||||
- `TELEMETRY_EXPORT_TOKEN` (or `HORIZON_TELEMETRY_EXPORT_TOKEN`): the bearer token of `/v1/export/*` (unset: those answer 404). A long random secret; requests with a wrong one count against the address's rate limit.
|
||||
|
||||
## Hosting
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! The protected, read-only export for the people who tune performance: the raw benchmark
|
||||
//! reports and the per-hardware totals, with no minimum number of installs (the public
|
||||
//! `/v1/aggregate` and `/v1/benchmarks/summary` hide small groups).
|
||||
//!
|
||||
//! - `GET /v1/export/benchmarks?after=<id>&limit=100`: `{"reports": [{"id", "day", "version",
|
||||
//! "report"}], "next": <id>|null}`, oldest first. Pass `next` as `after` for the next page.
|
||||
//! - `GET /v1/export/hardware?days=30&offset=0&limit=200`: `{"machines": [...], "next_offset"}`:
|
||||
//! one entry per distinct machine description (CPU threads and cores, RAM, GPU, drive, OS…),
|
||||
//! with its installs, sessions, hours and frame-time numbers, busiest first.
|
||||
//!
|
||||
//! Both need `Authorization: Bearer <TELEMETRY_EXPORT_TOKEN>`, are off (404) while the server has
|
||||
//! no such token, answer gzip to a client that accepts it, and are never cached. Install IDs
|
||||
//! never leave the database: a report has no owner here, and a machine only a count of them.
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use horizon_telemetry::payload::BUCKET_EDGES_MS;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::store::{Extra, Store, unpack};
|
||||
use crate::{App, peer, status};
|
||||
|
||||
/// Equal, in a time that depends on the token's length only, not on where they differ.
|
||||
pub(crate) fn same(sent: &[u8], token: &[u8]) -> bool {
|
||||
let mut diff = sent.len() ^ token.len();
|
||||
for (i, t) in token.iter().enumerate() {
|
||||
diff |= usize::from(sent.get(i).copied().unwrap_or(0) ^ t);
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
/// The answer to send instead when the request's `Bearer` token doesn't check out: 404 while the
|
||||
/// export is off, 401 for a missing or wrong token, 429 once an address has failed too often.
|
||||
/// None: go ahead.
|
||||
fn refuse(
|
||||
app: &App,
|
||||
headers: &HeaderMap,
|
||||
req_peer: Option<std::net::SocketAddr>,
|
||||
) -> Option<Response> {
|
||||
let Some(token) = app.config.export_token.as_deref() else {
|
||||
return Some(StatusCode::NOT_FOUND.into_response());
|
||||
};
|
||||
let sent = headers
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if same(sent.as_bytes(), token.as_bytes()) {
|
||||
return None;
|
||||
}
|
||||
// Only failures take from the address's bucket, so guessing is slow and using the token isn't.
|
||||
if !app.allow(app.client(headers, req_peer)) {
|
||||
return Some(status(StatusCode::TOO_MANY_REQUESTS, "too many requests"));
|
||||
}
|
||||
Some(StatusCode::UNAUTHORIZED.into_response())
|
||||
}
|
||||
|
||||
/// `value` as JSON, gzipped when the client accepts it.
|
||||
fn answer(headers: &HeaderMap, value: Value) -> Response {
|
||||
let json = value.to_string().into_bytes();
|
||||
let gzip = headers
|
||||
.get(header::ACCEPT_ENCODING)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| {
|
||||
v.split(',').any(|c| {
|
||||
c.trim()
|
||||
.split(';')
|
||||
.next()
|
||||
.is_some_and(|c| c.eq_ignore_ascii_case("gzip"))
|
||||
})
|
||||
});
|
||||
let mut res = if gzip {
|
||||
let mut z = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let packed = z.write_all(&json).and_then(|()| z.finish());
|
||||
match packed {
|
||||
Ok(packed) => {
|
||||
let mut res = packed.into_response();
|
||||
res.headers_mut()
|
||||
.insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
|
||||
res
|
||||
}
|
||||
Err(_) => json.into_response(),
|
||||
}
|
||||
} else {
|
||||
json.into_response()
|
||||
};
|
||||
let h = res.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("application/json"),
|
||||
);
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
h.insert(header::VARY, HeaderValue::from_static("Accept-Encoding"));
|
||||
res
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Page {
|
||||
after: Option<i64>,
|
||||
limit: Option<u32>,
|
||||
}
|
||||
|
||||
/// `GET /v1/export/benchmarks`: the raw benchmark reports, `limit` (1-500, default 100) a page.
|
||||
pub async fn benchmarks(
|
||||
State(app): State<Arc<App>>,
|
||||
Query(page): Query<Page>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
if let Some(res) = refuse(&app, req.headers(), peer(&req)) {
|
||||
return res;
|
||||
}
|
||||
let limit = page.limit.unwrap_or(100).clamp(1, 500);
|
||||
let listed = app
|
||||
.store()
|
||||
.export_benchmarks(page.after.unwrap_or(0), limit);
|
||||
match listed {
|
||||
Ok(reports) => {
|
||||
let next = (reports.len() as u32 == limit)
|
||||
.then(|| reports.last().map(|r| r["id"].clone()))
|
||||
.flatten();
|
||||
answer(req.headers(), json!({ "reports": reports, "next": next }))
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("horizon-telemetry-server: could not export the benchmarks: {e}");
|
||||
status(StatusCode::SERVICE_UNAVAILABLE, "try again later")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Machines {
|
||||
days: Option<u32>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `GET /v1/export/hardware`: play sessions of the last `days` (1-366, default 30) per machine
|
||||
/// description, `limit` (1-1000, default 200) machines a page from `offset`.
|
||||
pub async fn hardware(
|
||||
State(app): State<Arc<App>>,
|
||||
Query(q): Query<Machines>,
|
||||
req: Request,
|
||||
) -> Response {
|
||||
if let Some(res) = refuse(&app, req.headers(), peer(&req)) {
|
||||
return res;
|
||||
}
|
||||
let days = q.days.unwrap_or(30).clamp(1, 366);
|
||||
let (offset, limit) = (q.offset.unwrap_or(0), q.limit.unwrap_or(200).clamp(1, 1000));
|
||||
let to = (app.today)();
|
||||
let made = app.store().export_machines(to + 1 - days.min(to + 1), to);
|
||||
match made {
|
||||
Ok(all) => {
|
||||
let total = all.len();
|
||||
let machines: Vec<Value> = all.into_iter().skip(offset).take(limit).collect();
|
||||
let next = (offset + machines.len() < total).then_some(offset + machines.len());
|
||||
answer(
|
||||
req.headers(),
|
||||
json!({
|
||||
"from_day": to + 1 - days.min(to + 1), "to_day": to, "total_machines": total,
|
||||
"bucket_edges_ms": BUCKET_EDGES_MS.map(|e| (f64::from(e) * 10.0).round() / 10.0),
|
||||
"machines": machines, "next_offset": next,
|
||||
}),
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("horizon-telemetry-server: could not export the machines: {e}");
|
||||
status(StatusCode::SERVICE_UNAVAILABLE, "try again later")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Benchmark reports with an id above `after`, oldest first.
|
||||
pub fn export_benchmarks(&self, after: i64, limit: u32) -> rusqlite::Result<Vec<Value>> {
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT rowid, day, version, body FROM reports WHERE kind = 3 AND rowid > ?1
|
||||
ORDER BY rowid LIMIT ?2",
|
||||
)?;
|
||||
q.query_map(rusqlite::params![after, limit], |r| {
|
||||
Ok(json!({
|
||||
"id": r.get::<_, i64>(0)?, "day": r.get::<_, u32>(1)?, "version": r.get::<_, String>(2)?,
|
||||
"report": crate::store::inflate(&r.get::<_, Vec<u8>>(3)?),
|
||||
}))
|
||||
})?
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Play sessions in `from..=to` per machine description, the busiest first. A machine is its
|
||||
/// CPU, memory, GPU, drive and system: two sessions differing only in the screen or in
|
||||
/// battery against mains count as one. No group is left out for being small.
|
||||
pub fn export_machines(&self, from: u32, to: u32) -> rusqlite::Result<Vec<Value>> {
|
||||
#[derive(Default)]
|
||||
struct Machine {
|
||||
installs: HashSet<Vec<u8>>,
|
||||
sessions: u64,
|
||||
seconds: u64,
|
||||
p50_sum: f64,
|
||||
p99_sum: f64,
|
||||
buckets: Vec<u64>,
|
||||
peak_ram_mb: u32,
|
||||
description: Value,
|
||||
}
|
||||
let mut machines: HashMap<String, Machine> = HashMap::new();
|
||||
let mut q = self.db.prepare(
|
||||
"SELECT perf.install, perf.seconds, perf.frames, perf.peak_ram_mb, systems.os, systems.os_version,
|
||||
systems.arch, systems.cpu, systems.threads, systems.ram_gb, systems.gpu, systems.driver,
|
||||
systems.backend, systems.extra
|
||||
FROM perf JOIN systems ON systems.id = perf.system WHERE perf.day BETWEEN ?1 AND ?2",
|
||||
)?;
|
||||
let mut rows = q.query([from, to])?;
|
||||
while let Some(r) = rows.next()? {
|
||||
let Some(frames) = unpack(&r.get::<_, Vec<u8>>(2)?) else {
|
||||
continue;
|
||||
};
|
||||
let extra: Extra = serde_json::from_str(&r.get::<_, String>(13)?).unwrap_or_default();
|
||||
let description = json!({
|
||||
"os": r.get::<_, String>(4)?, "os_version": r.get::<_, String>(5)?,
|
||||
"arch": r.get::<_, String>(6)?, "cpu": r.get::<_, String>(7)?,
|
||||
"threads": r.get::<_, u16>(8)?, "cores": extra.cores,
|
||||
"cpu_base_mhz": extra.cpu_base_mhz, "cpu_boost_mhz": extra.cpu_boost_mhz,
|
||||
"ram_gb": r.get::<_, u16>(9)?,
|
||||
"gpu": r.get::<_, String>(10)?, "gpu_vendor": extra.gpu_vendor, "gpu_kind": extra.gpu_kind,
|
||||
"vram_mb": extra.vram_mb, "driver": r.get::<_, String>(11)?,
|
||||
"backend": r.get::<_, String>(12)?, "storage": extra.storage, "form": extra.form,
|
||||
});
|
||||
let m = machines.entry(description.to_string()).or_default();
|
||||
if m.buckets.is_empty() {
|
||||
m.buckets = vec![0; frames.buckets.len()];
|
||||
m.description = description;
|
||||
}
|
||||
m.installs.insert(r.get(0)?);
|
||||
m.sessions += 1;
|
||||
m.seconds += r.get::<_, u64>(1)?;
|
||||
m.peak_ram_mb = m.peak_ram_mb.max(r.get(3)?);
|
||||
m.p50_sum += f64::from(frames.p50_ms);
|
||||
m.p99_sum += f64::from(frames.p99_ms);
|
||||
for (sum, n) in m.buckets.iter_mut().zip(&frames.buckets) {
|
||||
*sum += u64::from(*n);
|
||||
}
|
||||
}
|
||||
let mut list: Vec<(String, Machine)> = machines.into_iter().collect();
|
||||
// Busiest first; the description breaks ties so pages are stable.
|
||||
list.sort_by(|a, b| b.1.sessions.cmp(&a.1.sessions).then(a.0.cmp(&b.0)));
|
||||
let avg = |sum: f64, n: u64| (sum / n as f64 * 10.0).round() / 10.0;
|
||||
Ok(list
|
||||
.into_iter()
|
||||
.map(|(_, m)| {
|
||||
let mut out = m.description;
|
||||
out["installs"] = m.installs.len().into();
|
||||
out["sessions"] = m.sessions.into();
|
||||
out["hours"] = ((m.seconds as f64 / 360.0).round() / 10.0).into();
|
||||
out["p50_ms"] = avg(m.p50_sum, m.sessions).into();
|
||||
out["p99_ms"] = avg(m.p99_sum, m.sessions).into();
|
||||
out["peak_ram_mb"] = m.peak_ram_mb.into();
|
||||
out["frame_buckets"] = m.buckets.into();
|
||||
out
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::same;
|
||||
|
||||
#[test]
|
||||
fn tokens_are_compared_whole() {
|
||||
assert!(same(b"abc", b"abc"));
|
||||
assert!(!same(b"abd", b"abc"));
|
||||
assert!(!same(b"ab", b"abc"));
|
||||
assert!(!same(b"abcd", b"abc"));
|
||||
assert!(!same(b"", b"abc"));
|
||||
// A token with a trailing NUL byte isn't the token without it.
|
||||
assert!(!same(b"abc\0", b"abc"));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
//! - `GET /v1/aggregate?days=30`: the anonymous totals the website reads, cached.
|
||||
//! - `GET /v1/benchmarks/summary`: players' benchmark results grouped by GPU and CPU, groups of at
|
||||
//! least 3 installs only (`summary`), cached; the website's Benchmarks page reads it.
|
||||
//! - `GET /v1/export/benchmarks` and `/v1/export/hardware`: raw benchmark reports and per-machine
|
||||
//! totals for the perf work, behind a bearer token (`export`); off without one.
|
||||
//! - `POST /v1/live`: a game's anonymous "I'm playing" ping (`live`, in memory only).
|
||||
//! - `GET /v1/live`: how many play now, exactly, cached briefly.
|
||||
//! - `GET /v1/live/history?range=24h|7d|30d|all&series=players|official`: that count, or the
|
||||
@@ -32,6 +34,7 @@ use axum::routing::{delete, get, post};
|
||||
use horizon_telemetry::batch;
|
||||
|
||||
pub mod classes;
|
||||
pub mod export;
|
||||
pub mod github;
|
||||
pub mod history;
|
||||
pub mod hub;
|
||||
@@ -81,6 +84,8 @@ pub struct Config {
|
||||
pub hub_reports_per_day: u32,
|
||||
/// Days reports are kept (opened as issues long before).
|
||||
pub hub_keep_days: u32,
|
||||
/// The bearer token the protected export (`export`) wants; unset, it answers 404.
|
||||
pub export_token: Option<String>,
|
||||
/// Opens the website's bug reports as issues; unset, `POST /hub/report` answers 503.
|
||||
pub github_token: Option<String>,
|
||||
/// Where those issues go, `owner/name`.
|
||||
@@ -106,6 +111,7 @@ impl Default for Config {
|
||||
hub_posts_per_hour: 20.0,
|
||||
hub_reports_per_day: 30,
|
||||
hub_keep_days: 90,
|
||||
export_token: None,
|
||||
github_token: None,
|
||||
github_repo: "horizon-rust/horizon".into(),
|
||||
}
|
||||
@@ -118,7 +124,8 @@ impl Config {
|
||||
/// `…_LIVE_PER_HOUR`, `…_LIVE_WINDOW_SECONDS`, `…_LIVE_MAX_SESSIONS`, `…_LIVE_MINUTE_DAYS`,
|
||||
/// `…_LIVE_HOUR_DAYS`, `…_OFFICIAL_SERVER` (`off` for none), `…_HUB_PER_HOUR`,
|
||||
/// `…_HUB_POSTS_PER_HOUR`, `…_HUB_REPORTS_PER_DAY`, `…_HUB_KEEP_DAYS`, `…_GITHUB_TOKEN` and
|
||||
/// `…_GITHUB_REPO`. An empty token counts as unset.
|
||||
/// `…_GITHUB_REPO`, plus `TELEMETRY_EXPORT_TOKEN` (or `HORIZON_TELEMETRY_EXPORT_TOKEN`) for the
|
||||
/// protected export. An empty token counts as unset.
|
||||
pub fn from_env() -> Self {
|
||||
let var = |k: &str| std::env::var(format!("HORIZON_TELEMETRY_{k}")).ok();
|
||||
let secret = |k: &str| {
|
||||
@@ -174,6 +181,11 @@ impl Config {
|
||||
hub_keep_days: var("HUB_KEEP_DAYS")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d.hub_keep_days),
|
||||
export_token: std::env::var("TELEMETRY_EXPORT_TOKEN")
|
||||
.ok()
|
||||
.map(|v| v.trim().to_owned())
|
||||
.filter(|v| !v.is_empty())
|
||||
.or_else(|| secret("EXPORT_TOKEN")),
|
||||
github_token: secret("GITHUB_TOKEN"),
|
||||
github_repo: var("GITHUB_REPO").unwrap_or(d.github_repo),
|
||||
}
|
||||
@@ -718,6 +730,8 @@ pub fn router(app: Arc<App>) -> Router {
|
||||
.route("/v1/installs/{id}", delete(forget))
|
||||
.route("/v1/aggregate", get(aggregate))
|
||||
.route("/v1/benchmarks/summary", get(benchmarks))
|
||||
.route("/v1/export/benchmarks", get(export::benchmarks))
|
||||
.route("/v1/export/hardware", get(export::hardware))
|
||||
.route("/hub/vote", post(hub::vote).options(hub::preflight))
|
||||
.route("/hub/votes", get(hub::votes))
|
||||
.route("/hub/report", post(intake::report).options(hub::preflight))
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! The protected export through its HTTP interface: off without a token, 401 without the right
|
||||
//! one, raw benchmark reports paginated, per-machine totals with the CPU core counts, gzip.
|
||||
use std::io::Read;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use horizon_telemetry::Item;
|
||||
use horizon_telemetry::batch::{self, Batch};
|
||||
use horizon_telemetry::payload::{Payload, Perf, Settings, Source, System, Timings};
|
||||
use horizon_telemetry_server::store::Store;
|
||||
use horizon_telemetry_server::{App, Config, router};
|
||||
use http_body_util::BodyExt;
|
||||
use serde_json::{Value, json};
|
||||
use tower::ServiceExt;
|
||||
|
||||
const DAY: u32 = 20_741;
|
||||
const TOKEN: &str = "a-long-random-export-token";
|
||||
|
||||
fn install(n: u32) -> String {
|
||||
format!("{n:032x}")
|
||||
}
|
||||
|
||||
fn app(token: Option<&str>) -> Arc<App> {
|
||||
App::with_clock(
|
||||
Store::open(":memory:").unwrap(),
|
||||
Config {
|
||||
requests_per_hour: 100_000.0,
|
||||
export_token: token.map(str::to_owned),
|
||||
..Config::default()
|
||||
},
|
||||
Box::new(|| DAY),
|
||||
)
|
||||
}
|
||||
|
||||
fn perf(cpu: &str, threads: u16, cores: u16, storage: &str) -> Item {
|
||||
let mut t = Timings::new();
|
||||
(0..3000).for_each(|i| t.record(6.0 + (i % 50) as f32 / 10.0));
|
||||
let mut item = Item::new(
|
||||
Source::Game,
|
||||
"v0.2.97",
|
||||
Payload::Perf(Perf {
|
||||
system: System {
|
||||
os: "windows".into(),
|
||||
cpu: cpu.into(),
|
||||
threads,
|
||||
cores,
|
||||
ram_gb: 32,
|
||||
gpu: "NVIDIA GeForce RTX 4070".into(),
|
||||
gpu_vendor: "nvidia".into(),
|
||||
gpu_kind: "discrete".into(),
|
||||
backend: "Dx12".into(),
|
||||
storage: storage.into(),
|
||||
..System::default()
|
||||
},
|
||||
settings: Settings {
|
||||
render_distance: 12,
|
||||
..Settings::default()
|
||||
},
|
||||
seconds: 3600,
|
||||
frames: t.summary(),
|
||||
peak_ram_mb: 2500,
|
||||
..Perf::default()
|
||||
}),
|
||||
);
|
||||
item.day = DAY;
|
||||
item
|
||||
}
|
||||
|
||||
fn benchmark(n: u32) -> Item {
|
||||
let mut item = Item::new(
|
||||
Source::Game,
|
||||
"v0.2.97",
|
||||
Payload::Benchmark(json!({
|
||||
"format": "horizon-benchmark-report", "version": 1, "n": n,
|
||||
"hardware": { "os": "windows", "cpu_threads": 32, "cpu_cores": 16, "storage": "nvme" },
|
||||
})),
|
||||
);
|
||||
item.day = DAY;
|
||||
item
|
||||
}
|
||||
|
||||
async fn send(app: &Arc<App>, req: Request<Body>) -> (StatusCode, Vec<u8>, Option<String>) {
|
||||
let res = router(app.clone()).oneshot(req).await.unwrap();
|
||||
let code = res.status();
|
||||
let encoding = res
|
||||
.headers()
|
||||
.get("content-encoding")
|
||||
.map(|v| v.to_str().unwrap().to_owned());
|
||||
(
|
||||
code,
|
||||
res.into_body().collect().await.unwrap().to_bytes().to_vec(),
|
||||
encoding,
|
||||
)
|
||||
}
|
||||
|
||||
async fn upload(app: &Arc<App>, id: u32, items: Vec<Item>) {
|
||||
let body = batch::encode(&Batch::new(&install(id), items));
|
||||
let req = Request::post("/v1/batch")
|
||||
.header("content-encoding", "zstd")
|
||||
.body(Body::from(body))
|
||||
.unwrap();
|
||||
assert_eq!(send(app, req).await.0, StatusCode::NO_CONTENT);
|
||||
}
|
||||
|
||||
async fn get(app: &Arc<App>, uri: &str, token: Option<&str>) -> (StatusCode, Value) {
|
||||
let mut req = Request::get(uri);
|
||||
if let Some(t) = token {
|
||||
req = req.header("authorization", format!("Bearer {t}"));
|
||||
}
|
||||
let (code, body, encoding) = send(app, req.body(Body::empty()).unwrap()).await;
|
||||
assert_eq!(encoding, None, "plain unless gzip is asked for");
|
||||
(code, serde_json::from_slice(&body).unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_is_off_without_a_token_and_refuses_a_wrong_one() {
|
||||
for uri in ["/v1/export/benchmarks", "/v1/export/hardware"] {
|
||||
let off = app(None);
|
||||
assert_eq!(get(&off, uri, None).await.0, StatusCode::NOT_FOUND);
|
||||
assert_eq!(get(&off, uri, Some(TOKEN)).await.0, StatusCode::NOT_FOUND);
|
||||
|
||||
let on = app(Some(TOKEN));
|
||||
assert_eq!(get(&on, uri, None).await.0, StatusCode::UNAUTHORIZED);
|
||||
for wrong in [
|
||||
"",
|
||||
"a-long-random-export-toke",
|
||||
"a-long-random-export-tokenX",
|
||||
"nope",
|
||||
] {
|
||||
assert_eq!(
|
||||
get(&on, uri, Some(wrong)).await.0,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{wrong:?}"
|
||||
);
|
||||
}
|
||||
// The wrong scheme, even with the right secret.
|
||||
let req = Request::get(uri)
|
||||
.header("authorization", format!("Basic {TOKEN}"))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(send(&on, req).await.0, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(get(&on, uri, Some(TOKEN)).await.0, StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guessing_is_rate_limited_but_using_the_token_is_not() {
|
||||
let app = App::with_clock(
|
||||
Store::open(":memory:").unwrap(),
|
||||
Config {
|
||||
requests_per_hour: 10.0,
|
||||
export_token: Some(TOKEN.into()),
|
||||
..Config::default()
|
||||
},
|
||||
Box::new(|| DAY),
|
||||
);
|
||||
let mut codes = Vec::new();
|
||||
for _ in 0..5 {
|
||||
codes.push(get(&app, "/v1/export/hardware", Some("guess")).await.0);
|
||||
}
|
||||
assert_eq!(codes[0], StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(codes[4], StatusCode::TOO_MANY_REQUESTS);
|
||||
for _ in 0..20 {
|
||||
assert_eq!(
|
||||
get(&app, "/v1/export/hardware", Some(TOKEN)).await.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn benchmark_reports_come_out_raw_and_in_pages() {
|
||||
let app = app(Some(TOKEN));
|
||||
for n in 0..5 {
|
||||
upload(&app, n + 1, vec![benchmark(n)]).await;
|
||||
}
|
||||
// A play session is not a benchmark.
|
||||
upload(&app, 9, vec![perf("AMD Ryzen 9 7950X", 32, 16, "nvme")]).await;
|
||||
|
||||
let (code, first) = get(&app, "/v1/export/benchmarks?limit=2", Some(TOKEN)).await;
|
||||
assert_eq!(code, StatusCode::OK);
|
||||
let reports = first["reports"].as_array().unwrap();
|
||||
assert_eq!(reports.len(), 2);
|
||||
assert_eq!(reports[0]["report"]["hardware"]["cpu_threads"], 32);
|
||||
assert_eq!(reports[0]["report"]["hardware"]["cpu_cores"], 16);
|
||||
assert_eq!(
|
||||
(reports[0]["day"].as_u64(), reports[0]["version"].as_str()),
|
||||
(Some(DAY.into()), Some("v0.2.97"))
|
||||
);
|
||||
|
||||
let mut seen = vec![
|
||||
reports[0]["report"]["n"].clone(),
|
||||
reports[1]["report"]["n"].clone(),
|
||||
];
|
||||
let mut next = first["next"].clone();
|
||||
let mut pages = 1;
|
||||
while let Some(after) = next.as_i64() {
|
||||
let (_, page) = get(
|
||||
&app,
|
||||
&format!("/v1/export/benchmarks?limit=2&after={after}"),
|
||||
Some(TOKEN),
|
||||
)
|
||||
.await;
|
||||
seen.extend(
|
||||
page["reports"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|r| r["report"]["n"].clone()),
|
||||
);
|
||||
next = page["next"].clone();
|
||||
pages += 1;
|
||||
}
|
||||
assert_eq!(seen, [0, 1, 2, 3, 4].map(Value::from));
|
||||
assert_eq!(pages, 3);
|
||||
|
||||
// Whose report it was never leaves the database.
|
||||
let (_, all) = get(&app, "/v1/export/benchmarks", Some(TOKEN)).await;
|
||||
assert_eq!(all["next"], Value::Null);
|
||||
assert!(!all.to_string().contains(&install(1)));
|
||||
assert!(!all.to_string().contains("install"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn machines_come_with_their_core_counts_and_no_minimum() {
|
||||
let app = app(Some(TOKEN));
|
||||
// One install on a 16-core / 32-thread machine, two on a 4-core / 8-thread one.
|
||||
upload(&app, 1, vec![perf("AMD Ryzen 9 7950X", 32, 16, "nvme")]).await;
|
||||
upload(&app, 2, vec![perf("Intel Core i5-8250U", 8, 4, "ssd")]).await;
|
||||
upload(&app, 3, vec![perf("Intel Core i5-8250U", 8, 4, "ssd")]).await;
|
||||
|
||||
let (code, out) = get(&app, "/v1/export/hardware?days=30", Some(TOKEN)).await;
|
||||
assert_eq!(code, StatusCode::OK);
|
||||
assert_eq!(out["total_machines"], 2);
|
||||
let machines = out["machines"].as_array().unwrap();
|
||||
// Busiest first.
|
||||
assert_eq!(machines[0]["cpu"], "Intel Core i5-8250U");
|
||||
assert_eq!(
|
||||
(
|
||||
machines[0]["threads"].as_u64(),
|
||||
machines[0]["cores"].as_u64()
|
||||
),
|
||||
(Some(8), Some(4))
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
machines[0]["installs"].as_u64(),
|
||||
machines[0]["sessions"].as_u64()
|
||||
),
|
||||
(Some(2), Some(2))
|
||||
);
|
||||
assert_eq!(machines[0]["storage"], "ssd");
|
||||
assert_eq!(machines[0]["hours"], 2.0);
|
||||
assert!(machines[0]["p99_ms"].as_f64().unwrap() > 0.0);
|
||||
assert_eq!(
|
||||
machines[0]["frame_buckets"].as_array().unwrap().len(),
|
||||
out["bucket_edges_ms"].as_array().unwrap().len() + 1
|
||||
);
|
||||
assert_eq!(machines[1]["cpu"], "AMD Ryzen 9 7950X");
|
||||
assert_eq!(
|
||||
(
|
||||
machines[1]["threads"].as_u64(),
|
||||
machines[1]["cores"].as_u64()
|
||||
),
|
||||
(Some(32), Some(16))
|
||||
);
|
||||
assert_eq!(machines[1]["installs"], 1);
|
||||
assert!(!out.to_string().contains(&install(1)));
|
||||
|
||||
// Pages by offset.
|
||||
let (_, page) = get(&app, "/v1/export/hardware?limit=1", Some(TOKEN)).await;
|
||||
assert_eq!(page["machines"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(page["next_offset"], 1);
|
||||
let (_, page) = get(&app, "/v1/export/hardware?limit=1&offset=1", Some(TOKEN)).await;
|
||||
assert_eq!(page["machines"][0]["cpu"], "AMD Ryzen 9 7950X");
|
||||
assert_eq!(page["next_offset"], Value::Null);
|
||||
|
||||
// Outside the window: nothing.
|
||||
let old = App::with_clock(
|
||||
Store::open(":memory:").unwrap(),
|
||||
Config {
|
||||
export_token: Some(TOKEN.into()),
|
||||
..Config::default()
|
||||
},
|
||||
Box::new(|| DAY + 90),
|
||||
);
|
||||
let (_, none) = get(&old, "/v1/export/hardware?days=30", Some(TOKEN)).await;
|
||||
assert_eq!(none["total_machines"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gzip_when_asked() {
|
||||
let app = app(Some(TOKEN));
|
||||
upload(&app, 1, vec![benchmark(7)]).await;
|
||||
let req = Request::get("/v1/export/benchmarks")
|
||||
.header("authorization", format!("Bearer {TOKEN}"))
|
||||
.header("accept-encoding", "br, gzip;q=0.8")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let res = router(app.clone()).oneshot(req).await.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::OK);
|
||||
assert_eq!(res.headers()["content-encoding"], "gzip");
|
||||
assert_eq!(res.headers()["content-type"], "application/json");
|
||||
assert_eq!(res.headers()["cache-control"], "no-store");
|
||||
let body = res.into_body().collect().await.unwrap().to_bytes();
|
||||
let mut json = String::new();
|
||||
flate2::read::GzDecoder::new(&body[..])
|
||||
.read_to_string(&mut json)
|
||||
.unwrap();
|
||||
let out: Value = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(out["reports"][0]["report"]["n"], 7);
|
||||
}
|
||||
@@ -108,6 +108,7 @@ The launcher reads the count when it opens and every minute after, and shows "N
|
||||
- `DELETE /v1/installs/<install_id>`: deletes everything sent under that ID.
|
||||
- `GET /v1/aggregate`: the anonymous totals the website's results page reads. Its `breakdowns` split the play sessions of the period by hardware class (below), each class with its installs, sessions, hours and mean p50 and p99 frame times.
|
||||
- `GET /v1/benchmarks/summary`: players' benchmark uploads grouped by GPU, CPU, render distance and game for playhorizon.dev/benchmarks. A group shows only with at least 3 different installs, and only its medians and counts: no install ID, day, OS version, driver, exact memory or screen. Uploads are never published one by one. Each group's `breakdowns` count its installs per hardware class.
|
||||
- `GET /v1/export/benchmarks` and `GET /v1/export/hardware`: for the maintainers' performance work only, behind a bearer token the public cannot use (404 while the server has none). The raw benchmark reports and the play sessions per machine description, including CPU threads and physical cores, with no minimum group size. Nothing identifying: no install ID, and only what a player already agreed to send (this page's lists).
|
||||
- `POST /v1/live` and `GET /v1/live`: the "I'm playing" ping and the live count.
|
||||
- `GET /v1/live/history?range=24h|7d|30d|all&series=players|official`: the live count, or the official server's, over time (above).
|
||||
|
||||
|
||||
@@ -363,6 +363,16 @@ def entities():
|
||||
cmd('/tp @e[type=minecraft:falling_block] 4.5 215 8.5'),
|
||||
cmd('/kill @e[type=minecraft:falling_block]'),
|
||||
cmd('/execute if entity @e[type=minecraft:falling_block]'),
|
||||
# A firework rocket, with what `addAdditionalSaveData` writes.
|
||||
cmd('/summon minecraft:firework_rocket 4.5 210 8.5 {LifeTime:2000,NoGravity:1b,Tags:["r"]}'),
|
||||
cmd('/execute if entity @e[type=minecraft:firework_rocket,tag=r]'),
|
||||
cmd('/tag @e[type=minecraft:firework_rocket,limit=1] list'),
|
||||
cmd('/data get entity @e[type=minecraft:firework_rocket,limit=1] LifeTime'),
|
||||
cmd('/data get entity @e[type=minecraft:firework_rocket,limit=1] FireworksItem.id'),
|
||||
cmd('/data get entity @e[type=minecraft:firework_rocket,limit=1] ShotAtAngle'),
|
||||
cmd('/tp @e[type=minecraft:firework_rocket] 4.5 215 8.5'),
|
||||
cmd('/kill @e[type=minecraft:firework_rocket]'),
|
||||
cmd('/execute if entity @e[type=minecraft:firework_rocket]'),
|
||||
# An ominous item spawner, with an item or empty (`ItemStack.EMPTY`,
|
||||
# which saves no `item` and spawns nothing when its time comes).
|
||||
cmd('/summon minecraft:ominous_item_spawner 4.5 210 8.5 {spawn_item_after_ticks:2000L,item:{id:"minecraft:diamond",count:2},Tags:["o"]}'),
|
||||
|
||||
@@ -107,8 +107,14 @@ def mob_case(kind, weapon=SWORD, sprint=False, crit=False, air=False):
|
||||
return lines
|
||||
|
||||
|
||||
# A shield that blocks half of a melee hit: its own push (blockedByItem) comes before the hit's (hurtServer).
|
||||
SHIELD = 'minecraft:shield'
|
||||
HALF_SHIELD = ('minecraft:shield[blocks_attacks={block_delay_seconds:0.25,'
|
||||
'damage_reductions:[{base:0,factor:0.5}]}]')
|
||||
|
||||
|
||||
def pvp_case(weapon=SWORD, sprint=False, crit=False, shield=False, armour=False, twice=False, mace=False,
|
||||
cooldown=False):
|
||||
cooldown=False, shield_item=SHIELD):
|
||||
lines = [f'cmd Alice item replace entity @s weapon.mainhand with {weapon}', 'cmd Bob clear @s',
|
||||
'cmd Bob effect give @s minecraft:resistance 1000 4 true',
|
||||
'cmd Bob effect give @s minecraft:instant_health 1 5 true', *stand('Alice', A, -90), *stand('Bob', V, 90)]
|
||||
@@ -117,8 +123,9 @@ def pvp_case(weapon=SWORD, sprint=False, crit=False, shield=False, armour=False,
|
||||
for slot, piece in (('head', 'helmet'), ('chest', 'chestplate'), ('legs', 'leggings'),
|
||||
('feet', 'boots'))]
|
||||
if shield:
|
||||
# A shield blocks from five ticks after it is raised.
|
||||
lines += ['cmd Bob item replace entity @s weapon.mainhand with minecraft:shield', 'use Bob']
|
||||
# A shield blocks from five ticks after it is raised. Two ticks first: a stack swapped in the tick of an earlier
|
||||
# shield's use still counts as being used, and the server drops the `use`.
|
||||
lines += [f'cmd Bob item replace entity @s weapon.mainhand with {shield_item}', 'ticks 2', 'use Bob']
|
||||
lines += ['ticks 30', *stand('Bob', V, 90, ticks=1)]
|
||||
if sprint:
|
||||
lines.append('sprint Alice on')
|
||||
@@ -155,6 +162,18 @@ def mob_hits_case(kind):
|
||||
'cmd Alice kill @e[type=!player]', 'cmd Alice gamemode survival']
|
||||
|
||||
|
||||
def zombie_half_shield_case():
|
||||
"""A zombie one block in front of Bob, who holds a raised half-blocking shield, hits him once."""
|
||||
mob = '@e[type=minecraft:zombie,limit=1]'
|
||||
return ['cmd Bob clear @s', 'cmd Alice gamemode creative', *stand('Bob', V, 90),
|
||||
f'cmd Bob item replace entity @s weapon.mainhand with {HALF_SHIELD}', 'ticks 2', 'use Bob', 'ticks 30',
|
||||
*stand('Bob', V, 90, ticks=1), 'mark Bob',
|
||||
f'cmd Alice summon minecraft:zombie {V[0] + 1} {V[1]} {V[2]} {{Rotation:[90f,0f]}}',
|
||||
f'cmd Alice effect give {mob} minecraft:slowness 100000 127 true', 'await Bob', f'cmd Alice kill {mob}',
|
||||
'ticks 20', *stand('Bob', V, 90), 'cmd Bob clear @s', 'cmd Alice kill @e[type=!player]',
|
||||
'cmd Alice gamemode survival']
|
||||
|
||||
|
||||
def blast_case(blast, row):
|
||||
"""A blast two blocks east of Bob in creative, which hurts him not: an end crystal he hits himself, or primed TNT
|
||||
about to go off. The push his client is sent (the explosion packet's playerKnockback, ServerExplosion.hitPlayers)
|
||||
@@ -185,6 +204,8 @@ CASES = {
|
||||
'pvp-knockback-2': pvp_case(weapon=KNOCKBACK_SWORD),
|
||||
'pvp-crit': pvp_case(crit=True),
|
||||
'pvp-shield': pvp_case(shield=True),
|
||||
'pvp-shield-partial': pvp_case(shield=True, shield_item=HALF_SHIELD),
|
||||
'zombie-shield-partial': zombie_half_shield_case(),
|
||||
'pvp-netherite': pvp_case(armour=True),
|
||||
'pvp-twice': pvp_case(twice=True),
|
||||
'pvp-sprint-twice': pvp_case(sprint=True, twice=True),
|
||||
|
||||
Reference in new issue
Block a user