Merge main into #7418

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/command_exec_corpus.py
This commit is contained in:
Ludovic Marie committed 2026-11-02 20:34:39 +01:00
commit 604256c623
282 files changed
+38178 -2392

No files matched your search

+9
View File
@@ -185,6 +185,15 @@ jobs:
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_imports.py "$file"
fi
done
- name: Baseline x86-64 CPUs only
# No POPCNT or LZCNT at all (the script says why only those): a C library's count intrinsic killed the
# game before main on a Core 2 (#7264). Not the Mac builds: every Intel Mac that runs macOS 11 has both.
if: runner.os != 'macOS'
run: |
set -euo pipefail
rustup component add llvm-tools
${{ runner.os == 'Windows' && 'python' || 'python3' }} tools/check_cpu_baseline.py \
$(for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do echo "$BIN/$binary${{ matrix.exe }}"; done)
- name: Every program starts
run: |
set -euo pipefail
+2 -1
View File
@@ -96,7 +96,8 @@ configuration and toolchain changes select the whole workspace; CI also keeps it
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the thread. Before you start, comment
**`/claim`** on the issue; you are assigned at once, no role needed. No issue for your work yet? Open one (a line is
enough), then claim it. `/unclaim` gives it back.
enough), then claim it. `/unclaim` gives it back. Issues labelled `tracker`, `proposal` or `not claimable` aren't
claimed whole: claim one of the issues a tracker lists, or open one for the part you take.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
Generated
-2
View File
@@ -2114,8 +2114,6 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
+5 -1
View File
@@ -1,5 +1,5 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga"]
exclude = ["launcher", "crates/horizon-telemetry-server", "crates/horizon-mod-registry", "third_party/wgpu-hal", "third_party/naga", "third_party/libmimalloc-sys"]
members = [
"crates/horizon-core",
"crates/horizon-dualsense",
@@ -67,6 +67,10 @@ large_enum_variant = "allow"
wgpu-hal = { path = "third_party/wgpu-hal" }
# naga 30.0.1, with the fixes to its GLSL frontend that shader packs need (THIRD_PARTY_NOTICES.md).
naga = { path = "third_party/naga" }
# libmimalloc-sys 0.1.49 whose mimalloc v3 counts bits without POPCNT when MSVC builds it: the
# game died before main on CPUs without it (Core 2, #7264; THIRD_PARTY_NOTICES.md). Drop this and
# the folder once a libmimalloc-sys release has microsoft/mimalloc#1291's fix.
libmimalloc-sys = { path = "third_party/libmimalloc-sys" }
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
+20
View File
@@ -14,6 +14,15 @@ Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
fixes to its GLSL frontend that shader packs need, each applied in the commit that needed it. It is licensed under MIT or Apache-2.0; both licenses
are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## libmimalloc-sys and mimalloc
`third_party/libmimalloc-sys` is [libmimalloc-sys](https://github.com/purpleprotocol/mimalloc_rust)
0.1.49 by Octavian Oncescu, with the [mimalloc](https://github.com/microsoft/mimalloc) v3 sources it
bundles (Copyright (c) 2018-2025 Microsoft Corporation, Daan Leijen), both under the MIT license
(`LICENSE.txt` and `c_src/mimalloc/v3/LICENSE` in that folder). It holds only the v3 sources. One
change: `mi_popcount` in `include/mimalloc/bits.h` uses mimalloc's portable bit count on MSVC
x86/x64 instead of the POPCNT instruction, which CPUs before Nehalem lack (#7264).
## Pixelify Sans
`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans)
@@ -27,6 +36,17 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## Hugeicons
`crates/horizon-theme/icons/glass/*.svg` are icons from the free set of
[Hugeicons](https://github.com/hugeicons/hugeicons) (`@hugeicons/core-free-icons` 4.4.0), under
the MIT license (`crates/horizon-theme/icons/glass/LICENSE-hugeicons.txt`), exported with a 1.6
stroke in `currentColor`.
`crates/horizon-theme/icons/glass/discord.svg` is Discord's logo as drawn by
[Simple Icons](https://github.com/simple-icons/simple-icons) 13.21.0 (CC0 1.0). The logo is Discord's
trademark, used only on the button that opens Horizon's Discord server.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` and `interpolation.wgsl` are
+203 -2
View File
@@ -182,13 +182,38 @@ impl LimiterClock for SystemClock {
i64::try_from(self.origin.elapsed().as_nanos()).unwrap_or(i64::MAX)
}
fn park(&mut self, nanos: i64) {
std::thread::sleep(std::time::Duration::from_nanos(nanos.max(0) as u64));
let end = self.nanos().saturating_add(nanos);
park_in_steps(
end,
|| self.nanos(),
|step| {
std::thread::sleep(std::time::Duration::from_nanos(step as u64));
},
);
}
fn spin(&mut self) {
std::hint::spin_loop();
}
}
/// The longest single sleep of [`SystemClock::park`].
const PARK_STEP_NS: i64 = 1_000_000;
/// Sleeps until `now()` reaches `end`, at most [`PARK_STEP_NS`] at a time. macOS lets
/// a sleep run late by a share of its length (timer coalescing: 1.8-3.5 ms on a 14 ms
/// sleep, 0.25 ms on 1 ms), more than the limiter's overshoot margin can learn (capped
/// at 2 ms): a 60 FPS cap gave frames of 17-20 ms. Short steps keep the lateness to
/// the last step's.
fn park_in_steps(end: i64, mut now: impl FnMut() -> i64, mut sleep: impl FnMut(i64)) {
loop {
let left = end - now();
if left <= 0 {
return;
}
sleep(left.min(PARK_STEP_NS));
}
}
/// The real clock without busy waiting: where vanilla spins on
/// `Thread.onSpinWait` for the last half millisecond, this sleeps in short
/// steps, so a capped frame rate leaves the core idle.
@@ -218,6 +243,18 @@ pub fn end_frame(
}
}
/// [`end_frame`] on a fixed grid of frame slots ([`FramerateLimiter::limit_on_grid`]):
/// Horizon's Efficient pacing.
pub fn end_frame_on_grid(
limiter: &mut FramerateLimiter,
framerate_limit: i32,
clock: &mut impl LimiterClock,
) {
if framerate_limit < 260 {
limiter.limit_on_grid(framerate_limit, clock);
}
}
impl FramerateLimiter {
pub fn new(now_ns: i64) -> Self {
Self::with_state(now_ns, 0, 0)
@@ -248,6 +285,37 @@ impl FramerateLimiter {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
self.wait_until(target_time_ns, clock);
self.last_frame_time = clock.nanos();
}
/// Horizon's Efficient pacing: [`Self::limit_display_fps`]'s wait, but each frame's
/// slot starts where the last one's ended (its deadline), not when the thread woke
/// up. Vanilla's way adds every late wake-up to the frame (on the Mac, 50 us sleeps
/// running 0.2 ms late made a 144 FPS cap 139); on a fixed grid the cap holds on
/// average. A frame that ends past its slot, or wakes up more than half a frame
/// late, starts the grid again from now: no frame is rushed out to catch up.
pub fn limit_on_grid(&mut self, framerate_limit: i32, clock: &mut impl LimiterClock) {
let period = 1_000_000_000 / i64::from(framerate_limit);
if framerate_limit != self.last_framerate_limit {
self.average_overshoot_ns = 0;
self.last_framerate_limit = framerate_limit;
}
let deadline = self.last_frame_time + period;
if clock.nanos() >= deadline {
self.last_frame_time = clock.nanos();
return;
}
self.wait_until(deadline, clock);
let woke = clock.nanos();
self.last_frame_time = if woke - deadline > period / 2 {
woke
} else {
deadline
};
}
/// `limitDisplayFPS`'s wait: sleep to within the learned overshoot and half a
/// millisecond of `target_time_ns`, then spin.
fn wait_until(&mut self, target_time_ns: i64, clock: &mut impl LimiterClock) {
loop {
let remaining = target_time_ns - clock.nanos();
if remaining <= 0 {
@@ -271,7 +339,6 @@ impl FramerateLimiter {
clock.spin();
}
}
self.last_frame_time = clock.nanos();
}
pub fn last_frame_time(&self) -> i64 {
self.last_frame_time
@@ -678,6 +745,140 @@ mod tests {
assert_eq!(calculate_scale([1920, 1080], 3, true), 4);
assert_eq!(gui_scaled_size([1921, 1081], 4), [481, 271]);
}
/// A clock whose sleeps run late by a quarter of their length, as macOS's do,
/// sleeping as [`SystemClock::park`] does.
struct Coalescing {
now: std::cell::Cell<i64>,
}
impl LimiterClock for Coalescing {
fn nanos(&mut self) -> i64 {
self.now.get()
}
fn park(&mut self, nanos: i64) {
let now = &self.now;
park_in_steps(
now.get() + nanos,
|| now.get(),
|step| {
now.set(now.get() + step + step / 4);
},
);
}
fn spin(&mut self) {
self.now.set(self.now.get() + 1000);
}
}
#[test]
fn a_frame_cap_holds_when_sleeps_run_late() {
let mut clock = Coalescing {
now: std::cell::Cell::new(0),
};
let mut limiter = FramerateLimiter::new(0);
let mut last = 0;
for frame in 0..120 {
// The frame's own work.
clock.now.set(clock.now.get() + 2_000_000);
limiter.limit_display_fps(60, &mut clock);
let period = clock.now.get() - last;
last = clock.now.get();
assert!(
(16_666_666..16_666_666 + 100_000).contains(&period),
"frame {frame}: {period} ns"
);
}
}
/// A clock whose sleeps and spins each run up to `late` ns late (a different
/// share each time), as a loaded machine's do.
struct Late {
now: i64,
late: i64,
seed: u64,
}
impl Late {
fn new(late: i64) -> Self {
Self {
now: 0,
late,
seed: 0x9e37_79b9_7f4a_7c15,
}
}
fn lateness(&mut self) -> i64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
(self.seed % 1000) as i64 * self.late / 1000
}
}
impl LimiterClock for Late {
fn nanos(&mut self) -> i64 {
self.now
}
fn park(&mut self, nanos: i64) {
self.now += nanos + self.lateness();
}
fn spin(&mut self) {
self.now += 50_000 + self.lateness();
}
}
/// Frame periods of 144 FPS frames whose work takes `work(frame)` ns.
fn grid_periods(late: i64, work: impl Fn(usize) -> i64) -> Vec<i64> {
let mut clock = Late::new(late);
let mut limiter = FramerateLimiter::new(0);
let mut ends = Vec::new();
for frame in 0..300 {
clock.now += work(frame);
limiter.limit_on_grid(144, &mut clock);
ends.push(clock.now);
}
ends.windows(2).map(|w| w[1] - w[0]).collect()
}
#[test]
fn the_grid_holds_the_cap_when_wake_ups_run_late() {
let period = 1_000_000_000 / 144;
for late in [0, 50_000, 200_000, 1_000_000] {
let periods = grid_periods(late, |_| 2_000_000);
let mean = periods[10..].iter().sum::<i64>() / (periods.len() - 10) as i64;
assert!(
(mean - period).abs() < 10_000,
"{late} ns late: mean period {mean} ns"
);
// Each slot waits at least half a frame: the grid never rushes one out.
assert!(
periods.iter().all(|&p| p > period / 2),
"{late} ns late: {periods:?}"
);
}
// Vanilla's way adds the lateness to the frames: the cap isn't held.
let mut clock = Late::new(400_000);
let mut limiter = FramerateLimiter::new(0);
for _ in 0..300 {
clock.now += 2_000_000;
limiter.limit_display_fps(144, &mut clock);
}
assert!(
clock.now / 300 > period + 50_000,
"vanilla: {} ns",
clock.now / 300
);
}
#[test]
fn a_long_frame_starts_the_grid_again_without_a_burst() {
let period = 1_000_000_000 / 144;
// Frame 100 works 30 ms: four slots go by.
let periods = grid_periods(
50_000,
|frame| if frame == 100 { 30_000_000 } else { 2_000_000 },
);
assert!(periods[99] >= 30_000_000);
for (i, &p) in periods.iter().enumerate().skip(100) {
// On the grid a slot is a late wake-up (50 us at most) short or long.
assert!(
(period - 100_000..period + 100_000).contains(&p),
"frame {}: {p} ns",
i + 1
);
}
}
#[test]
fn limiter_paces_frames_at_the_target() {
let mut limiter = FramerateLimiter::new(0);
+115 -4
View File
@@ -828,6 +828,12 @@ pub struct Renderer {
/// Solid quads facing away from the eye are left out (`mesher::Sides`).
sides_on: bool,
sky: wgpu::RenderPipeline,
/// The main sky without the sunrise fan, which `sunrise_pipeline` draws as
/// geometry over it. The mirror keeps `sky`: its per-pixel fan is cheap at
/// the reflection's size and needs no second projection.
sky_base: wgpu::RenderPipeline,
sunrise_pipeline: wgpu::RenderPipeline,
sunrise_buffer: wgpu::Buffer,
dark_disc: wgpu::RenderPipeline,
/// Clears the world pass's depth to the far plane so the first-person hand draws in the
/// same pass (`hand_in_world`), as the hand's own pass would clear it.
@@ -2373,6 +2379,47 @@ impl Renderer {
false,
None,
);
let sky_base = pipeline(
"Sky base",
"vs_sky",
"fs_sky_base",
&[],
tri,
None,
false,
false,
None,
);
let sunrise_attributes = wgpu::vertex_attr_array![0=>Float32x4, 1=>Float32];
let sunrise_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: 20,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &sunrise_attributes,
})];
let sunrise_blend = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
// The sky's HDR alpha is the destination's; the fan only tints it.
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
};
let sunrise_pipeline = pipeline(
"Sunrise fan",
"vs_sunrise",
"fs_sunrise",
&sunrise_buffers,
tri,
None,
false,
false,
Some(sunrise_blend),
);
let dark_disc = pipeline(
"Dark disc",
"vs_sky",
@@ -3704,6 +3751,12 @@ impl Renderer {
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let sunrise_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Sunrise fan vertices"),
size: (sunrise_fan::VERTICES * 5 * std::mem::size_of::<f32>()) as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
@@ -3884,6 +3937,9 @@ impl Renderer {
cull,
flat,
sky,
sky_base,
sunrise_pipeline,
sunrise_buffer,
dark_disc,
depth_reset,
lines,
@@ -4817,11 +4873,13 @@ impl Renderer {
) -> impl Iterator<Item = (&'a ([i32; 3], u32), bool)> + 'a {
let fade = fade_millis(fade_seconds);
let now = Instant::now();
// No section uploaded within the fade: all are whole, none is looked up.
let all = !self.sections_fading_in(fade, now);
let min_y = self.layout.min_y();
self.block_entities
.iter()
.filter(move |(key, _)| {
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
all || chunk_visibility(self.section_elapsed_ms(**key, now), fade)
>= CHUNK_VISIBILITY_THRESHOLD
})
.filter_map(move |(key, blocks)| {
@@ -5089,8 +5147,8 @@ impl Renderer {
if self.detail_columns.remove(&(x, z)) == Some(complete) {
self.detail_revision += 1;
}
self.occlusion.remove_column(x, z);
}
self.occlusion.remove_columns(&gone);
let kept = |key: &SectionKey| !gone.contains(&(key.0, key.2));
let before = self.block_entities.len();
self.block_entities.retain(|key, _| kept(key));
@@ -6223,6 +6281,27 @@ impl Renderer {
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
// The sunrise fan as sixteen triangles. Its clip positions use the sky's
// own view (no TAA jitter, bob or spin: `fs_sky`'s `view_ray` has none),
// and its apex weight interpolates the barycentric the shader used to
// compute for every sky pixel.
if frame.sky.sunrise_color[3] > 0.001 && !frame.title && frame.skybox {
let sky_view_proj = views.primary().view_proj(Mat4::IDENTITY);
let mut mesh = [[0.0f32; 5]; sunrise_fan::VERTICES];
for (to, from) in mesh
.iter_mut()
.zip(sunrise_fan::mesh(frame.sky.sunrise_color[3], sun))
{
let clip = sky_view_proj * glam::Vec4::new(from[0], from[1], from[2], 1.0);
*to = [clip.x, clip.y, clip.z, clip.w, from[3]];
}
self.uploads.buffer(
&mut encoder,
&self.sunrise_buffer,
0,
bytemuck::cast_slice(&mesh),
);
}
laps.lap("r_targets");
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
// bob in its pose rather than the projection (same product).
@@ -6232,8 +6311,9 @@ impl Renderer {
self.config.height.max(1) as f32,
frame.depth_far,
));
let hand_view_proj = hand_projection * frame.bob * view;
let hand_globals = Globals {
view_proj: (hand_projection * frame.bob * view).to_cols_array_2d(),
view_proj: hand_view_proj.to_cols_array_2d(),
..globals
};
self.uploads.buffer(
@@ -7848,8 +7928,13 @@ impl Renderer {
{
Some((sky, native)) => sky.draw(&mut pass, native),
None => {
pass.set_pipeline(&self.sky);
pass.set_pipeline(&self.sky_base);
pass.draw(0..3, 0..1);
if frame.sky.sunrise_color[3] > 0.001 {
pass.set_pipeline(&self.sunrise_pipeline);
pass.set_vertex_buffer(0, self.sunrise_buffer.slice(..));
pass.draw(0..sunrise_fan::VERTICES as u32, 0..1);
}
}
}
}
@@ -8755,6 +8840,8 @@ impl Renderer {
first_crosshair: debug_depth + debug_top,
world_depth: false,
real: false,
hand_drawn: !pack_draws_hand,
hand_view_proj,
};
self.draw_overlay(&mut encoder, frame, &generated_view, generated, &overlay);
}
@@ -8797,6 +8884,8 @@ impl Renderer {
first_crosshair: debug_depth + debug_top,
world_depth: scaled.is_none(),
real: true,
hand_drawn: !pack_draws_hand,
hand_view_proj,
};
let readback =
self.draw_overlay(&mut encoder, frame, &view_target, &target_texture, &overlay);
@@ -9084,6 +9173,23 @@ impl Renderer {
}
}
}
// A native mod's turn after the hand, on the real frame only.
if overlay.real && !frame.title {
native_render::native_hand_drawn(&mut native_render::HandDrawn {
device: &self.device,
queue: &self.queue,
encoder: &mut *encoder,
color: target_texture,
color_view: view_target,
color_format: self.config.format.remove_srgb_suffix(),
size: [self.config.width, self.config.height],
hand: if overlay.hand_drawn { frame.hand } else { &[] },
view_proj: overlay.hand_view_proj,
camera: frame.camera,
atlas: self.hud.atlas_texture.as_ref(),
quad_indices: &self.indices,
});
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
encoder,
@@ -9621,6 +9727,11 @@ struct Overlay {
world_depth: bool,
/// The real frame, not frame generation's made one: the world icon grabs it.
real: bool,
/// The game drew the first-person hand, in its own pass or at the end of the world's
/// (`hand_in_world`), not a shader pack.
hand_drawn: bool,
/// Camera-relative world to clip space for the hand: its own field of view and the bob.
hand_view_proj: Mat4,
}
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
+46 -6
View File
@@ -506,12 +506,17 @@ impl Arena {
by_page[page].push((start, Self::span(vertices).min(self.page_limit)));
}
for (page, ranges) in by_page.into_iter().enumerate() {
if !ranges.is_empty() {
self.pages[page]
.as_mut()
.expect("live arena page")
.free
.give_all(ranges);
if ranges.is_empty() {
continue;
}
let page = self.pages[page].as_mut().expect("live arena page");
// Every range of the page given back (a far teleport empties the arena): one
// free range, without sorting tens of thousands into the list.
let given: u64 = ranges.iter().map(|r| r.1).sum();
if page.free_vertices() + given == page.capacity {
page.free = FreeList::new(page.capacity);
} else {
page.free.give_all(ranges);
}
}
}
@@ -746,6 +751,41 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
/// Released together, every range of a page leaves it one free range (a far teleport
/// empties the arena at once), while a page keeping some ranges frees only the others.
#[test]
fn ranges_released_at_once_free_exactly_their_vertices() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
let ranges: Vec<(u32, u64)> = (0..8)
.map(|_| alloc(&mut arena, &device, &queue, GRAIN).unwrap())
.collect();
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// All four of the first page, the middle two of the second.
let released: Vec<(u32, u64, u64)> = ranges
.iter()
.enumerate()
.filter(|&(i, _)| i < 4 || i == 5 || i == 6)
.map(|(_, &(page, start))| (page, start, GRAIN))
.collect();
arena.release_all(released);
let free = |page: u32| -> Vec<(u64, u64)> {
let free = &arena.pages[page as usize].as_ref().unwrap().free.0;
free.iter().map(|(&start, &len)| (start, len)).collect()
};
assert_eq!(free(first), vec![(0, 4 * GRAIN)]);
assert_eq!(free(second), vec![(ranges[5].1, 2 * GRAIN)]);
// The emptied page takes a whole page's range again, without growing.
let pages = arena.pages.iter().flatten().count();
assert_eq!(arena.alloc_existing(4 * GRAIN), Some((first, 0)));
assert_eq!(arena.pages.iter().flatten().count(), pages);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
@@ -12,7 +12,7 @@ use std::sync::{
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use generated::{HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
@@ -40,8 +40,8 @@ pub fn world_drawn_hooked() -> bool {
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// particles, weather and the world post pass, before the first-person hand ([`HandDrawn`]),
/// the HUD and the screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
@@ -76,6 +76,35 @@ pub struct WorldDrawn<'a> {
pub quad_indices: &'a wgpu::Buffer,
}
/// What a mod can draw with once the first-person hand is drawn ([`native_hand_drawn`]): after
/// the world and the hand, before entity outlines, post effects, screen effects and the HUD.
/// `color` holds the frame's picture with the hand in it (copy it out before reading it).
#[non_exhaustive]
pub struct HandDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
pub encoder: &'a mut wgpu::CommandEncoder,
/// The frame's target and a view of it in `color_format`.
pub color: &'a wgpu::Texture,
pub color_view: &'a wgpu::TextureView,
pub color_format: wgpu::TextureFormat,
/// The size of `color`.
pub size: [u32; 2],
/// The hand and held item just drawn, camera-relative quads of 4 vertices: empty when no
/// hand drew (third person, F1, a sleeping or spectating player) or a shader pack drew it.
pub hand: &'a [Vertex],
/// Camera-relative world to clip space for the hand: its own field of view
/// (`hand_fov_degrees`) and the view bob, without TAA's jitter (the hand has none). Its
/// depth is the hand's own: the hand's pass clears depth and keeps none.
pub view_proj: Mat4,
/// The camera's position in the world.
pub camera: DVec3,
/// The shared atlas the hand's UVs point into.
pub atlas: Option<&'a wgpu::Texture>,
/// Quad indices (0 1 2 0 2 3 for each 4 vertices).
pub quad_indices: &'a wgpu::Buffer,
}
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -94,6 +123,13 @@ pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
#[inline(never)]
pub fn native_hand_drawn(hand: &mut HandDrawn<'_>) {
generated::dispatch_hand_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), hand);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
@@ -11,6 +11,11 @@ pub trait RenderHooks: Send + Sync {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API @API:hand_drawn@.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
@@ -34,3 +39,14 @@ pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds)
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
@@ -13,6 +13,7 @@
//! frame has (vanilla's partial update), so what it opens shows at once; what
//! it closes goes at the next full search.
use super::SectionKey;
use crate::fast_hash::FxHashSet;
use crate::mesher::ALL_VISIBLE;
use glam::DVec3;
use std::collections::{HashMap, VecDeque};
@@ -589,6 +590,19 @@ impl Occlusion {
}
}
}
/// `remove_column` for each of `gone`. When they are every column it holds (a
/// teleport drops the whole view), the old graph says nothing about what arrives
/// next: it starts over as `clear` does instead of seeing through each removed
/// section one edit at a time (some 90 000 at render distance 32).
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) {
if self.columns.map.keys().all(|column| gone.contains(column)) {
self.clear();
return;
}
for &(x, z) in gone {
self.remove_column(x, z);
}
}
pub fn clear(&mut self) {
self.columns.map.clear();
self.edits.clear();
@@ -1313,6 +1327,46 @@ mod tests {
println!("search_cost: best {best:.2} ms over 12, {reached} reached");
}
/// Removed columns see through every face: the graph is the one of a world that
/// never had them, whether some columns go or all of them (a teleport).
#[test]
fn removed_columns_leave_the_graph_of_a_world_without_them() {
fn without(o: &Occlusion, gone: &FxHashSet<(i32, i32)>) -> Occlusion {
let mut fresh = Occlusion::new(0, 6);
for (&(x, z), column) in &o.columns.map {
if !gone.contains(&(x, z)) {
for (y, &vis) in column.iter().enumerate() {
fresh.set((x, y as i32, z), vis);
}
}
}
fresh
}
let camera = at((0, 2, 0));
let mut o = Occlusion::new(0, 6);
random_world(&mut o, 0x51ed_2701, 900, 6);
o.settle(camera, 6);
let some: FxHashSet<(i32, i32)> = (-6..=6)
.flat_map(|x| (-1..=1).map(move |z| (x, z)))
.collect();
let mut expected = without(&o, &some);
o.remove_columns(&some);
o.settle(camera, 6);
expected.settle(camera, 6);
let graph = |o: &Occlusion| o.graph.as_ref().expect("searched").directions.clone();
assert_eq!(graph(&o), graph(&expected));
let all: FxHashSet<(i32, i32)> = o.columns.map.keys().copied().collect();
o.remove_columns(&all);
assert!(o.contains((40, 3, 40)), "no graph from the old view culls");
random_world(&mut o, 0x2545_f491, 900, 6);
let mut arrived = Occlusion::new(0, 6);
random_world(&mut arrived, 0x2545_f491, 900, 6);
o.settle(camera, 6);
arrived.settle(camera, 6);
assert_eq!(graph(&o), graph(&arrived));
}
/// The worker's graph is the one the render thread would compute.
#[test]
fn the_worker_matches_a_search_on_the_render_thread() {
@@ -147,6 +147,17 @@ impl<T> Resident<T> {
/// Removes every section of the `gone` columns, the others keeping their order: one
/// pass over the store, where `remove` moves a slot and looks its column up for each.
pub fn remove_columns(&mut self, gone: &FxHashSet<(i32, i32)>) -> Vec<T> {
// Every column (a far teleport): emptied, without a lookup per section.
if self.columns.iter().all(|c| gone.contains(&(c.x, c.z))) {
self.bounds.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
return std::mem::take(&mut self.items)
.into_iter()
.map(|(_, value)| value)
.collect();
}
let items = std::mem::take(&mut self.items);
let bounds = std::mem::take(&mut self.bounds);
self.slots.clear();
@@ -533,7 +544,18 @@ mod tests {
((seed >> 4) % 6) as i32 - 1,
((seed >> 8) % 5) as i32 - 2,
);
if (seed >> 12).is_multiple_of(97) {
if (seed >> 12).is_multiple_of(389) {
// Every column at once (a far teleport), and one that holds nothing.
let gone: FxHashSet<(i32, i32)> = (-2..=3)
.flat_map(|x| (-2..=2).map(move |z| (x, z)))
.collect();
let mut removed = r.remove_columns(&gone);
let mut expected: Vec<u32> = model.drain().map(|(_, v)| v).collect();
removed.sort_unstable();
expected.sort_unstable();
assert_eq!(removed, expected);
assert!(r.columns.is_empty() && r.column_index.is_empty());
} else if (seed >> 12).is_multiple_of(97) {
// Whole columns at once: the column of `key` and the next one along x.
let gone: FxHashSet<(i32, i32)> =
[(key.0, key.2), (key.0 + 1, key.2)].into_iter().collect();
@@ -15,6 +15,11 @@ use glam::Vec3;
pub const TRIANGLES: usize = 16;
/// One vertex per triangle corner, each as `(position, weight)`: the apex
/// weighs 1, the rim 0, so the rasterizer interpolates the barycentric apex
/// weight the shader used to compute per pixel.
pub const VERTICES: usize = TRIANGLES * 3;
/// Per triangle: xyz `r0 × r1` (its dot with a ray is the apex's weight
/// times S), w `[A, r0, r1]` (its sign is S's where the hit lies ahead).
/// Then per rim vertex: xyz `r × A`.
@@ -29,6 +34,19 @@ pub fn normals(alpha: f32, sun: Vec3) -> [[f32; 4]; 2 * TRIANGLES] {
out
}
/// The fan as triangle-list vertices in camera space, with the apex at
/// weight 1 and each rim vertex at 0 (`VERTICES`).
pub fn mesh(alpha: f32, sun: Vec3) -> [[f32; 4]; VERTICES] {
let (apex, rim) = vertices(alpha, sun);
let mut out = [[0.0; 4]; VERTICES];
for i in 0..TRIANGLES {
out[i * 3] = apex.extend(1.0).to_array();
out[i * 3 + 1] = rim[i].extend(0.0).to_array();
out[i * 3 + 2] = rim[(i + 1) % TRIANGLES].extend(0.0).to_array();
}
out
}
/// The fan's apex and rim in camera space, as vanilla builds them: the
/// fan turned to the horizon the sun sets on (or rises from), its rim
/// tilted by the alpha.
@@ -208,6 +226,21 @@ mod tests {
best
}
#[test]
fn mesh_puts_the_apex_weight_on_the_apex_of_every_triangle() {
let (apex, rim) = vertices(0.7, Vec3::new(0.0, 0.6, -0.8));
let mesh = mesh(0.7, Vec3::new(0.0, 0.6, -0.8));
assert_eq!(mesh.len(), VERTICES);
for i in 0..TRIANGLES {
assert_eq!(&mesh[i * 3][..3], apex.to_array());
assert_eq!(mesh[i * 3][3], 1.0);
assert_eq!(&mesh[i * 3 + 1][..3], rim[i].to_array());
assert_eq!(mesh[i * 3 + 1][3], 0.0);
assert_eq!(&mesh[i * 3 + 2][..3], rim[(i + 1) % TRIANGLES].to_array());
assert_eq!(mesh[i * 3 + 2][3], 0.0);
}
}
#[test]
fn normals_weigh_every_ray_as_the_per_pixel_fan_did() {
let mut hits = 0;
+10 -10
View File
@@ -245,7 +245,8 @@ impl GraphicsApi {
/// What the frame limiter does in the last half millisecond before a frame.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Pacing {
/// Sleep the whole wait: no CPU spent spinning, a little less precise.
/// Sleep the whole wait: no CPU spent spinning, a little less precise; frames
/// keep to a fixed grid of slots, so late wake-ups don't lower the frame rate.
#[default]
Efficient,
/// Vanilla's `FramerateLimiter`: sleep, then spin until the deadline.
@@ -933,10 +934,7 @@ impl Graphics {
// and render threads are raised above four cores, where vanilla
// raises its server thread). Leaving two idled a core of a 4-thread
// laptop while a world loaded: a teleport there took ~15% longer.
let auto = std::thread::available_parallelism()
.map_or(2, |n| n.get())
.saturating_sub(1)
.max(1);
let auto = horizon_threads::worker_count().saturating_sub(1).max(1);
if self.battery { auto.div_ceil(2) } else { auto }
}
/// The frame cap battery mode adds: 60 in a world, 30 in menus without a
@@ -1233,10 +1231,12 @@ mod tests {
assert_eq!(g.lod_distance_on(&discrete), distance);
}
assert!(
crate::graphics_ui::rows(crate::graphics_ui::Level::Simple)
.iter()
.any(|row| matches!(row, crate::graphics_ui::Row::Setting(s)
if s.text.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks")))),
crate::graphics_ui::rows()
.into_iter()
.flat_map(|(_, groups)| groups.into_iter().flat_map(|(_, rows)| rows))
.any(|r| r
.text
.contains(&format!("{INTEGRATED_LOD_DISTANCE} chunks"))),
"the LOD Distance description names the cap"
);
}
@@ -1294,7 +1294,7 @@ mod tests {
fn automatic_workers_leave_a_core_as_vanilla_does() {
let g = Graphics::default();
assert_eq!(g.workers, 0);
let cores = std::thread::available_parallelism().map_or(2, |n| n.get());
let cores = horizon_threads::worker_count();
assert_eq!(g.worker_threads(), cores.saturating_sub(1).max(1));
// A manual count is the player's own choice, kept as asked.
let manual = Graphics { workers: 3, ..g };
+238 -209
View File
@@ -1,14 +1,19 @@
//! The look of Horizon's graphics settings screens, in one place: sizes,
//! colours and the widgets they draw with. The screens (`graphics_ui`) only
//! lay out rows and handle input; a menu theme replaces this module.
//! The look of Horizon's graphics settings screen, in one place: sizes,
//! colours and the controls it draws with. The screen (`graphics_ui`) only
//! lays out rows and handles input; a menu theme replaces this module.
//!
//! It takes the design system's Blocks palette (`horizon_theme::BLOCKS_DARK`,
//! `docs/design-system.md`) in a lighter form: square panels of dark glass
//! with a thin edge, so the world shows through, flat controls, and the violet
//! face on the one main button, in vanilla's font.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
use horizon_theme::BLOCKS_DARK as B;
/// What a setting costs in frame time, shown beside its description.
/// What a setting costs in frame time, shown with its description.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Cost {
None,
@@ -28,217 +33,241 @@ impl Cost {
Cost::Saves => "Saves time",
}
}
}
pub struct Style {
/// Width of a setting's button or slider.
pub control_width: f32,
/// Space between the control and its description.
pub gap: f32,
/// Widest description column; narrower on small screens.
pub max_text_width: f32,
/// Vertical distance between setting rows.
pub row_height: f32,
pub header_height: f32,
/// Top and bottom bands holding the title and the buttons.
pub band: f32,
pub title_color: u32,
pub header_color: u32,
pub description_color: u32,
/// Colour of each `Cost`, in its order.
pub cost_colors: [u32; 5],
}
/// `hud::rgba` with full alpha, usable in constants.
const fn opaque(r: u8, g: u8, b: u8) -> u32 {
0xFF00_0000 | (b as u32) << 16 | (g as u32) << 8 | r as u32
}
pub const STYLE: Style = Style {
control_width: 150.0,
gap: 8.0,
max_text_width: 250.0,
row_height: 25.0,
header_height: 16.0,
band: 33.0,
title_color: u32::MAX,
header_color: opaque(255, 255, 85),
description_color: opaque(170, 170, 170),
cost_colors: [
opaque(120, 200, 120),
opaque(170, 220, 120),
opaque(240, 200, 90),
opaque(240, 110, 90),
opaque(85, 255, 85),
],
};
impl Style {
/// Width of the description column on a screen `screen_width` wide.
pub fn text_width(&self, screen_width: f32) -> f32 {
(screen_width - self.control_width - self.gap - 24.0).clamp(120.0, self.max_text_width)
}
/// Left edge of a row's control, centring control and description.
pub fn row_x(&self, screen_width: f32) -> f32 {
let total = self.control_width + self.gap + self.text_width(screen_width);
((screen_width - total) / 2.0).floor()
}
pub fn background(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
ui::list_background(batch, atlas, top, bottom, in_world);
}
pub fn frame(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
top: f32,
bottom: f32,
in_world: bool,
) {
ui::separators(batch, atlas, top, bottom, in_world);
}
pub fn title(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, self.title_color);
}
pub fn header(&self, batch: &mut HudBatch, atlas: &HudAtlas, text: &Text, x: f32, y: f32) {
batch.text_styled(atlas, text, x, y + 4.0, self.header_color, true);
}
pub fn button(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
hover: bool,
active: bool,
) {
ui::button(batch, atlas, x, y, w, 20.0, label, active, hover);
}
pub fn slider(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
active: bool,
dragging: bool,
) {
ui::slider(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
active,
dragging && active,
);
}
pub fn slider_ext(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
(x, y, w): (f32, f32, f32),
label: &Text,
value: f32,
hover: bool,
focused: bool,
can_change_value: bool,
active: bool,
dragging: bool,
) {
ui::slider_ext(
batch,
atlas,
x,
y,
w,
label,
value,
hover && active,
focused,
can_change_value,
active,
dragging && active,
);
}
/// The cost tag and the description beside a control, wrapped to two lines.
pub fn description(
&self,
batch: &mut HudBatch,
atlas: &HudAtlas,
x: f32,
y: f32,
cost: Cost,
text: &str,
) {
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
atlas,
&Text::literal(line.clone()),
lx,
y + i as f32 * 10.0,
self.description_color,
true,
);
/// `0xRRGGBB`: the palette's success, warn and danger.
pub fn rgb(self) -> u32 {
match self {
Cost::None => B.ink_soft,
Cost::Low | Cost::Saves => B.success,
Cost::Medium => B.warn,
Cost::High => B.danger,
}
}
}
/// Greedy word wrap; the first line is `indent` narrower.
fn wrap(atlas: &HudAtlas, text: &str, width: f32, indent: f32) -> Vec<String> {
let mut lines = vec![String::new()];
for word in text.split_whitespace() {
let room = if lines.len() == 1 {
width - indent
} else {
width
};
let line = lines.last().expect("one line");
let candidate = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if atlas.font.width(&candidate) <= room || line.is_empty() {
*lines.last_mut().expect("one line") = candidate;
} else {
lines.push(word.to_owned());
}
}
lines
/// `0xRRGGBB` and an alpha in the HUD's colour encoding.
const fn hex(c: u32, a: u8) -> u32 {
(a as u32) << 24 | (c & 0xff) << 16 | (c >> 8 & 0xff) << 8 | (c >> 16 & 0xff)
}
/// The plate and the strip: the page's dark, see-through, so the world shows behind them.
const GLASS: u32 = hex(B.page, 118);
/// Their 1 px edge.
const EDGE: u32 = hex(B.ink, 26);
/// A group's panel, a little lighter than the plate it sits on.
const GROUP: u32 = hex(B.ink, 11);
/// A hovered or focused row, lit on its group's panel.
pub const HOVER: u32 = hex(B.ink, 18);
/// The open page in the page list.
pub const OPEN: u32 = hex(B.ink, 26);
/// The rule under the plate's heading, and between two rows of a group.
pub const HEAD_RULE: u32 = hex(B.ink, 31);
pub const ROW_RULE: u32 = hex(B.ink, 15);
/// Fields, boxes and the value stepper: sunk, see-through.
const WELL: u32 = hex(B.outline, 102);
/// A button, a slider's track, the scroller's thumb.
const BUTTON: u32 = hex(B.ink, 20);
const BUTTON_HOVER: u32 = hex(B.ink, 41);
const TRACK: u32 = hex(B.ink, 64);
const HANDLE: u32 = hex(B.ink, 255);
/// The one main button.
const FACE: u32 = hex(B.face, 255);
const FACE_HOVER: u32 = hex(0x6a50e6, 255);
/// A tick box's edge when off.
const BOX_EDGE: u32 = hex(B.ink_soft, 89);
const OUTLINE: u32 = hex(B.outline, 255);
pub const ACCENT: u32 = hex(B.accent, 255);
/// Vanilla's white text, and its grey for what can't be used now.
pub const WHITE: u32 = u32::MAX;
pub const MUTED: u32 = hex(B.muted, 255);
/// A page that isn't open, a value at rest.
pub const SOFT: u32 = hex(B.ink_soft, 255);
/// Vanilla's grey for a tooltip's lines under its first (`ChatFormatting.GRAY`).
pub const GREY: u32 = hex(0xaaaaaa, 255);
/// A light veil over the backdrop, under the panels.
pub const VEIL: u32 = hex(B.page, 77);
#[cfg(test)]
mod tests {
use super::*;
/// Sizes in GUI pixels.
/// Shorter screens than this (a 720p window at scale 3) keep a narrower margin on top.
pub const TITLED: f32 = 300.0;
pub const ROW: f32 = 20.0;
/// A group's name over its panel, and a page's over its groups while searching.
pub const GROUP_ROW: f32 = 14.0;
pub const PAGE_ROW: f32 = 16.0;
/// Space under a group's panel, and more before the next page's name.
pub const GROUP_GAP: f32 = 10.0;
pub const PAGE_GAP: f32 = 6.0;
/// The control on the right of a row (a button, a box or a slider), and the
/// left column: the search field, the page list and the buttons.
pub const CONTROL_W: f32 = 112.0;
pub const CONTROL_H: f32 = 16.0;
pub const SEARCH_H: f32 = 16.0;
/// Vanilla's scroller: the column `ui::Scroll::press` takes.
pub const SCROLLER_W: f32 = 6.0;
/// The strip under the plate that describes a setting.
pub const DOCK_H: f32 = 50.0;
pub const BUTTON_H: f32 = 20.0;
/// A row's name and control, from its group panel's edges.
pub const INSET: f32 = 10.0;
#[test]
fn every_cost_has_a_label_and_a_colour() {
let all = [Cost::None, Cost::Low, Cost::Medium, Cost::High, Cost::Saves];
assert_eq!(all.len(), STYLE.cost_colors.len());
for (i, cost) in all.into_iter().enumerate() {
assert_eq!(cost as usize, i);
assert!(!cost.label().is_empty());
pub fn fill(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, h, color);
}
/// A 1 px ring inside `rect`: the keyboard or controller focus, in white as vanilla draws it.
pub fn ring(batch: &mut HudBatch, atlas: &HudAtlas, [x, y, w, h]: [f32; 4], color: u32) {
batch.fill(atlas, x, y, w, 1.0, color);
batch.fill(atlas, x, y + h - 1.0, w, 1.0, color);
batch.fill(atlas, x, y + 1.0, 1.0, h - 2.0, color);
batch.fill(atlas, x + w - 1.0, y + 1.0, 1.0, h - 2.0, color);
}
/// The screen's plate, and the strip under it: dark glass with a thin edge.
pub fn panel(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GLASS);
ring(batch, atlas, rect, EDGE);
}
/// A group's panel on the plate, its rows on it.
pub fn group(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, GROUP);
}
/// A sunk well: the search field, the value stepper.
pub fn well(batch: &mut HudBatch, atlas: &HudAtlas, rect: [f32; 4]) {
fill(batch, atlas, rect, WELL);
}
/// The scroller: a faint track and its thumb.
pub fn scroller(batch: &mut HudBatch, atlas: &HudAtlas, track: [f32; 4], thumb: [f32; 4]) {
fill(batch, atlas, track, ROW_RULE);
fill(batch, atlas, thumb, TRACK);
}
/// `text` cut to `width` with an ellipsis, as a long name gives way to its control.
pub fn fit(atlas: &HudAtlas, text: &str, width: f32) -> String {
if atlas.font.width(text) <= width {
return text.to_owned();
}
let mut out = String::new();
for c in text.chars() {
if atlas.font.width(&format!("{out}{c}...")) > width {
break;
}
out.push(c);
}
format!("{}...", out.trim_end())
}
/// A 12 px tick box ending at `right`, centred in the row at `y`: its edge in the accent when on.
pub fn checkbox(batch: &mut HudBatch, atlas: &HudAtlas, right: f32, y: f32, on: bool, live: bool) {
let rect = [right - 12.0, y + 4.0, 12.0, 12.0];
fill(batch, atlas, rect, WELL);
let edge = match (on, live) {
(true, true) => ACCENT,
(true, false) => MUTED,
(false, _) => BOX_EDGE,
};
ring(batch, atlas, rect, edge);
if on {
tick(batch, atlas, rect[0] + 1.0, rect[1] + 2.0, edge);
}
}
/// A pixel tick, 10 by 8, over its own shadow.
fn tick(batch: &mut HudBatch, atlas: &HudAtlas, x: f32, y: f32, color: u32) {
const TICK: [&str; 8] = [
" #",
" ##",
" ## ",
"# ## ",
"## ## ",
" ## ## ",
" ### ",
" # ",
];
for (j, row) in TICK.iter().enumerate() {
for (i, c) in row.chars().enumerate() {
if c == '#' {
let (px, py) = (x + i as f32, y + j as f32);
fill(batch, atlas, [px + 1.0, py + 1.0, 1.0, 1.0], OUTLINE);
fill(batch, atlas, [px, py, 1.0, 1.0], color);
}
}
}
}
/// A flat button with its label centred: Undo, Calibrate..., and Done in the
/// violet (`primary`). Pointed at or focused, vanilla's white edge.
pub fn button(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
label: &Text,
(hover, live, primary): (bool, bool, bool),
) {
let [x, y, w, h] = rect;
let face = match (live, primary, hover) {
(false, _, _) => WELL,
(true, true, false) => FACE,
(true, true, true) => FACE_HOVER,
(true, false, false) => BUTTON,
(true, false, true) => BUTTON_HOVER,
};
fill(batch, atlas, rect, face);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let color = if live { WHITE } else { MUTED };
let ty = y + ((h - 8.0) / 2.0).floor();
ui::centered_text(batch, atlas, label, x + 2.0, ty, w - 4.0, color);
}
/// A slider: its value over a thin track filled in the accent up to the handle at `position`.
pub fn slider(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
position: f32,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
let color = if live { WHITE } else { MUTED };
ui::centered_text(batch, atlas, value, x + 2.0, y + 1.0, w - 4.0, color);
let line = y + h - 3.0;
let at = x + (position.clamp(0.0, 1.0) * (w - 4.0)).round();
fill(batch, atlas, [x, line, w, 2.0], TRACK);
fill(
batch,
atlas,
[x, line, at - x, 2.0],
if live { ACCENT } else { MUTED },
);
let handle = [at, line - 3.0, 4.0, 8.0];
fill(batch, atlas, handle, if live { HANDLE } else { MUTED });
if hover && live {
ring(batch, atlas, [at - 1.0, line - 4.0, 6.0, 10.0], ACCENT);
}
}
/// A value that a press steps through: in a well, between the arrows that say so.
pub fn cycler(
batch: &mut HudBatch,
atlas: &HudAtlas,
rect: [f32; 4],
value: &Text,
(hover, live): (bool, bool),
) {
let [x, y, w, h] = rect;
well(batch, atlas, rect);
if hover && live {
ring(batch, atlas, rect, WHITE);
}
let ty = y + ((h - 8.0) / 2.0).floor();
let arrows = if hover && live { WHITE } else { MUTED };
batch.text_styled(atlas, &Text::literal("<"), x + 3.0, ty, arrows, true);
batch.text_styled(atlas, &Text::literal(">"), x + w - 8.0, ty, arrows, true);
let color = match (live, hover) {
(false, _) => MUTED,
(true, true) => WHITE,
(true, false) => SOFT,
};
batch.text_styled(atlas, value, x + 9.0, ty, color, true);
}
/// A screen's title, centred at the top (split-screen profiles).
pub fn title(batch: &mut HudBatch, atlas: &HudAtlas, text: &Text) {
let w = batch.width;
ui::centered_text(batch, atlas, text, 0.0, 12.0, w, u32::MAX);
}
/// The cost's colour in the HUD's encoding: the square beside its name.
pub fn cost_color(cost: Cost) -> u32 {
hex(cost.rgb(), 255)
}
File diff suppressed because it is too large. Load diff
@@ -1,4 +1,4 @@
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
//! HDR calibration (the graphics screen's HDR page, Calibrate...): full screen, one step at a time, the way
//! display calibration tools do it. HDR output draws each step's test picture straight in
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
//! shapes the game's picture, so what the player sees is the display alone:
+12 -4
View File
@@ -278,10 +278,18 @@ impl Column {
/// The water is one span again.
fn light_open_water(&mut self, kinds: &[u8]) {
// Water; lava glows.
let water = |span: &Span| {
let kind = kinds.get(span.state.0 as usize).copied().unwrap_or(0);
self.light_open_water_where(|state| {
let kind = kinds.get(state.0 as usize).copied().unwrap_or(0);
kind & FLUID != 0 && kind >> 4 == 0
};
});
}
/// [`Column::light_open_water`] for a caller that knows which states are
/// water without `BlockFacts`: the save decoder, whose palette already
/// names water apart from lava. Saved columns skipping this lit their
/// water a level darker per block, which drew a band through the sea
/// where they met the generator's columns.
pub fn light_open_water_where(&mut self, is_water: impl Fn(BlockStateId) -> bool) {
let water = |span: &Span| is_water(span.state);
if !self.spans.last().is_some_and(water) {
return;
}
@@ -1114,7 +1122,7 @@ impl Lod {
let keeps_observed = source
.as_ref()
.is_some_and(|source| source.keeps_observed());
let cores = thread::available_parallelism().map_or(1, |n| n.get());
let cores = horizon_threads::worker_count();
let shared = Arc::new(Shared {
jobs: Mutex::new(VecDeque::new()),
mesh_jobs: Mutex::new(std::collections::BinaryHeap::new()),
+75 -112
View File
@@ -2032,6 +2032,9 @@ impl OptionsUi {
.pack_screen
.as_ref()
.is_some_and(|p| p.search_focused()),
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => {
self.graphics.typing()
}
_ => self.settings_search_active(),
}
}
@@ -2136,11 +2139,8 @@ impl OptionsUi {
if page == Page::Packs {
self.pack_screen = None;
}
if matches!(
page,
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr
) {
self.graphics.reset();
if let Some(at) = Self::graphics_page(page) {
self.graphics.open(at);
}
if page == Page::SplitSetup {
self.split_setup.reset();
@@ -2149,12 +2149,35 @@ impl OptionsUi {
self.split_profiles.reset();
}
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
/// What the graphics screen's actions do to these options.
fn graphics_events(
&mut self,
s: &mut Settings,
actions: Vec<crate::graphics_ui::Action>,
) -> Vec<Event> {
use crate::graphics_ui::Action;
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
events
}
/// Where the graphics screen opens for `page`, if it is one of its pages.
fn graphics_page(page: Page) -> Option<crate::graphics_ui::Page> {
match page {
Page::Graphics => Some(crate::graphics_ui::Level::Simple),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Level::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Level::Hdr),
Page::Graphics => Some(crate::graphics_ui::Page::General),
Page::GraphicsAdvanced => Some(crate::graphics_ui::Page::Advanced),
Page::GraphicsHdr => Some(crate::graphics_ui::Page::Hdr),
_ => None,
}
}
@@ -2333,7 +2356,7 @@ impl OptionsUi {
}
Page::Horizon => {
// Two to a row, as vanilla's OptionsList.addSmall lays out
// buttons; each opens a page built from vanilla's widgets.
// buttons; each opens one of Horizon's settings pages.
let open =
|key: &str, page: Page| button(0.0, 0.0, 150.0, tr(key), Act::Push(page));
vec![
@@ -2420,7 +2443,7 @@ impl OptionsUi {
rows
}
Page::Video => {
// Horizon's graphics screens, Simple and Advanced.
// Horizon's graphics settings screen, at General and at Advanced.
let mut rows = vec![Row::Buttons(
button(
0.0,
@@ -3388,10 +3411,9 @@ impl OptionsUi {
c.draw(batch, atlas, cursor);
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
if Self::graphics_page(self.page()).is_some() {
self.graphics.binding = self.selected_key;
self.graphics
.draw(level, s, batch, atlas, cursor, self.in_world);
self.graphics.draw(s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
@@ -3753,8 +3775,7 @@ impl OptionsUi {
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
// Waiting on a key: a mouse button binds, as on Key Binds.
if self.selected_key.is_some() && !drag {
let button = if right {
@@ -3764,36 +3785,10 @@ impl OptionsUi {
};
return self.bind_mouse(s, button);
}
let mut events = Vec::new();
for action in self
let actions = self
.graphics
.click(level, s, (x, y), size, framebuffer, drag, right)
{
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
.click(s, (x, y), size, framebuffer, drag, right);
return self.graphics_events(s, actions);
}
if self.page() == Page::Packs {
let mut events = Vec::new();
@@ -4201,6 +4196,7 @@ impl OptionsUi {
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
/// The graphics screen has its own wheel (`GraphicsUi::wheel`).
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4211,38 +4207,9 @@ impl OptionsUi {
) -> Vec<Event> {
self.size = size;
self.framebuffer = framebuffer;
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
let actions = self
.graphics
.wheel(level, s, cursor, size, framebuffer, amount);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
return events;
if Self::graphics_page(self.page()).is_some() {
let actions = self.graphics.wheel(s, size, cursor, framebuffer, amount);
return self.graphics_events(s, actions);
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
@@ -4322,8 +4289,8 @@ impl OptionsUi {
if self.resource_error.is_some() {
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.scroll(level, s, size.1, amount);
if Self::graphics_page(self.page()).is_some() {
self.graphics.scroll(s, size, amount);
return;
}
match &mut self.pack_screen {
@@ -4394,7 +4361,7 @@ impl OptionsUi {
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
/// set before; both go back to Graphics: HDR.
/// set before; both go back to the graphics screen's HDR page.
fn calibration_outcome(
&mut self,
s: &mut Settings,
@@ -4505,6 +4472,8 @@ impl OptionsUi {
self.scroll = 0.0;
} else if self.page() == Page::SplitProfiles {
self.split_profiles.type_text(text);
} else if Self::graphics_page(self.page()).is_some() && self.graphics.typing() {
self.graphics.type_text(text);
} else if self.settings_search_active() {
self.settings_search.insert_text(text);
self.scroll = 0.0;
@@ -4536,6 +4505,9 @@ impl OptionsUi {
if self.page() == Page::SplitProfiles {
return self.split_profiles.shortcut(shortcut, clipboard);
}
if Self::graphics_page(self.page()).is_some() {
return self.graphics.shortcut(shortcut, clipboard);
}
if self.settings_search_active() {
if self.settings_search.shortcut(shortcut, clipboard) {
self.scroll = 0.0;
@@ -4646,37 +4618,11 @@ impl OptionsUi {
return Vec::new();
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
if Self::graphics_page(self.page()).is_some() {
let size = (self.size.0.max(480.0), self.size.1.max(240.0));
let fb = [self.framebuffer[0].max(480), self.framebuffer[1].max(240)];
let (actions, handled) = self.graphics.key_modifiers(level, s, key, shift, size, fb);
let mut events = Vec::new();
for action in actions {
match action {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::SliderDrag => events.push(Event::SliderDrag),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
{
events.extend(self.back(s));
} else {
self.stack.pop();
self.push(Page::Graphics);
}
}
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::Done => events.extend(self.back(s)),
}
}
let (actions, handled) = self.graphics.key_modifiers(s, key, text, shift, size, fb);
let mut events = self.graphics_events(s, actions);
if key == K::Escape {
events.extend(self.back(s));
return events;
@@ -7191,6 +7137,22 @@ mod tests {
assert!(!events.iter().any(|e| matches!(e, Event::Volume(0, _))));
}
#[test]
fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
use crate::graphics_ui::{H, Item, Target};
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Graphics);
let size = (640.0, 360.0);
s.horizon.chunk_updates = 16;
ui.graphics.focus = Some(Target::Item(Item::Horizon(H::ChunkUpdates)));
ui.graphics.can_change_value = true;
let before = ui.graphics.scroll_amount();
ui.wheel(&mut s, size, (320.0, 150.0), [1920, 1080], -1.0);
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
#[test]
fn horizon_pages_slider_and_wheel_parity() {
use winit::keyboard::KeyCode as K;
@@ -7223,13 +7185,14 @@ mod tests {
// 2. Horizon Graphics page through OptionsUi:
// Tab onto ChunkUpdates slider, ArrowRight adjusts value, Space toggles can_change_value
ui.show(Page::GraphicsAdvanced);
ui.show(Page::Graphics);
ui.graphics.open(crate::graphics_ui::Page::Performance);
s.horizon.chunk_updates = 10;
ui.size = size;
ui.framebuffer = [854, 480];
// Tab into controls
for _ in 0..15 {
for _ in 0..80 {
ui.key(&mut s, &atlas, K::Tab, None);
if ui.graphics.focus
== Some(crate::graphics_ui::Target::Item(
+1 -3
View File
@@ -7,9 +7,7 @@ use std::sync::atomic::{AtomicUsize, Ordering};
/// Threads take the next item as they finish one, so a run of slow items
/// doesn't hold back the others.
pub fn par_map<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> R + Sync) -> Vec<R> {
let threads = std::thread::available_parallelism()
.map_or(1, |n| n.get())
.min(items.len());
let threads = horizon_threads::worker_count().min(items.len());
if threads <= 1 {
return items.iter().map(f).collect();
}
+58 -1
View File
@@ -1098,6 +1098,43 @@ pub struct ParticleEngine {
const MAX_PARTICLES: usize = 16384;
/// `ParticleLimit.SPORE_BLOSSOM`, the only limited particle.
const SPORE_BLOSSOM_LIMIT: usize = 1000;
const PARTICLE_LIGHT_CACHE_SIZE: usize = 64;
#[derive(Clone, Copy)]
struct CachedParticleLight {
block: [i32; 3],
packed: u8,
}
struct ParticleLightCache {
entries: [Option<CachedParticleLight>; PARTICLE_LIGHT_CACHE_SIZE],
}
impl Default for ParticleLightCache {
fn default() -> Self {
Self {
entries: [None; PARTICLE_LIGHT_CACHE_SIZE],
}
}
}
impl ParticleLightCache {
fn get(&mut self, block: [i32; 3], light_at: &dyn Fn([i32; 3]) -> u8) -> u8 {
let hash = (block[0] as u32).wrapping_mul(73_856_093)
^ (block[1] as u32).wrapping_mul(19_349_663)
^ (block[2] as u32).wrapping_mul(83_492_791);
let slot = hash as usize & (PARTICLE_LIGHT_CACHE_SIZE - 1);
if let Some(entry) = self.entries[slot]
&& entry.block == block
{
return entry.packed;
}
let packed = light_at(block);
self.entries[slot] = Some(CachedParticleLight { block, packed });
packed
}
}
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
@@ -4156,6 +4193,7 @@ impl ParticleEngine {
// Camera.setRotation: rotationYXZ(PI - yRot, -xRot, 0).
let base = Quat::from_rotation_y(std::f32::consts::PI - yaw.to_radians())
* Quat::from_rotation_x(-pitch.to_radians());
let mut light_cache = ParticleLightCache::default();
for p in &self.particles {
if p.no_render {
continue;
@@ -4255,7 +4293,7 @@ impl ParticleEngine {
_ => {}
}
let block = ParticleLevel::block_at(p.position);
let packed = light_at(block);
let packed = light_cache.get(block, light_at);
let (sky_light, block_light) = p.light_coords(
partial,
u32::from(packed >> 4) * 16,
@@ -4366,6 +4404,25 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn particle_light_cache_reuses_exact_blocks_and_fails_open_on_collisions() {
let reads = std::cell::Cell::new(0);
let light_at = |block: [i32; 3]| {
reads.set(reads.get() + 1);
block[0] as u8
};
let mut cache = ParticleLightCache::default();
let first = [0, 0, 0];
let colliding = [64, 0, 0];
assert_eq!(cache.get(first, &light_at), 0);
assert_eq!(cache.get(first, &light_at), 0);
assert_eq!(reads.get(), 1);
assert_eq!(cache.get(colliding, &light_at), 64);
assert_eq!(cache.get(first, &light_at), 0);
assert_eq!(reads.get(), 3);
}
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
@@ -3,10 +3,10 @@
//! pause menu (Splitscreen Settings), playing on until the new one can join. A profile is a name
//! (kept in the game folder), with its own options. The
//! game fills the list when the page opens (`filled`), takes the choice, and says why a pick
//! can't play (`refused`). Built from vanilla's widgets, as Horizon's own pages are.
//! can't play (`refused`). Built from vanilla's widgets.
use crate::{
gamepad::{Button as PadButton, GlyphStyle},
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
text::Text,
@@ -241,7 +241,7 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr(
@@ -272,7 +272,7 @@ impl SplitProfiles {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
@@ -293,7 +293,7 @@ impl SplitProfiles {
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
graphics_style::title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
+2 -2
View File
@@ -1,7 +1,7 @@
//! Setup's monitor map and draft window choices. Apply is consumed by the game adapter.
use crate::{
gamepad::Button as PadButton,
graphics_style::STYLE,
graphics_style,
hud::{HudAtlas, HudBatch, rgba},
lang,
split_layout::{Draft, Monitor, Placement, Spot, Warning, ordered},
@@ -568,7 +568,7 @@ impl SplitUi {
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32), in_world: bool) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
STYLE.title(
graphics_style::title(
batch,
atlas,
&Text::literal(tr("horizon.pages.split_setup", &[])),
+158 -13
View File
@@ -99,6 +99,10 @@ pub trait ChunkGenerator: Send + Sync + 'static {
/// The worker has had nothing to do for [`IDLE_RELEASE`]: give back what
/// it keeps between jobs only to go faster.
fn idle(&self, _worker: &mut Self::Worker) {}
/// Every worker has had nothing to do for [`IDLE_RELEASE`]: give back the
/// caches the workers share, which only spare work for chunks generated
/// next to each other in a run. Results do not change.
fn all_idle(&self) {}
fn proto(&self, worker: &mut Self::Worker, x: i32, z: i32) -> Proto;
fn decorates(&self) -> bool {
false
@@ -149,6 +153,16 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
/// What the generator's shared caches hold: (name, entries, bytes, if
/// known), for `horizon-ctl memory`.
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
Vec::new()
}
/// A proto chunk's heap bytes by part, and its sections by bits per
/// block, for `horizon-ctl memory` (`ProtoChunk::heap_bytes`).
fn proto_heap_bytes(&self, _proto: &Proto) -> ([usize; 6], [usize; 16]) {
([0; 6], [0; 16])
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -399,12 +413,29 @@ impl Mailbox {
done.pop_front()
}
}
/// [`Streaming::proto_census`].
#[derive(Debug, Default)]
pub struct ProtoCensus {
pub protos: usize,
/// Packed blocks, section palettes and lists, heightmaps, other terrain
/// data, biomes, the box and its structure lists.
pub bytes: [usize; 6],
pub sections_by_bits: [usize; 16],
pub light_views: usize,
pub light_view_bytes: usize,
/// `ChunkGenerator::cache_census`.
pub caches: Vec<(&'static str, usize, Option<usize>)>,
}
pub struct Streaming {
shared: Arc<Shared>,
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
/// `ChunkGenerator::proto_heap_bytes`, for the memory census.
proto_bytes: Arc<dyn Fn(&Proto) -> ([usize; 6], [usize; 16]) + Send + Sync>,
/// `ChunkGenerator::cache_census`.
caches: Arc<dyn Fn() -> Vec<(&'static str, usize, Option<usize>)> + Send + Sync>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
@@ -824,6 +855,7 @@ impl<G: ChunkGenerator> Source for Jobs<G> {
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
self.generator.all_idle();
}
}
impl<G: ChunkGenerator> Jobs<G> {
@@ -961,9 +993,19 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
let proto_bytes = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.proto_heap_bytes(p))
};
let caches = {
let generator = generator.clone();
Arc::new(move || generator.cache_census())
};
Self {
shared,
non_air_sections,
proto_bytes,
caches,
jobs,
workers,
order: 0,
@@ -1136,20 +1178,39 @@ impl Streaming {
self.submit(None, 0, Job::Free(meshes));
}
}
/// A column the server unloaded (ClientboundForgetLevelChunkPacket).
pub fn forget(&mut self, world: &mut World, x: i32, z: i32) -> Option<Arc<Chunk>> {
self.lit.remove(&(x, z));
self.meshed.remove(&(x, z));
self.lighting.remove(&(x, z));
// By key, not by a pass over every pending section: a moved or
// shrunk storage forgets thousands of columns in one frame.
for sy in 0..world.layout().sections() as i32 {
self.meshing.remove(&(x, sy, z));
self.compile_started.remove(&(x, sy, z));
self.waiting.remove(&(x, sy, z));
/// The columns the server unloaded (ClientboundForgetLevelChunkPacket),
/// often thousands at once (a teleport or a moved view): their pending
/// sections go through [`forget_pending_sections`]. Returns the chunks
/// they held.
pub fn forget_columns(
&mut self,
world: &mut World,
gone: &FxHashSet<(i32, i32)>,
) -> Vec<Arc<Chunk>> {
// Every column held (a far teleport): no pending section is left to keep, and a
// pass over them all (80,000 at render distance 32) took 1.7 ms of the frame.
if world.chunks.keys().all(|column| gone.contains(column)) {
self.meshing.clear();
self.compile_started.clear();
self.waiting.clear();
} else {
forget_pending_sections(
&mut self.meshing,
&mut self.compile_started,
&mut self.waiting,
gone,
world.layout().sections() as i32,
);
}
world.dirty_sections.take_column(x, z);
world.chunks.remove(&(x, z))
let mut chunks = Vec::with_capacity(gone.len());
for &(x, z) in gone {
self.lit.remove(&(x, z));
self.meshed.remove(&(x, z));
self.lighting.remove(&(x, z));
world.dirty_sections.take_column(x, z);
chunks.extend(world.chunks.remove(&(x, z)));
}
chunks
}
/// Queue a job for the column `at`, nearest first: its distance (`distance`)
/// times four plus `bias`, which orders a column's jobs. A job without a
@@ -1463,6 +1524,26 @@ impl Streaming {
&& self.finishing.is_empty()
&& self.lighting.is_empty())
}
/// The proto chunks held (not checked out): how many, their heap bytes by
/// part and their sections by bits per block; and the light views of
/// them with their bytes. For `horizon-ctl memory`.
pub fn proto_census(&self) -> ProtoCensus {
let mut census = ProtoCensus::default();
for proto in self.protos.values().flatten() {
let (bytes, bits) = (self.proto_bytes)(proto);
census.protos += 1;
for (sum, b) in census.bytes.iter_mut().zip(bytes) {
*sum += b;
}
for (sum, b) in census.sections_by_bits.iter_mut().zip(bits) {
*sum += b;
}
}
census.light_views = self.light_views.len();
census.light_view_bytes = self.light_views.values().map(|v| v.heap_bytes()).sum();
census.caches = (self.caches)();
census
}
/// How much work each stage holds: queued jobs, then what is in flight or
/// waiting at each step from generation to meshing.
pub fn stages(&self) -> [(&'static str, usize); 7] {
@@ -2761,6 +2842,34 @@ fn whole_relight_columns(
(columns, inline)
}
/// Drops the pending sections (`meshing`, `compile_started`, `waiting`) of
/// the `gone` columns: one pass over each store when that is cheaper than a
/// lookup per section of every column (a far teleport at render distance 32
/// forgets ~3,700 columns, 1.7 ms of lookups), else by key.
fn forget_pending_sections(
meshing: &mut FxHashMap<(i32, i32, i32), u64>,
compile_started: &mut FxHashMap<(i32, i32, i32), u64>,
waiting: &mut FxHashSet<(i32, i32, i32)>,
gone: &FxHashSet<(i32, i32)>,
sections: i32,
) {
let lookups = gone.len().saturating_mul(sections.max(0) as usize);
if lookups > meshing.len() + compile_started.len() + waiting.len() {
let kept = |&(x, _, z): &(i32, i32, i32)| !gone.contains(&(x, z));
meshing.retain(|k, _| kept(k));
compile_started.retain(|k, _| kept(k));
waiting.retain(kept);
} else {
for &(x, z) in gone {
for sy in 0..sections {
meshing.remove(&(x, sy, z));
compile_started.remove(&(x, sy, z));
waiting.remove(&(x, sy, z));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -2908,6 +3017,42 @@ mod tests {
assert!(waiting.elapsed() >= std::time::Duration::from_millis(10));
}
/// Whether the forgotten columns are few (looked up by key) or many
/// (one pass over each store), exactly their pending sections go.
#[test]
fn forgotten_columns_lose_their_pending_sections_either_way() {
let pending: Vec<(i32, i32, i32)> = (0..4)
.flat_map(|x| (0..3).map(move |sy| (x, sy, -x)))
.collect();
for gone in [
vec![(1, -1)],
(0..400)
.map(|x| (x, -x))
.filter(|&c| c != (2, -2))
.collect(),
] {
let gone: FxHashSet<(i32, i32)> = gone.into_iter().collect();
let mut meshing: FxHashMap<_, u64> = pending.iter().map(|&k| (k, 1)).collect();
let mut started = meshing.clone();
let mut waiting: FxHashSet<_> = pending.iter().copied().collect();
forget_pending_sections(&mut meshing, &mut started, &mut waiting, &gone, 3);
let mut left: Vec<_> = waiting.into_iter().collect();
left.sort();
let expected: Vec<_> = pending
.iter()
.copied()
.filter(|&(x, _, z)| !gone.contains(&(x, z)))
.collect();
assert_eq!(left, expected, "{} gone", gone.len());
let mut left: Vec<_> = meshing.into_keys().collect();
left.sort();
assert_eq!(left, expected);
let mut left: Vec<_> = started.into_keys().collect();
left.sort();
assert_eq!(left, expected);
}
}
/// Edits in two neighbouring columns light those and the ten around
/// them again; a column next to one that can't be lit whole keeps its
/// edits in the frame.
+27 -5
View File
@@ -646,21 +646,43 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
// gfx/native_sky.rs builds that stage from this section).
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
var color = sky_along_ray_base(d);
let w = sunrise_weight(d);
if w > 0.0 {
color = mix(color, globals.sunrise.rgb, w * globals.sunrise.a);
}
return color;
}
// The sky without the sunrise fan: the renderer rasterizes the fan's sixteen
// triangles over this colour instead of testing them in every sky pixel.
fn sky_along_ray_base(d: vec3<f32>) -> vec3<f32> {
var color = globals.fog_color.rgb;
let dist = disc_distance(d, 16.0);
if dist.x >= 0.0 {
color = sky_fog(globals.sky_color.rgb, dist);
}
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
@fragment fn fs_sky_base(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray_base(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
// The sunrise/sunset fan as geometry: the clip position is projected by the
// CPU with the sky's unjittered view, and the apex weight (1 at the apex, 0 at
// the rim) interpolates exactly the barycentric the per-pixel test computed.
struct SunriseOut {
@builtin(position) clip: vec4<f32>,
@location(0) weight: f32,
}
@vertex fn vs_sunrise(@location(0) clip: vec4<f32>, @location(1) weight: f32) -> SunriseOut {
return SunriseOut(clip, weight);
}
@fragment fn fs_sunrise(in: SunriseOut) -> @location(0) vec4<f32> {
return vec4(globals.sunrise.rgb, in.weight * globals.sunrise.a);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
@fragment fn fs_depth_reset(in: SkyOut) -> @location(0) vec4<f32> {
@@ -88,7 +88,6 @@
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
@@ -129,16 +128,10 @@
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
@@ -93,7 +93,6 @@
"Resolution Scale": "Resolution Scale",
"Anti-aliasing": "Anti-aliasing",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR Settings...",
"Paper White": "Paper White",
"Peak Brightness": "Peak Brightness",
"Black Level": "Black Level",
@@ -134,16 +133,8 @@
"Medium cost": "Medium cost",
"High cost": "High cost",
"Saves time": "Saves time",
"Rendering": "Rendering",
"Display": "Display",
"Preferences": "Preferences",
"Overlay": "Overlay",
"HDR": "HDR",
"Graphics": "Graphics",
"Graphics: Advanced": "Graphics: Advanced",
"Graphics: HDR": "Graphics: HDR",
"Advanced...": "Advanced...",
"Simple...": "Simple...",
"Done": "Done",
"This feature is not yet available in Horizon.": "This feature is not yet available in Horizon.",
"Your display or system doesn't offer HDR output.": "Your display or system doesn't offer HDR output.",
@@ -216,6 +207,32 @@
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"General": "General",
"Advanced": "Advanced",
"Undo": "Undo",
"Search options": "Search options",
"Shortcut": "Shortcut",
"Distance": "Distance",
"Display": "Display",
"Interface": "Interface",
"Sky and weather": "Sky and weather",
"Lighting and entities": "Lighting and entities",
"Textures": "Textures",
"Image": "Image",
"Chunk loading": "Chunk loading",
"Culling": "Culling",
"Power": "Power",
"Frames": "Frames",
"Overlay": "Overlay",
"Output": "Output",
"Brightness": "Brightness",
"How far you see, the window and the HUD.": "How far you see, the window and the HUD.",
"The sky and weather, lighting, textures and the image.": "The sky and weather, lighting, textures and the image.",
"Chunk loading, culling and power use.": "Chunk loading, culling and power use.",
"How frames reach the screen, and the performance overlay.": "How frames reach the screen, and the performance overlay.",
"Brighter-than-white output on an HDR display.": "Brighter-than-white output on an HDR display.",
"Results for \"%s\"": "Results for \"%s\"",
"No settings match.": "No settings match.",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
@@ -376,7 +393,6 @@
"horizon.splitscreen.setup.help.give_up_pad": "Your controller stops driving you and is offered to join (A).",
"horizon.splitscreen.setup.note.held_at": "%s to %s: A drops it here, B puts it back.",
"horizon.splitscreen.setup.note.held": "%s picked up: left/right picks where, A drops, B puts it back.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Block Entities": "Block Entities",
"Shulker Boxes": "Shulker Boxes",
@@ -82,7 +82,6 @@
"Resolution Scale": "Escala de resolución",
"Anti-aliasing": "Antialiasing",
"Upscaling": "Reescalado",
"HDR Settings...": "Ajustes HDR...",
"Paper White": "Blanco de papel",
"Peak Brightness": "Brillo máximo",
"Black Level": "Nivel de negro",
@@ -122,16 +121,8 @@
"Medium cost": "Coste medio",
"High cost": "Coste alto",
"Saves time": "Ahorra tiempo",
"Rendering": "Renderizado",
"Display": "Pantalla",
"Preferences": "Preferencias",
"Overlay": "Superposición",
"HDR": "HDR",
"Graphics": "Gráficos",
"Graphics: Advanced": "Gráficos: avanzados",
"Graphics: HDR": "Gráficos: HDR",
"Advanced...": "Avanzados...",
"Simple...": "Simples...",
"Done": "Hecho",
"This feature is not yet available in Horizon.": "Esta función aún no está disponible en Horizon.",
"Your display or system doesn't offer HDR output.": "Tu pantalla o sistema no ofrece salida HDR.",
@@ -207,5 +198,31 @@
"horizon.pages.split_profiles.tooltip": "Quién juega: un perfil guardado en esta carpeta del juego.",
"horizon.shader_options.open": "Opciones de sombreadores…",
"horizon.shader_options.profile": "Perfil",
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…"
"horizon.shader_options.loading": "Leyendo las opciones del paquete de sombreadores…",
"General": "General",
"Advanced": "Avanzado",
"Undo": "Deshacer",
"Search options": "Buscar opciones",
"Shortcut": "Atajo",
"Distance": "Distancia",
"Display": "Pantalla",
"Interface": "Interfaz",
"Sky and weather": "Cielo y clima",
"Lighting and entities": "Iluminación y entidades",
"Textures": "Texturas",
"Image": "Imagen",
"Chunk loading": "Carga de chunks",
"Culling": "Descarte",
"Power": "Energía",
"Frames": "Fotogramas",
"Overlay": "Superposición",
"Output": "Salida",
"Brightness": "Brillo",
"How far you see, the window and the HUD.": "Hasta dónde ves, la ventana y el HUD.",
"The sky and weather, lighting, textures and the image.": "El cielo y el clima, la iluminación, las texturas y la imagen.",
"Chunk loading, culling and power use.": "Carga de chunks, descarte y consumo de energía.",
"How frames reach the screen, and the performance overlay.": "Cómo llegan los fotogramas a la pantalla, y la superposición de rendimiento.",
"Brighter-than-white output on an HDR display.": "Salida más brillante que el blanco en una pantalla HDR.",
"Results for \"%s\"": "Resultados de \"%s\"",
"No settings match.": "Ningún ajuste coincide."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
@@ -129,16 +128,10 @@
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
@@ -88,7 +88,6 @@
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
@@ -129,16 +128,10 @@
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"General": "Ogólne",
"Advanced": "Zaawansowane",
"Undo": "Cofnij",
"Search options": "Szukaj opcji",
"Shortcut": "Skrót",
"Distance": "Odległość",
"Interface": "Interfejs",
"Sky and weather": "Niebo i pogoda",
"Lighting and entities": "Oświetlenie i byty",
"Textures": "Tekstury",
"Image": "Obraz",
"Chunk loading": "Wczytywanie chunków",
"Culling": "Pomijanie",
"Power": "Zasilanie",
"Frames": "Klatki",
"Output": "Wyjście",
"Brightness": "Jasność",
"How far you see, the window and the HUD.": "Jak daleko widzisz, okno i HUD.",
"The sky and weather, lighting, textures and the image.": "Niebo i pogoda, oświetlenie, tekstury i obraz.",
"Chunk loading, culling and power use.": "Wczytywanie chunków, pomijanie i zużycie energii.",
"How frames reach the screen, and the performance overlay.": "Jak klatki trafiają na ekran oraz nakładka wydajności.",
"Brighter-than-white output on an HDR display.": "Obraz jaśniejszy niż biel na ekranie HDR.",
"Results for \"%s\"": "Wyniki dla \"%s\"",
"No settings match.": "Żadne ustawienie nie pasuje.",
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij",
"Culling": "Pomijanie",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
"Leaf Culling": "Pomijanie liści",
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
@@ -81,7 +81,6 @@
"Resolution Scale": "Масштаб разрешения",
"Anti-aliasing": "Сглаживание",
"Upscaling": "Апскейл",
"HDR Settings...": "Настройки HDR…",
"Paper White": "Белая точка",
"Peak Brightness": "Пиковая яркость",
"Black Level": "Уровень чёрного",
@@ -121,16 +120,8 @@
"Medium cost": "Средние затраты",
"High cost": "Много затрат",
"Saves time": "Экономит время",
"Rendering": "Рендеринг",
"Display": "Изображение",
"Preferences": "Предпочтения",
"Overlay": "Оверлей",
"HDR": "HDR",
"Graphics": "Графика",
"Graphics: Advanced": "Графика: подробно",
"Graphics: HDR": "Графика: HDR",
"Advanced...": "Подробно…",
"Simple...": "Просто…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Эта функция в Horizon пока недоступна.",
"Your display or system doesn't offer HDR output.": "Ваш экран или система не поддерживает HDR-вывод.",
@@ -206,5 +197,31 @@
"horizon.pages.split_profiles.tooltip": "Кто играет: профиль, хранимый в этой папке игры.",
"horizon.shader_options.open": "Настройки шейдеров…",
"horizon.shader_options.profile": "Профиль",
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…"
"horizon.shader_options.loading": "Чтение настроек набора шейдеров…",
"General": "Общие",
"Advanced": "Дополнительно",
"Undo": "Отменить",
"Search options": "Поиск настроек",
"Shortcut": "Сочетание клавиш",
"Distance": "Дальность",
"Display": "Экран",
"Interface": "Интерфейс",
"Sky and weather": "Небо и погода",
"Lighting and entities": "Освещение и сущности",
"Textures": "Текстуры",
"Image": "Изображение",
"Chunk loading": "Загрузка чанков",
"Culling": "Отсечение",
"Power": "Питание",
"Frames": "Кадры",
"Overlay": "Оверлей",
"Output": "Вывод",
"Brightness": "Яркость",
"How far you see, the window and the HUD.": "Дальность обзора, окно и HUD.",
"The sky and weather, lighting, textures and the image.": "Небо и погода, освещение, текстуры и изображение.",
"Chunk loading, culling and power use.": "Загрузка чанков, отсечение и энергопотребление.",
"How frames reach the screen, and the performance overlay.": "Как кадры попадают на экран, и оверлей производительности.",
"Brighter-than-white output on an HDR display.": "Вывод ярче белого на HDR-дисплее.",
"Results for \"%s\"": "Результаты для «%s»",
"No settings match.": "Нет подходящих настроек."
}
@@ -82,7 +82,6 @@
"Resolution Scale": "Çözünürlük Ölçeği",
"Anti-aliasing": "Kenar Yumuşatma",
"Upscaling": "Yükseltme",
"HDR Settings...": "HDR Ayarları...",
"Paper White": "Referans Beyaz",
"Peak Brightness": "Tepe Parlaklığı",
"Black Level": "Siyah Seviyesi",
@@ -122,16 +121,8 @@
"Medium cost": "Orta Performans Maliyeti",
"High cost": "Yüksek Performans Maliyeti",
"Saves time": "Zaman Kazandırır",
"Rendering": "Görüntüleme",
"Display": "Ekran",
"Preferences": "Tercihler",
"Overlay": "Bilgi Katmanı",
"HDR": "HDR",
"Graphics": "Grafikler",
"Graphics: Advanced": "Grafikler: Gelişmiş",
"Graphics: HDR": "Grafikler: HDR",
"Advanced...": "Gelişmiş...",
"Simple...": "Basit...",
"Done": "Tamam",
"This feature is not yet available in Horizon.": "Bu özellik henüz Horizon'da mevcut değil.",
"Your display or system doesn't offer HDR output.": "Ekranınız veya sisteminiz HDR çıkışını desteklemiyor.",
@@ -202,5 +193,31 @@
"horizon.pages.split_profiles.tooltip": "Kim oynuyor: bu oyun klasöründe tutulan bir profil.",
"horizon.shader_options.open": "Gölgelendirici Seçenekleri…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…"
"horizon.shader_options.loading": "Gölgelendirici paketinin seçenekleri okunuyor…",
"General": "Genel",
"Advanced": "Gelişmiş",
"Undo": "Geri al",
"Search options": "Seçeneklerde ara",
"Shortcut": "Kısayol",
"Distance": "Mesafe",
"Display": "Ekran",
"Interface": "Arayüz",
"Sky and weather": "Gökyüzü ve hava",
"Lighting and entities": "Işıklandırma ve varlıklar",
"Textures": "Dokular",
"Image": "Görüntü",
"Chunk loading": "Chunk yükleme",
"Culling": "Ayıklama",
"Power": "Güç",
"Frames": "Kareler",
"Overlay": "Kaplama",
"Output": "Çıkış",
"Brightness": "Parlaklık",
"How far you see, the window and the HUD.": "Ne kadar uzağı gördüğün, pencere ve HUD.",
"The sky and weather, lighting, textures and the image.": "Gökyüzü ve hava, ışıklandırma, dokular ve görüntü.",
"Chunk loading, culling and power use.": "Chunk yükleme, ayıklama ve güç kullanımı.",
"How frames reach the screen, and the performance overlay.": "Karelerin ekrana nasıl ulaştığı ve performans kaplaması.",
"Brighter-than-white output on an HDR display.": "HDR ekranda beyazdan parlak çıkış.",
"Results for \"%s\"": "\"%s\" için sonuçlar",
"No settings match.": "Eşleşen ayar yok."
}
@@ -88,7 +88,6 @@
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
@@ -129,16 +128,10 @@
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
@@ -211,6 +204,30 @@
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"General": "Загальні",
"Advanced": "Додатково",
"Undo": "Скасувати",
"Search options": "Пошук налаштувань",
"Shortcut": "Сполучення клавіш",
"Distance": "Дальність",
"Interface": "Інтерфейс",
"Sky and weather": "Небо й погода",
"Lighting and entities": "Освітлення й сутності",
"Textures": "Текстури",
"Image": "Зображення",
"Chunk loading": "Завантаження чанків",
"Culling": "Відсікання",
"Power": "Живлення",
"Frames": "Кадри",
"Output": "Вивід",
"Brightness": "Яскравість",
"How far you see, the window and the HUD.": "Як далеко видно, вікно й HUD.",
"The sky and weather, lighting, textures and the image.": "Небо й погода, освітлення, текстури й зображення.",
"Chunk loading, culling and power use.": "Завантаження чанків, відсікання й енергоспоживання.",
"How frames reach the screen, and the performance overlay.": "Як кадри потрапляють на екран, і оверлей продуктивності.",
"Brighter-than-white output on an HDR display.": "Вивід, яскравіший за білий, на HDR-дисплеї.",
"Results for \"%s\"": "Результати для «%s»",
"No settings match.": "Немає відповідних налаштувань.",
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
@@ -291,7 +308,6 @@
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити",
"Culling": "Відсікання",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
"Leaf Culling": "Відсікання листя",
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
@@ -82,7 +82,6 @@
"Resolution Scale": "渲染比例",
"Anti-aliasing": "抗锯齿",
"Upscaling": "放大",
"HDR Settings...": "HDR 设置…",
"Paper White": "纸白亮度",
"Peak Brightness": "峰值亮度",
"Black Level": "黑位",
@@ -123,16 +122,8 @@
"Medium cost": "中等开销",
"High cost": "高开销",
"Saves time": "节省时间",
"Rendering": "渲染",
"Display": "显示",
"Preferences": "偏好",
"Overlay": "叠加层",
"HDR": "HDR",
"Graphics": "图形",
"Graphics: Advanced": "图形:高级",
"Graphics: HDR": "图形:HDR",
"Advanced...": "高级…",
"Simple...": "简单…",
"Done": "完成",
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
@@ -211,6 +202,32 @@
"horizon.shader_options.open": "光影选项…",
"horizon.shader_options.profile": "档案",
"horizon.shader_options.loading": "正在读取光影包的选项…",
"General": "常规",
"Advanced": "高级",
"Undo": "撤销",
"Search options": "搜索选项",
"Shortcut": "快捷键",
"Distance": "距离",
"Display": "显示",
"Interface": "界面",
"Sky and weather": "天空与天气",
"Lighting and entities": "光照与实体",
"Textures": "纹理",
"Image": "图像",
"Chunk loading": "区块加载",
"Culling": "剔除",
"Power": "电源",
"Frames": "帧",
"Overlay": "叠加层",
"Output": "输出",
"Brightness": "亮度",
"How far you see, the window and the HUD.": "视距、窗口与 HUD。",
"The sky and weather, lighting, textures and the image.": "天空与天气、光照、纹理与图像。",
"Chunk loading, culling and power use.": "区块加载、剔除与功耗。",
"How frames reach the screen, and the performance overlay.": "帧如何送达屏幕,以及性能叠加层。",
"Brighter-than-white output on an HDR display.": "在 HDR 显示器上输出比白色更亮的画面。",
"Results for \"%s\"": "“%s”的结果",
"No settings match.": "没有匹配的设置。",
"horizon.pages.split_settings": "分屏设置",
"horizon.pages.split_setup": "分屏",
"horizon.pages.split_setup.tooltip": "在本机游玩的玩家:手柄可以加入你的世界。",
@@ -308,7 +325,6 @@
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
"Culling": "剔除",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
"Leaf Culling": "树叶剔除",
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
+89 -3
View File
@@ -519,8 +519,10 @@ pub fn mob_to_nbt(
}
}
let species = m.species();
// MushroomCow saves its variant under `Type` (below), not `variant`.
if species != crate::mobs::Species::Mooshroom
// `VariantUtils.writeVariant`: only the species that store the name. A
// mooshroom saves `Type` (below), a fox `Type`, a horse and a llama a
// packed `Variant` int, so those report the component but save no name.
if crate::mobs::finalize::saves_variant_name(species)
&& let Some((_, variant)) =
crate::mobs::finalize::species_variant_component(species, m.extra.variant)
{
@@ -1657,7 +1659,7 @@ pub fn mob_from_nbt(
m.extra.vector[0] = 0.7;
}
}
if crate::mobs::finalize::species_variant_component(m.species(), 0).is_some()
if crate::mobs::finalize::saves_variant_name(m.species())
&& let Some(v) = text(tag.get("variant"))
{
m.extra.variant = crate::mobs::finalize::variant_index(m.species(), &v);
@@ -2715,6 +2717,7 @@ pub struct TridentSave<'a> {
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub no_gravity: bool,
pub item: &'a crate::items::ItemStack,
}
pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
@@ -2744,6 +2747,12 @@ pub fn trident_to_nbt(t: &TridentSave, uuid: [i32; 4], raw: Option<&Tag>) -> Tag
] {
put(&mut tag, key, value);
}
// Entity.saveWithoutId writes NoGravity only when set.
if t.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
} else if let Tag::Compound(map) = &mut tag {
map.remove(&"NoGravity".into());
}
tag
}
/// (position, motion, yaw, pitch, in ground, life, shake, dealt damage,
@@ -2758,6 +2767,7 @@ pub struct TridentNbt {
pub shake: u32,
pub dealt_damage: bool,
pub creative_only: bool,
pub no_gravity: bool,
pub item: crate::items::ItemStack,
}
pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
@@ -2772,6 +2782,7 @@ pub fn trident_from_nbt(tag: &Tag) -> Option<TridentNbt> {
shake: number(tag.get("shake")).unwrap_or(0).max(0) as u32,
dealt_damage: flag(tag, "DealtDamage"),
creative_only: number(tag.get("pickup")) == Some(2),
no_gravity: flag(tag, "NoGravity"),
item: tag
.get("item")
.and_then(item_from_nbt)
@@ -3457,12 +3468,16 @@ mod tests {
shake: 3,
dealt_damage: true,
creative_only: true,
no_gravity: true,
item: &trident,
},
[1, 2, 3, 4],
None,
);
let t = trident_from_nbt(&tag).unwrap();
// `Entity.saveWithoutId` writes NoGravity only when set.
assert!(t.no_gravity);
assert!(matches!(tag.get("NoGravity"), Some(Tag::Byte(1))));
assert_eq!((t.in_ground, t.life, t.shake), (true, 9, 3));
assert_eq!((t.dealt_damage, t.creative_only), (true, true));
assert_eq!((t.yaw, t.pitch), (10.0, 20.0));
@@ -3970,6 +3985,14 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
// alone, so a spawn egg carrying one hatches that variant.
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
// Horse.applyImplicitComponent: `setVariant` sets the coat colour and
// keeps the markings the spawn picked (`getTypeVariant() & -256`),
// which Horizon keeps in `extra.variant2` and leaves alone here.
crate::mobs::Species::Horse => (Some("minecraft:horse/variant"), None),
// Llama.applyImplicitComponent, which a trader llama inherits.
crate::mobs::Species::Llama | crate::mobs::Species::TraderLlama => {
(Some("minecraft:llama/variant"), None)
}
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -4869,6 +4892,69 @@ mod item_component_tests {
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
}
/// `Horse.applyImplicitComponent` and `Llama.applyImplicitComponent`: the
/// `.../variant` component sets the coat colour. A horse's markings are
/// the other half of the packed variant its spawn picked and stay.
#[test]
fn a_horse_or_llama_spawn_egg_picks_its_variant_component() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let spawn = |kind: &str, id: u32| {
Mob::new(
id,
Arc::new(data.types[kind].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
)
};
// A black horse (Horse.Variant.BLACK, 4) with a white field (markings
// 2), as a spawn in a plains leaves it.
let mut horse = spawn("minecraft:horse", 1);
(horse.extra.variant, horse.extra.variant2) = (4, 2);
let mut egg = crate::items::ItemStack::new("minecraft:horse_spawn_egg", 1);
egg.data_mut().set_other(
"minecraft:horse/variant",
Some(string("minecraft:dark_brown")),
);
apply_item_components(&mut horse, &egg);
assert_eq!(horse.extra.variant, 6, "Horse.Variant.DARK_BROWN");
assert_eq!(
horse.extra.variant2, 2,
"the markings the spawn picked stay"
);
// `Horse.get(DataComponents.HORSE_VARIANT)` reports it back.
let (id, variant) = crate::mobs::finalize::variant_component(&horse).unwrap();
assert_eq!(
(id, variant.as_str()),
("minecraft:horse/variant", "minecraft:dark_brown")
);
// The save keeps the packed `Variant` int and gains no `variant` name,
// which only `VariantUtils.writeVariant` species store.
let tag = mob_to_nbt(&horse, [1, 2, 3, 4], None, None);
assert_eq!(tag.get("variant"), None);
assert!(
matches!(tag.get("Variant"), Some(Tag::Int(v)) if *v == 6 | 2 << 8),
"colour 6 and markings 2 packed"
);
// A llama, as a spawn in a plains leaves it: creamy.
let mut egg = crate::items::ItemStack::new("minecraft:llama_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:llama/variant", Some(string("minecraft:gray")));
let mut llama = spawn("minecraft:llama", 2);
assert_eq!(llama.extra.variant, 0);
apply_item_components(&mut llama, &egg);
assert_eq!(llama.extra.variant, 3, "Llama.Variant.GRAY");
// TraderLlama inherits the llama's arm.
let mut trader = spawn("minecraft:trader_llama", 3);
apply_item_components(&mut trader, &egg);
assert_eq!(trader.extra.variant, 3, "the trader llama reads it too");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
+82 -11
View File
@@ -208,6 +208,7 @@ mod native_explosions;
mod native_falling;
mod native_hooks;
mod native_inventories;
mod native_item_container;
mod native_item_energy;
mod native_item_fluids;
mod native_lifecycle;
@@ -2399,6 +2400,7 @@ struct Game<'a> {
/// `horizon-ctl gpu-passes`: time Horizon's own passes on the GPU with F3 shut.
gpu_passes: bool,
screen: Screen,
observed_screen: Option<native_hooks::ObservedScreen>,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
/// Avoid an endless rebuild loop if the new device is lost as well.
@@ -2708,6 +2710,7 @@ struct Game<'a> {
waypoints: horizon_client::locator_bar::ClientWaypoints,
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
locator_camera: Option<([f64; 3], f32, f32, f32)>,
native_camera_frame: Option<horizon_client::mod_api::NativeCamera>,
/// `LocalPlayer.postEffects`: the chains the server has the screen run
/// (`ClientboundPostEffectsPacket`), in order.
post_effects: Vec<String>,
@@ -6013,9 +6016,8 @@ impl Game<'_> {
if entity && hand == Hand::Off && self.with_hand(hand, |g| g.interact_entity(None))? {
return Ok(Some(hand));
}
// A client of a server never hits a fence knot with its crosshair:
// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` reach the
// server as a click on the fence block.
// A click on the fence block: `LeadItem.useOn`. A client whose crosshair
// met the knot first sends Interact with the knot's id instead.
if on_block && self.with_hand(hand, Game::interact_knot) {
return Ok(Some(hand));
}
@@ -6061,8 +6063,7 @@ impl Game<'_> {
|| self.interact_boat(target)
|| self.interact_cart(target)
|| self.interact_mob(target)?
// Knots are reached only through the crosshair pick for now: a
// guest's Interact naming a knot's id is a known gap.
// A guest's Interact naming a knot's id goes to `use_knot` before this.
|| target.is_none() && self.interact_knot())
}
/// `ItemStack.applyAfterUseComponentSideEffects`: a used stack with a
@@ -10810,10 +10811,18 @@ impl Game<'_> {
);
}
// Mod block entities: their mods draw them (`native_block_entity_submit`),
// culled by the bounds they give.
let mods = r
.visible_block_entities(self.settings.chunk_fade)
.filter(|(_, state)| !self.res.block_entity_render().renders(*state));
// culled by the bounds they give. A world without any (`has_mods`) has none
// to find among its other unrendered block entities (furnaces, hoppers...).
let mods = self
.world
.block_entities
.has_mods()
.then(|| {
r.visible_block_entities(self.settings.chunk_fade)
.filter(|(_, state)| !self.res.block_entity_render().renders(*state))
})
.into_iter()
.flatten();
let untemplated = self.untemplated_mod_block_entities(camera);
self.mod_block_entity_geometry(
mods.chain(untemplated.iter()),
@@ -12891,7 +12900,17 @@ impl Game<'_> {
let target = self.bucket_target(content, &hit);
let mut canceled = false;
self.native_fill_bucket(stack, target, &mut canceled);
if canceled || self.bucket_empty_cancelled(stack, target) {
// `mayUseItemAt(blockPos2)`: a block that refuses the fluid passes
// it to the block beside, so a mod hears that block too (powder
// snow places like a block and never goes there).
let beside_too = content != BucketContent::Powder && hit.beside() != target;
if !canceled && beside_too {
self.native_fill_bucket(stack, hit.beside(), &mut canceled);
}
if canceled
|| self.bucket_empty_cancelled(stack, target)
|| beside_too && self.bucket_empty_cancelled(stack, hit.beside())
{
return Ok(UseResult::Fail);
}
let pos = BlockPos::new(target[0], target[1], target[2]);
@@ -13580,6 +13599,29 @@ impl Game<'_> {
if canceled {
continue;
}
let (mut x, mut y, mut z, mut pitch, mut yaw, mut roll, mut pose_scale) =
(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0);
self.native_render_hand_pose(
hand,
item.as_ref(),
a,
attack,
inverse_height,
&mut x,
&mut y,
&mut z,
&mut pitch,
&mut yaw,
&mut roll,
&mut pose_scale,
);
// No mod moved the hand (always so without one): no matrices to build.
let pose = [x, y, z, pitch, yaw, roll, pose_scale];
let to_world = if pose == [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0] {
to_world
} else {
to_world * native_hooks::hand_pose_matrix(pose)
};
// The main hand on its arm, the off hand on the other one.
let left = self.settings.main_hand_left != (hand == Hand::Off);
let Some(stack) = item else {
@@ -14307,7 +14349,7 @@ impl Game<'_> {
let lines = vec![(horizon_client::text::Text::literal(text), u32::MAX)];
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
Self::tooltip_box(batch, atlas, x, y, w, h);
self.tooltip_box(batch, None, x, y, w, h);
Self::tooltip_text(batch, atlas, &lines, x, y);
}
/// `CreativeModeInventoryScreen.getTooltipFromContainerItem`: the lines
@@ -15006,6 +15048,7 @@ impl Game<'_> {
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
}
self.observe_screen();
// Minecraft.doWorldLoad: the LevelLoadingScreen while single player's
// server starts, with the server's chunk map and progress.
if let Some(starting) = &self.integrated_starting {
@@ -15348,6 +15391,32 @@ impl Game<'_> {
let distance = (terrain_distance * 16) as f32;
let depth_far =
horizon_client::camera::depth_far(terrain_distance, self.settings.cloud_range);
// Only native mods read it: no matrices or allocation per frame without one.
let mods_read_camera = !horizon_client::mod_api::mods().is_empty();
self.native_camera_frame = (mods_read_camera && !title).then(|| {
let partial = camera_alpha as f32;
horizon_client::mod_api::NativeCamera {
position: camera.to_array(),
eye_height: self.view.eye_height_old
+ (self.view.eye_height - self.view.eye_height_old) * partial,
partial_tick: partial,
yaw,
pitch,
fov_degrees,
near: 0.05,
far: depth_far,
view: (bob * spin * horizon_client::camera::view_matrix(yaw, pitch))
.to_cols_array(),
projection: glam::Mat4::perspective_rh(
fov_degrees.to_radians(),
size[0] as f32 / size[1].max(1) as f32,
depth_far,
0.05,
)
.to_cols_array(),
dimension: self.dim.id().to_string(),
}
});
self.view_frustum = self
.renderer
.as_ref()
@@ -18281,6 +18350,7 @@ impl<'a> Game<'a> {
debug: false,
gpu_passes: false,
screen: Screen::Playing,
observed_screen: None,
window: None,
renderer: None,
last_device_recovery: None,
@@ -18458,6 +18528,7 @@ impl<'a> Game<'a> {
waypoint_styles: prepared.waypoint_styles,
waypoints: Default::default(),
locator_camera: None,
native_camera_frame: None,
post_effects: Vec::new(),
spectated_post_effect: None,
experience_display: Default::default(),
+50 -1
View File
@@ -287,7 +287,8 @@ fn resource_ids(root: &Path) -> Arc<ResourceIds> {
let ids = Arc::new(ResourceIds {
recipes: ids_under(&data.join("recipe"), "json"),
damage_types: ids_under(&data.join("damage_type"), "json"),
loot_tables: ids_under(&data.join("loot_table"), "json"),
// `/loot ... loot <id>` must find a content mod's tables too.
loot_tables: json_ids_of_every_namespace(root, "loot_table"),
templates: templates_under(&root.join("worldgen/structure")),
entity_types,
enchantments: json_ids_in(&data.join("enchantment")),
@@ -364,6 +365,33 @@ fn ids_under(root: &Path, extension: &str) -> Vec<String> {
ids_in(root, extension, "minecraft")
}
/// The ids of the `.json` files in `data/<namespace>/<kind>` of every
/// namespace of `root` (vanilla's and the content mods'), sorted.
fn json_ids_of_every_namespace(root: &Path, kind: &str) -> Vec<String> {
let data = root.join("data");
let mut namespaces: std::collections::BTreeSet<String> = std::fs::read_dir(&data)
.into_iter()
.flatten()
.flatten()
.filter(|e| e.path().is_dir())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
// A prepared root's namespaces live in its data file, not on the disk.
if let Some(files) = horizon_core::prepared_files::files_under(&data) {
namespaces.extend(
files
.iter()
.filter_map(|f| Some(f.split_once('/')?.0.to_owned())),
);
}
let mut out: Vec<String> = namespaces
.iter()
.flat_map(|ns| ids_in(&data.join(ns).join(kind), "json", ns))
.collect();
out.sort();
out
}
/// The templates of every namespace folder under `root` (vanilla's and the
/// content mods'), as `namespace:path`, sorted.
fn templates_under(root: &Path) -> Vec<String> {
@@ -1579,6 +1607,27 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A content mod's loot tables are listed with vanilla's, so `/loot`
/// finds them.
#[test]
fn loot_tables_of_every_namespace_are_listed() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
for dir in [
"data/minecraft/loot_table/chests",
"data/mymod/loot_table/rooms",
] {
std::fs::create_dir_all(root.join(dir)).unwrap();
}
std::fs::write(root.join("data/minecraft/loot_table/chests/a.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/rooms/b.json"), "{}").unwrap();
std::fs::write(root.join("data/mymod/loot_table/notes.txt"), "").unwrap();
assert_eq!(
resource_ids(&root).loot_tables,
["minecraft:chests/a", "mymod:rooms/b"]
);
let _ = std::fs::remove_dir_all(&root);
}
/// Only the overworld clock's timelines count; a marker is a bare tick
/// count (hidden from commands) or an object that may show itself.
#[test]
@@ -69,6 +69,8 @@ pub(super) enum Target {
Falling(u32),
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
Rocket(u32),
/// A thrown trident, by its entity id (`minecraft:trident`).
Trident(u32),
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
Spawner(u32),
}
@@ -152,6 +154,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Tnt(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Spawner(id)) = t
else {
return None;
@@ -549,6 +552,11 @@ impl Game<'_> {
.iter()
.find(|r| r.id == id)
.map(|r| [r.position.x, r.position.y, r.position.z]),
Target::Trident(id) => self
.tridents
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
Target::Spawner(id) => self
.ominous_spawners
.iter()
@@ -603,6 +611,7 @@ impl Game<'_> {
Target::Tnt(_) => "minecraft:tnt".into(),
Target::Falling(_) => "minecraft:falling_block".into(),
Target::Rocket(_) => "minecraft:firework_rocket".into(),
Target::Trident(_) => "minecraft:trident".into(),
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
}
}
@@ -905,6 +914,7 @@ impl Game<'_> {
Target::Tnt(id),
Target::Falling(id),
Target::Rocket(id),
Target::Trident(id),
Target::Spawner(id),
]
.into_iter()
@@ -930,6 +940,7 @@ impl Game<'_> {
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::Trident(id) => self.tridents.iter().any(|t| t.id == id),
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
}
}
@@ -1600,6 +1611,12 @@ impl Game<'_> {
r.previous = v;
}
}
Target::Trident(id) => {
if let Some(t) = self.tridents.iter_mut().find(|t| t.id == id) {
t.position = v;
t.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];
@@ -2102,6 +2119,7 @@ impl Game<'_> {
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::Trident(id) => self.tridents.retain(|t| t.id != id),
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
@@ -2242,6 +2260,10 @@ impl Game<'_> {
beam: None,
invulnerable: false,
});
// tryAddFreshEntityWithPassengers refused: ERROR_DUPLICATE_UUID.
if !self.native_joins_flush(false, self.next_entity) {
return Err(err("commands.summon.failed.uuid", &[]));
}
name = Text::translate("entity.minecraft.end_crystal", &[]);
}
k if super::minecarts::CartKind::from_id(&kind).is_some() => {
@@ -2320,6 +2342,37 @@ impl Game<'_> {
state,
)
}
/// `BlockInput.overwriteWithDefinedProperties`: restore explicit command
/// properties after normalizing the state against neighbouring blocks.
fn block_input_state(
&mut self,
pos: [i32; 3],
input: &BlockInput,
state: BlockStateId,
strict: bool,
) -> R<BlockStateId> {
if strict {
return Ok(state);
}
let updated = self.with_level(|b, level, ticks| {
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
let updated = b.update_from_neighbour_shapes(level, ticks, at, state);
if b.is_air(updated) { state } else { updated }
});
let registry = self.pack.assets.registry;
let mut descriptor = registry
.state(updated)
.expect("registered block state")
.clone();
for (key, value) in &input.properties {
if descriptor.properties.contains_key(key) {
descriptor.properties.insert(key.clone(), value.clone());
}
}
registry
.resolve(&descriptor)
.map_err(|_| err("argument.block.id.invalid", &[&input.id]))
}
/// Place a state like BlockInput.place: false when nothing changed.
pub(super) fn place_state(
&mut self,
@@ -2388,6 +2441,7 @@ impl Game<'_> {
if ctx.has("destroy") && current != self.world.air {
self.destroy_block_dropping(pos)?;
}
let state = self.block_input_state(pos, &b, state, ctx.has("strict"))?;
let placed = self.place_state(pos, state, ctx.has("strict"))?;
let loaded = match &b.nbt {
Some(nbt) => self.load_block_entity_nbt(pos, nbt),
@@ -3841,6 +3895,7 @@ mod look_at_tests {
Target::Tnt(7),
Target::Falling(7),
Target::Rocket(7),
Target::Trident(7),
Target::Spawner(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
@@ -224,6 +224,11 @@ impl Game<'_> {
let r = self.rockets.iter().find(|r| r.id == id)?;
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
}
Target::Trident(id) => {
let uuid = self.uuid_for(id);
let t = self.tridents.iter().find(|t| t.id == id)?;
t.to_nbt(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;
@@ -856,6 +861,8 @@ impl Game<'_> {
Some(Target::Falling(id))
} else if self.rockets.iter().any(|r| r.id == id) {
Some(Target::Rocket(id))
} else if self.tridents.iter().any(|t| t.id == id) {
Some(Target::Trident(id))
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
Some(Target::Spawner(id))
} else {
@@ -7,7 +7,7 @@ use horizon_client::{
text::Text,
worldborder::MAX_SIZE,
};
use horizon_core::random::RandomSource;
use horizon_core::{position::ChunkPos, random::RandomSource};
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
@@ -41,7 +41,8 @@ impl Game<'_> {
match id {
"forceload.query" if ctx.get("pos").is_none() => {
let mut forced: Vec<(i32, i32)> = self.streaming.forced.iter().copied().collect();
forced.sort_unstable();
// ForceLoadCommand sorts its LongStream before formatting chunks.
forced.sort_unstable_by_key(|&(x, z)| ChunkPos::new(x, z).pack());
if forced.is_empty() {
self.command_failure(Text::translate("commands.forceload.added.none", &[dim]));
return Ok(0);
+14 -1
View File
@@ -402,6 +402,19 @@ impl Game<'_> {
at: [f64; 3],
persistent: bool,
scripted: bool,
) -> Option<u32> {
self.spawn_mob_reason(kind, at, persistent, scripted, !persistent)
}
/// `spawn_mob_for` with the spawn reason's NATURAL flag explicit: only a
/// natural spawn rolls the jockey mounts of zombie horses, husks and
/// drowned (`reinforcement` and spawner spawns pass `false`).
pub(super) fn spawn_mob_reason(
&mut self,
kind: &str,
at: [f64; 3],
persistent: bool,
scripted: bool,
natural: bool,
) -> Option<u32> {
let kind = if kind.contains(':') {
kind.to_owned()
@@ -437,7 +450,7 @@ impl Game<'_> {
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
self.spawn_jockeys(id, true);
self.spawn_jockeys(id, natural);
}
Some(id)
}
@@ -1736,6 +1736,14 @@ impl Game<'_> {
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
// ServerPlayer.onEffectAdded and onEffectUpdated send an effect
// the command gave as it was given: vanilla's console runs after
// the players' tick (`DedicatedServer.tickConnection`), so the
// next tick is the first to count it down. A player's own
// commands sync theirs the same way (`run_guest_command`).
if let Some(h) = self.lan.as_mut() {
h.hub.sync_all_effects();
}
}
}
}
+3
View File
@@ -513,6 +513,9 @@ impl Game<'_> {
// `/summon ender_dragon` outside the End: no fight, but the dragon flies.
self.tick_fight();
}
// The spikes' crystals (placed above) join before they light a fire:
// a mod that cancels one keeps its fire out too.
self.native_joins_flush(false, 0);
self.tick_crystals();
self.tick_eyes();
if self.dim == Dim::End {
+16 -2
View File
@@ -996,8 +996,7 @@ impl Entity for ThrownTrident {
!self.in_ground || self.previous != self.position
}
fn selector_target(&self) -> Option<Target> {
// No `Target` variant for it yet: `@e` does not find tridents.
None
Some(Target::Trident(self.id))
}
/// Like any arrow (`AbstractArrow.push`) not pushed while in the ground.
fn pushed_by_explosion(&self) -> bool {
@@ -1078,3 +1077,18 @@ mod no_gravity_stand_tests {
assert!(!stand.triggers_blocks());
}
}
#[cfg(test)]
mod trident_selector_tests {
use super::Entity as _;
use crate::game::{commands::Target, trident::ThrownTrident};
use horizon_core::geometry::Vec3;
/// `@e`, `/kill` and `/data` reach a thrown trident like any other entity
/// (`EntityTypeTest.forClass(Entity.class)`: every entity in the level).
#[test]
fn a_thrown_trident_is_a_selector_target() {
let t = ThrownTrident::remote(9, Vec3::new(0.5, 100.0, 0.5), 0.0, 0.0);
assert_eq!(t.selector_target(), Some(Target::Trident(9)));
}
}
+26 -3
View File
@@ -17,6 +17,14 @@ use horizon_client::{
/// `Abilities.walkingSpeed`.
const WALKING_SPEED: f32 = 0.1;
fn valid_fov_modifier(modifier: f32, fallback: f32) -> f32 {
if modifier.is_finite() && (0.01..=10.0).contains(&modifier) {
modifier
} else {
fallback
}
}
impl Game<'_> {
/// `Player.isScoping`: using a spyglass.
pub(super) fn scoping(&self) -> bool {
@@ -75,9 +83,12 @@ impl Game<'_> {
pub(super) fn tick_fov(&mut self) {
let first_person = self.camera_type.first_person();
let effect = self.fov_effect_scale();
let target = self
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let target = self.fov_player().map_or(1.0, |player| {
let base = field_of_view_modifier(&player, first_person, effect);
let mut modifier = base;
self.native_compute_fov_modifier(&player, base, &mut modifier);
valid_fov_modifier(modifier, base)
});
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
@@ -219,3 +230,15 @@ impl Game<'_> {
hud_layout::paint(batch, &self.gui.atlas, &draws);
}
}
#[cfg(test)]
mod tests {
use super::valid_fov_modifier;
#[test]
fn modifier_validation() {
assert_eq!(valid_fov_modifier(0.5, 1.0), 0.5);
for value in [f32::NAN, f32::INFINITY, 0.0, 10.1] {
assert_eq!(valid_fov_modifier(value, 1.0), 1.0);
}
}
}
+7
View File
@@ -593,6 +593,13 @@ impl Game<'_> {
}
}
self.cull_entity_vertices(out, entity_start);
if let Some(stack) = &f.item {
let mut canceled = false;
self.native_render_item_in_frame(f.id, f.pos, stack, &mut canceled);
if canceled {
continue;
}
}
if let Some(id) = map {
// ItemFrameRenderer: the map fills the face, in 90-degree steps.
let depth = if f.invisible { 0.5 } else { 0.4375 };
+6 -1
View File
@@ -155,7 +155,12 @@ impl Game<'_> {
if let Some(id) = self.spawn_mob("minecraft:happy_ghast", at, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.extra.age = -24000;
// DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity, so ageBoundaryReached runs the ghastling setup
// (`babyGhastSetup`) while the mob is still being hatched: its
// goals, navigation and brain are a ghastling's before its first
// tick, not an adult's until the next aiStep.
horizon_client::mobs::finalize::set_age(m, -24000);
m.yaw = yaw;
m.body_yaw = yaw;
m.head_yaw = yaw;
@@ -2174,6 +2174,17 @@ impl Game<'_> {
{
return;
}
// LeashFenceKnotEntity.interact: a knot has no entity here, only its fence.
if let Some(knot) = self.knot_at_id(p.id) {
let hand = if p.hand == 1 { Hand::Off } else { Hand::Main };
let swung = self.as_guest(host, conn, |g| {
let swings = g.swings_started;
g.with_hand(hand, |g| g.use_knot(knot, true));
g.swings_started != swings
});
self.lan_swing_back(host, conn, swung);
return;
}
let before = host.hub.guests[gi].vehicle;
if self
.look_at_entity(host, gi, p.id, Some(p.location))
@@ -2958,6 +2969,15 @@ impl Game<'_> {
if let Some(b) = self.tracked_special_box(id) {
return Some(b);
}
if let Some(knot) = self.knot_at_id(id) {
let knot = horizon_client::mobs::leash::knot_state(knot);
let p = knot.position;
let half = knot.width / 2.0;
return Some(Aabb::new(
Vec3::new(p.x - half, p.y, p.z - half),
Vec3::new(p.x + half, p.y + knot.height, p.z + half),
));
}
self.creatures
.projectiles
.iter()
@@ -2965,6 +2985,14 @@ impl Game<'_> {
.map(|p| p.bounds())
}
/// The fence of the knot whose entity id is `id` (`lan_assign_ids`).
fn knot_at_id(&self, id: i32) -> Option<[i32; 3]> {
self.knot_ids
.iter()
.find(|&(_, &knot)| knot as i32 == id)
.map(|(&fence, _)| fence)
}
fn guest_attack(&mut self, host: &mut LanHost, conn: u32, id: i32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
@@ -596,6 +596,12 @@ impl Game<'_> {
Some(g) => g.entity_id,
None => owner(p.owner),
};
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.baseTick's
// `setSharedFlagOnFire(remainingFireTicks > 0)`, which a client's
// isOnFire reads to draw the flames (a Flame bow's arrow).
if p.arrow.as_ref().is_some_and(|a| a.on_fire()) {
e.meta.push((0, serializer::BYTE, Value::Byte(1)));
}
match &p.kind {
K::Arrow { critical, .. } => {
let a = p.arrow.as_deref();
+10 -23
View File
@@ -337,7 +337,7 @@ impl Game<'_> {
}
false
}
/// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` on a fence.
/// `LeadItem.useOn` on the fence the crosshair hits.
pub(super) fn interact_knot(&mut self) -> bool {
let Some(hit) = self.pick() else {
return false;
@@ -355,7 +355,15 @@ impl Game<'_> {
{
return false;
}
let knot_pos = hit.pos;
// `FenceBlock.useWithoutItem` / `LeadItem.useOn` only tie the player's own mobs
// (`LeadItem.bindPlayerMobs`). A client whose crosshair met the knot sends Interact with
// the knot's id instead. Taking a mob back here would undo a tie: the client sends the
// click once for each hand.
self.use_knot(hit.pos, false)
}
/// A click at the fence at `knot_pos`: `knot_hit` for `LeashFenceKnotEntity.interact`,
/// otherwise `LeadItem.useOn`. Returns whether it was used.
pub(super) fn use_knot(&mut self, knot_pos: [i32; 3], knot_hit: bool) -> bool {
let knot = leash::knot_state(knot_pos).center;
let center = glam::DVec3::new(knot.x, knot.y, knot.z);
let at = [center.x, center.y, center.z];
@@ -363,27 +371,6 @@ impl Game<'_> {
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
.collect();
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
// post inside that box went through the knot first. Any other click on
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
let box_ = leash::knot_state(knot_pos);
let half = box_.width / 2.0 + 1.0e-6;
(hit.point[0] - box_.position.x).abs() <= half
&& (hit.point[2] - box_.position.z).abs() <= half
&& hit.point[1] >= box_.position.y - 1.0e-6
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
// The knot is an entity: a hit on it counts only within the
// entity interaction range (`filterHitResult`), shorter than
// the block range the fence was hit at.
&& self.eye_position().is_some_and(|eye| {
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
})
};
let held_item = self.held().map(|s| s.item.to_string());
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
// Shears snip every lead on the knot.
+24
View File
@@ -94,6 +94,29 @@ impl Game<'_> {
.integrated
.as_ref()
.and_then(|i| i.call(|g| (g.level_census(), g.section_addresses())));
// The server thread's generation: proto chunks waiting for their
// neighbours, and the light jobs' views of them.
let generation = self.integrated.as_ref().and_then(|i| {
i.call(|g| {
let c = g.streaming.proto_census();
let [blocks, lists, heightmaps, other, biomes, records] = c.bytes;
json!({
"protos": c.protos,
"protos_mb": mb(c.bytes.iter().sum::<usize>()),
"proto_blocks_mb": mb(blocks),
"proto_section_lists_mb": mb(lists),
"proto_heightmaps_mb": mb(heightmaps),
"proto_other_terrain_mb": mb(other),
"proto_biomes_mb": mb(biomes),
"proto_records_mb": mb(records),
"proto_sections_by_bits": c.sections_by_bits,
"light_views": c.light_views,
"light_views_mb": mb(c.light_view_bytes),
"caches": c.caches.iter().map(|(name, n, bytes)| json!([name, n, bytes.map(mb)])).collect::<Vec<_>>(),
"stages": g.streaming.stages().iter().map(|(k, v)| json!([k, v])).collect::<Vec<_>>(),
})
})
});
// The window's sections the server thread holds too (lan/handoff.rs).
let shared = server.as_ref().map(|(_, at)| {
let (sections, bytes) = shared(self.all_worlds(), at);
@@ -136,6 +159,7 @@ impl Game<'_> {
"client_columns": census_json(&client),
"server_columns": server.as_ref().map(|(c, _)| census_json(c)),
"shared_with_server": shared,
"server_generation": generation,
"far_tiles": self.lod.as_ref().map(|lod| {
let (active, cached) = lod.stats();
json!({ "active": active, "cached": cached })
+24
View File
@@ -489,4 +489,28 @@ impl Host for super::Game<'_> {
fn instance_dir(&self) -> PathBuf {
self.native_instance_dir()
}
fn camera(&self) -> Option<NativeCamera> {
self.native_camera()
}
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.native_block_collision(pos)
}
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
self.native_item_container_slots(stack)
}
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
self.native_item_container_insert(stack, item)
}
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
}
@@ -117,6 +117,7 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Tnt(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Spawner(id) => Some(id),
Target::Player | Target::Guest(_) => None,
}
@@ -343,6 +344,9 @@ impl Game<'_> {
self.persist.live.push((id, kind.into_owned(), true));
}
}
// Ids taken and never announced (the dragon's parts, another level's
// entities) must not keep the next flush walking.
self.persist.joined_mark = self.persist.joined_mark.max(self.next_entity);
kept
}
/// The entity with the lowest id above the last one announced.
+282 -1
View File
@@ -881,7 +881,7 @@ use super::{
native_panel_recipes::RecipePage,
};
use horizon_client::mod_api::{
self, NativeCommand, NativeDispensed, NativeLifecycleContext, NativeServerPlayer,
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext, NativeServerPlayer,
ServerTickPhase, WorldPhase,
};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
@@ -936,6 +936,106 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
pub(super) struct ObservedScreen {
id: &'static str,
menu: Option<String>,
title: Option<Text>,
rect: [f32; 4],
slots: usize,
mouse: [f32; 2],
partial_tick: f32,
}
impl ObservedScreen {
fn view(&self) -> NativeScreenView<'_> {
NativeScreenView {
id: self.id,
menu: self.menu.as_deref(),
title: self.title.as_ref(),
rect: self.rect,
slots: self.slots,
mouse: self.mouse,
partial_tick: self.partial_tick,
}
}
}
fn observed_overlay_id(overlays: &[(bool, &'static str)]) -> Option<&'static str> {
overlays.iter().find_map(|(open, id)| open.then_some(*id))
}
impl Game<'_> {
fn observed_screen_view(&self) -> Option<NativeScreenView<'_>> {
let book = match &self.book {
Some(super::books::BookScreen::Edit { signing: true, .. }) => Some("book_sign"),
Some(super::books::BookScreen::Edit { .. }) => Some("book_edit"),
Some(super::books::BookScreen::View {
lectern: Some(_), ..
}) => Some("lectern"),
Some(super::books::BookScreen::View { .. }) => Some("book_view"),
None => None,
};
let overlay = observed_overlay_id(&[
(self.command_prompt.is_some(), "command_prompt"),
(self.link_prompt.is_some(), "link_prompt"),
(self.code_of_conduct.is_some(), "code_of_conduct"),
(self.server_packs.prompt.is_some(), "server_pack_prompt"),
(self.dialog_open(), "dialog"),
(book.is_some(), book.unwrap_or("book_view")),
(self.sign_edit.is_some(), "sign_editor"),
]);
let Some(id) = overlay else {
return self.screen_view_now();
};
let renderer = self.renderer.as_ref()?;
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(NativeScreenView {
id,
menu: None,
title: None,
rect: [0.0, 0.0, batch.width, batch.height],
slots: 0,
mouse: self.native_cursor(),
partial_tick: self.clock.fraction() as f32,
})
}
pub(super) fn observe_screen(&mut self) {
// Free with no native mod: the snapshot clones the title and menu.
if mod_api::mods().is_empty() {
return;
}
let current = self.observed_screen_view().map(|view| ObservedScreen {
id: view.id,
menu: view.menu.map(str::to_owned),
title: view.title.cloned(),
rect: view.rect,
slots: view.slots,
mouse: view.mouse,
partial_tick: view.partial_tick,
});
let changed = match (&self.observed_screen, &current) {
(Some(old), Some(new)) => old.id != new.id || old.menu != new.menu,
(None, None) => false,
_ => true,
};
if changed {
if let Some(old) = &self.observed_screen {
self.native_screen_closing(&old.view());
}
if let Some(new) = &current {
self.native_screen_opening(&new.view());
self.native_screen_init_post(&new.view());
}
} else if let (Some(old), Some(new)) = (&self.observed_screen, &current)
&& old.rect != new.rect
{
self.native_screen_init_post(&new.view());
}
self.observed_screen = current;
}
}
/// `Level.setBlock`'s flags, for the `native_set_block_property` helpers:
/// `flags::UPDATE_CLIENTS` as a vanilla crop grows, `flags::UPDATE_ALL` as a
/// furnace sets its `lit`. Horizon sends every write to players, so
@@ -1647,6 +1747,27 @@ const SHAKE_MAX_DEGREES: f32 = 10.0;
/// The largest offset a shake gives, in blocks, along each axis.
const SHAKE_MAX_OFFSET: f32 = 0.5;
/// The pose a native mod asks for the first-person hand, each out-of-range
/// or non-finite component replaced by its identity.
pub(super) fn hand_pose_matrix(values: [f32; 7]) -> glam::Mat4 {
let valid = |value: f32, min: f32, max: f32, default: f32| {
if value.is_finite() && (min..=max).contains(&value) {
value
} else {
default
}
};
let [x, y, z, pitch, yaw, roll, scale] = values;
glam::Mat4::from_translation(glam::Vec3::new(
valid(x, -16.0, 16.0, 0.0),
valid(y, -16.0, 16.0, 0.0),
valid(z, -16.0, 16.0, 0.0),
)) * glam::Mat4::from_rotation_x(valid(pitch, -3600.0, 3600.0, 0.0).to_radians())
* glam::Mat4::from_rotation_y(valid(yaw, -3600.0, 3600.0, 0.0).to_radians())
* glam::Mat4::from_rotation_z(valid(roll, -3600.0, 3600.0, 0.0).to_radians())
* glam::Mat4::from_scale(glam::Vec3::splat(valid(scale, 0.01, 100.0, 1.0)))
}
/// `shake` scaled by `scale` (Distortion Effects) and bounded on each axis;
/// a value that isn't finite counts as none.
fn bounded_shake(shake: NativeShake, scale: f32) -> NativeShake {
@@ -2309,6 +2430,16 @@ impl Game<'_> {
pub(super) fn native_commands(&mut self, commands: &mut Vec<&'static str>) {
mod_api::dispatch_commands(self, mod_api::mods(), commands);
}
/// Player FOV scale, after vanilla effects and before zoom and easing.
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
pub(super) fn native_compute_fov_modifier(
&self,
player: &horizon_client::camera::FovPlayer,
base: f32,
modifier: &mut f32,
) {
mod_api::dispatch_compute_fov_modifier(self, mod_api::mods(), player, base, modifier);
}
/// Local player movement input, after reading it and before applying it.
pub(super) fn native_movement_input(
&self,
@@ -2387,6 +2518,60 @@ impl Game<'_> {
);
}
/// Additional first-person hand and item pose, including empty arms and maps.
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
/// API @API:render_hand_pose@.
pub(super) fn native_render_hand_pose(
&self,
hand: horizon_client::first_person::Hand,
stack: Option<&ItemStack>,
partial: f32,
swing: f32,
equip: f32,
x: &mut f32,
y: &mut f32,
z: &mut f32,
pitch: &mut f32,
yaw: &mut f32,
roll: &mut f32,
scale: &mut f32,
) {
mod_api::dispatch_render_hand_pose(
self,
mod_api::mods(),
hand,
stack,
partial,
swing,
equip,
x,
y,
z,
pitch,
yaw,
roll,
scale,
);
}
/// Before a visible item frame's item or map is drawn. Cancel hides the item, not the frame.
pub(super) fn native_render_item_in_frame(
&self,
entity_id: u32,
pos: [i32; 3],
stack: &ItemStack,
canceled: &mut bool,
) {
mod_api::dispatch_render_item_in_frame(
self,
mod_api::mods(),
entity_id,
pos,
stack,
canceled,
);
}
/// Final scaled third-person camera distance, before visual-shape collision clipping.
/// `entity` includes its living scale/distance and optional mount scale/distance.
/// Only finite requests in 0..=1024 blocks replace the vanilla distance.
@@ -2642,6 +2827,18 @@ impl Game<'_> {
) {
mod_api::dispatch_item_energy_buffer(self, mod_api::mods(), item, buffer);
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
pub(super) fn native_item_container(&self, item: &str, slots: &mut Option<u32>) {
mod_api::dispatch_item_container(self, mod_api::mods(), item, slots);
}
/// A server tick of the block entity at `pos`, in vanilla's order among
/// the others (`Level.tickBlockEntities`), when its `id` is yours: read
/// and change its saved compound `nbt`, its item slots `items` (keep
@@ -2840,6 +3037,20 @@ impl Game<'_> {
) {
mod_api::dispatch_screen_draw(self, mod_api::mods(), block, data, batch, origin);
}
/// A screen was observed opening, at most once per frame.
pub(super) fn native_screen_opening(&self, screen: &NativeScreenView<'_>) {
mod_api::dispatch_screen_opening(self, mod_api::mods(), screen);
}
/// A screen was observed opening or changing rectangle, at most once per frame.
pub(super) fn native_screen_init_post(&self, screen: &NativeScreenView<'_>) {
mod_api::dispatch_screen_init_post(self, mod_api::mods(), screen);
}
/// A screen was observed closing, at most once per frame.
pub(super) fn native_screen_closing(&self, screen: &NativeScreenView<'_>) {
mod_api::dispatch_screen_closing(self, mod_api::mods(), screen);
}
/// The open screen has drawn itself and its tooltips (`Screen.render`,
/// the point of `ScreenEvent.Render.Post`): draw over it with
/// `native_fill`, `native_sprite` and `native_text`, `screen.rect` being
@@ -5567,6 +5778,24 @@ impl Game<'_> {
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
mod_api::dispatch_tooltip(self, mod_api::mods(), stack, lines);
}
/// Change a tooltip box's background and border gradient colors, as ARGB.
/// Stack is None for tooltips not associated with an item.
pub(super) fn native_tooltip_color(
&self,
stack: Option<&ItemStack>,
background: &mut u32,
border_top: &mut u32,
border_bottom: &mut u32,
) {
mod_api::dispatch_tooltip_color(
self,
mod_api::mods(),
stack,
background,
border_top,
border_bottom,
);
}
/// Client-side image component for this stack's tooltip. Answer its width/height
/// in GUI pixels; finite positive dimensions reserve space after text and any
/// vanilla bundle component. None keeps the vanilla tooltip unchanged.
@@ -7984,6 +8213,22 @@ impl Game<'_> {
let state = self.world.block(pos[0], pos[1], pos[2]);
self.res.facts().get(state).name.to_string()
}
/// The most recently extracted world frame's camera, if one exists.
pub(super) fn native_camera(&self) -> Option<NativeCamera> {
self.native_camera_frame.clone()
}
/// Collision boxes in block-local coordinates with an empty collision context.
pub(super) fn native_block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
self.block_collision_shape(pos)
.boxes()
.iter()
.map(|bounds| {
let min = bounds.min_position();
let max = bounds.max_position();
[min.x, min.y, min.z, max.x, max.y, max.z]
})
.collect()
}
/// Property `key` of the block at `pos` (a machine's `facing`), if it
/// has one. API 20.
pub(super) fn native_block_property(&self, pos: [i32; 3], key: &str) -> Option<String> {
@@ -9243,6 +9488,42 @@ fn mod_screen_opens(
#[cfg(test)]
mod tests {
#[test]
fn hand_pose_rejects_invalid_components() {
let identity = super::hand_pose_matrix([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]);
assert_eq!(identity, glam::Mat4::IDENTITY);
assert_eq!(
super::hand_pose_matrix([
f32::NAN,
17.0,
f32::INFINITY,
3601.0,
-3601.0,
f32::NAN,
0.0
]),
identity
);
let point = super::hand_pose_matrix([1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0])
.transform_point3(glam::Vec3::X);
assert_eq!(point, glam::Vec3::new(3.0, 2.0, 3.0));
}
#[test]
fn observed_overlay_prefers_first_open_screen() {
assert_eq!(
super::observed_overlay_id(&[(false, "sign"), (false, "book")]),
None
);
assert_eq!(
super::observed_overlay_id(&[(false, "sign"), (true, "book")]),
Some("book")
);
assert_eq!(
super::observed_overlay_id(&[(true, "sign"), (true, "book")]),
Some("sign")
);
}
/// Exercise the gameplay constructor, where name-based entities compete
/// with a mod block's sign template.
#[test]
@@ -0,0 +1,370 @@
//! Items held by item stacks for native mods (docs/native-mods.md): a mod's
//! backpacks, storage cells and toolboxes (`native_item_container`), read,
//! filled and emptied. NeoForge's `Capabilities.Item.ITEM` is a
//! `ResourceHandler<ItemResource>` reached through an `ItemAccess`: it acts on
//! one item of a stack at a time and may change it. So do these, as the item
//! fluid and energy helpers do: they take a stack and answer without changing
//! it, and the caller shrinks its stack by one and hands out the changed item
//! they return. The contents live in `minecraft:container`, as a shulker box
//! item keeps them. They hold no world state, so they work on any side.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// `ItemContainerContents`' limit on slots.
const MAX_SLOTS: u32 = 256;
/// `Container.getMaxStackSize`.
const CONTAINER_MAX: u32 = 99;
/// The slots of one item of `stack`, empty ones included; slots past `size`
/// are dropped, as the item has no room for them.
fn contents(stack: &ItemStack, size: u32) -> Vec<Option<ItemStack>> {
let mut slots = vec![None; size as usize];
for (slot, item) in stack.data().map_or(&[][..], |d| &d.container[..]) {
if let Some(s) = slots.get_mut(usize::from(*slot))
&& item.count > 0
{
*s = Some(item.clone());
}
}
slots
}
/// One item of `stack`, unchanged.
fn one_of(stack: &ItemStack) -> ItemStack {
let mut one = stack.clone();
one.count = 1;
one
}
/// One item of `stack` holding `slots`, its other data kept. An empty item
/// carries nothing, so it stacks with a new one.
fn with_contents(stack: &ItemStack, slots: &[Option<ItemStack>]) -> ItemStack {
let mut one = one_of(stack);
let data = one.data_mut();
data.container = slots
.iter()
.enumerate()
.filter_map(|(i, s)| Some((i as u8, s.clone().filter(|s| s.count > 0)?)))
.collect();
// What a server sent for the stack no longer describes it; kept, an
// emptied item would not stack with a new one.
data.wire = Default::default();
one.tidy();
one
}
/// `SimpleContainer.addItem` on one item of `stack`: `item` tops up the
/// slots that hold the same item and components, then goes into the first
/// empty ones. Unlike `SimpleContainer`, which puts the whole rest in the
/// first empty slot and cuts it to the limit, no slot takes more than its
/// limit (`limit`), and what doesn't fit stays out. A shulker box never goes
/// in (`Item.canFitInsideContainerItems`). The count that went in, and the
/// item it becomes.
fn insert_one(
stack: &ItemStack,
size: Option<u32>,
item: &ItemStack,
limit: u32,
) -> Option<(u32, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
if item.count == 0 || !horizon_client::menu::fits_inside_container_items(item) {
return Some((0, one_of(stack)));
}
let mut slots = contents(stack, size);
let mut left = item.count;
for held in slots.iter_mut().flatten() {
if left == 0 {
break;
}
if held.same_item_same_components(item) {
let room = limit.saturating_sub(held.count).min(left);
held.count += room;
left -= room;
}
}
for slot in slots.iter_mut().filter(|s| s.is_none()) {
if left == 0 {
break;
}
let put = limit.min(left);
*slot = Some(ItemStack {
count: put,
..item.clone()
});
left -= put;
}
let moved = item.count - left;
if moved == 0 {
return Some((0, one_of(stack)));
}
Some((moved, with_contents(stack, &slots)))
}
/// Up to `max` out of `slot` of one item of `stack`: what came out (none
/// when nothing did), and the item it becomes.
fn extract_one(
stack: &ItemStack,
size: Option<u32>,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = size?;
if stack.count == 0 {
return None;
}
let mut slots = contents(stack, size);
let Some(Some(held)) = slots.get_mut(slot) else {
return Some((None, one_of(stack)));
};
let take = held.count.min(max);
if take == 0 {
return Some((None, one_of(stack)));
}
let taken = ItemStack {
count: take,
..held.clone()
};
held.count -= take;
Some((Some(taken), with_contents(stack, &slots)))
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The slots a mod gave item `item`, clamped; none for any other item.
fn item_container_size(&self, item: &str) -> Option<u32> {
if item.starts_with("minecraft:") {
return None;
}
let mut slots = None;
self.native_item_container(item, &mut slots);
slots.filter(|s| *s >= 1).map(|s| s.min(MAX_SLOTS))
}
/// `Container.getMaxStackSize(stack)`: the container's 99, or the stack's
/// own limit (its `max_stack_size` component, else the item's) when less.
fn item_container_limit(&self, stack: &ItemStack) -> u32 {
let own = match stack
.data()
.and_then(|d| d.other("minecraft:max_stack_size"))
{
Some(Tag::Int(n)) => (*n).clamp(1, 99) as u32,
_ => self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack),
};
own.min(CONTAINER_MAX)
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
pub(in crate::game) fn native_item_container_slots(
&self,
stack: &ItemStack,
) -> Option<Vec<Option<ItemStack>>> {
let size = self.item_container_size(&stack.item)?;
(stack.count > 0).then(|| contents(stack, size))
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
pub(in crate::game) fn native_item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let size = self.item_container_size(&stack.item);
insert_one(stack, size, item, self.item_container_limit(item))
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
pub(in crate::game) fn native_item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let size = self.item_container_size(&stack.item);
extract_one(stack, size, slot, max)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_world::nbt::JavaString;
use std::sync::Arc;
fn bag(count: u32) -> ItemStack {
ItemStack::new("mymod:bag", count)
}
fn of(item: &str, count: u32) -> ItemStack {
ItemStack::new(item, count)
}
/// (slot, item, count) of what one item holds.
fn held(stack: &ItemStack, size: u32) -> Vec<(usize, String, u32)> {
contents(stack, size)
.into_iter()
.enumerate()
.filter_map(|(i, s)| s.map(|s| (i, s.item.to_string(), s.count)))
.collect()
}
#[test]
fn an_insert_tops_up_equal_stacks_before_empty_slots() {
let (_, one) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 10), 64).unwrap();
let (_, one) = insert_one(&one, Some(3), &of("minecraft:dirt", 5), 64).unwrap();
let (moved, one) = insert_one(&one, Some(3), &of("minecraft:stone", 60), 64).unwrap();
// 54 top up slot 0 to 64, the other 6 take the first empty slot.
assert_eq!(moved, 60);
assert_eq!(
held(&one, 3),
[
(0, "minecraft:stone".into(), 64),
(1, "minecraft:dirt".into(), 5),
(2, "minecraft:stone".into(), 6),
]
);
}
#[test]
fn no_slot_passes_its_limit_and_the_rest_stays_out() {
// 16 per slot (ender pearls), two slots: 32 of 40 go in.
let (moved, one) =
insert_one(&bag(1), Some(2), &of("minecraft:ender_pearl", 40), 16).unwrap();
assert_eq!(moved, 32);
assert_eq!(
held(&one, 2),
[
(0, "minecraft:ender_pearl".into(), 16),
(1, "minecraft:ender_pearl".into(), 16),
]
);
// Full: nothing goes in, and the item comes back as it was.
assert_eq!(
insert_one(&one, Some(2), &of("minecraft:ender_pearl", 1), 16).unwrap(),
(0, one.clone())
);
}
#[test]
fn different_components_never_share_a_slot() {
let mut named = of("minecraft:stone", 1);
named.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Rock"))),
);
let (_, one) = insert_one(&bag(1), Some(2), &of("minecraft:stone", 1), 64).unwrap();
let (moved, one) = insert_one(&one, Some(2), &named, 64).unwrap();
assert_eq!(moved, 1);
assert_eq!(held(&one, 2).len(), 2);
}
#[test]
fn a_shulker_box_never_goes_in() {
let shulker = of("minecraft:red_shulker_box", 1);
assert_eq!(
insert_one(&bag(1), Some(9), &shulker, 1).unwrap(),
(0, bag(1))
);
// Another container item does.
assert_eq!(insert_one(&bag(1), Some(9), &bag(1), 64).unwrap().0, 1);
}
#[test]
fn an_extract_takes_up_to_max_and_an_emptied_bag_is_new_again() {
let (_, full) = insert_one(&bag(1), Some(4), &of("minecraft:apple", 20), 64).unwrap();
let (taken, rest) = extract_one(&full, Some(4), 0, 15).unwrap();
assert_eq!(taken.map(|t| t.count), Some(15));
assert_eq!(held(&rest, 4), [(0, "minecraft:apple".into(), 5)]);
let (taken, empty) = extract_one(&rest, Some(4), 0, 64).unwrap();
assert_eq!(
taken.map(|t| (t.item.to_string(), t.count)),
Some(("minecraft:apple".into(), 5))
);
assert_eq!(empty, bag(1));
// An empty slot, or one past the bag's slots, gives nothing.
assert_eq!(extract_one(&empty, Some(4), 0, 1).unwrap(), (None, bag(1)));
assert_eq!(
extract_one(&full, Some(4), 7, 1).unwrap(),
(None, full.clone())
);
}
#[test]
fn the_stack_given_stays_and_one_item_comes_back() {
let stack = bag(3);
let (moved, one) = insert_one(&stack, Some(1), &of("minecraft:stone", 1), 64).unwrap();
assert_eq!((moved, one.count, stack.count), (1, 1, 3));
assert_eq!(stack, bag(3));
}
#[test]
fn items_without_slots_hold_nothing() {
assert_eq!(
insert_one(&bag(1), None, &of("minecraft:stone", 1), 64),
None
);
assert_eq!(extract_one(&bag(1), None, 0, 1), None);
assert_eq!(
insert_one(&bag(0), Some(3), &of("minecraft:stone", 1), 64),
None
);
// Nothing to insert moves nothing.
assert_eq!(
insert_one(&bag(1), Some(3), &of("minecraft:stone", 0), 64).unwrap(),
(0, bag(1))
);
}
#[test]
fn slots_past_the_size_are_dropped() {
// A mod that shrank its bag after it was filled.
let (_, big) = insert_one(&bag(1), Some(3), &of("minecraft:stone", 64 * 3), 64).unwrap();
assert_eq!(held(&big, 2).len(), 2);
assert_eq!(extract_one(&big, Some(2), 2, 64).unwrap().0, None);
}
#[test]
fn an_emptied_bag_from_a_server_stacks_with_a_new_one() {
let mut sent = bag(2);
sent.data_mut().wire = horizon_client::item_data::Wire::new(bag(2), Arc::from(vec![1u8]));
let (_, filled) = insert_one(&sent, Some(1), &of("minecraft:stone", 3), 64).unwrap();
let (_, emptied) = extract_one(&filled, Some(1), 0, 3).unwrap();
assert!(emptied.same_item_same_components(&bag(1)));
}
#[test]
fn a_bag_keeps_the_rest_of_its_data() {
let mut stack = bag(1);
stack.data_mut().set_other(
"minecraft:custom_name",
Some(Tag::String(JavaString::from("Kit"))),
);
let (_, filled) = insert_one(&stack, Some(2), &of("minecraft:stone", 2), 64).unwrap();
assert!(
filled
.data()
.and_then(|d| d.other("minecraft:custom_name"))
.is_some()
);
assert_eq!(held(&filled, 2), [(0, "minecraft:stone".into(), 2)]);
let (_, emptied) = extract_one(&filled, Some(2), 0, 2).unwrap();
assert_eq!(emptied, stack);
}
}
@@ -365,8 +365,7 @@ impl Game<'_> {
if self.carried.is_some() {
self.draw_carried(batch);
} else if let Some(stack) = self.browser_pointed_stack(&layout) {
let lines = self.stack_tooltip(&stack.1);
self.draw_tooltip_lines(batch, &lines, cursor.0, cursor.1);
self.draw_stack_tooltip(batch, &stack.1, cursor.0, cursor.1);
} else {
self.draw_slot_tooltip(batch);
}
+1 -1
View File
@@ -611,7 +611,7 @@ impl Game<'_> {
}
horizon_client::graphics::Pacing::Efficient => {
let mut clock = horizon_client::window::SleepingClock(self.frame_clock);
horizon_client::window::end_frame(&mut self.limiter, limit, &mut clock);
horizon_client::window::end_frame_on_grid(&mut self.limiter, limit, &mut clock);
}
}
}
+55 -3
View File
@@ -24,6 +24,26 @@ fn zombie_kind(s: Species) -> bool {
)
}
/// Which `finalizeSpawn` mounts a mob may roll for its spawn reason.
#[derive(Debug, PartialEq, Eq)]
struct MountRolls {
zombie_horse_rider: bool,
camel_husk: bool,
nautilus: bool,
}
/// `ZombieHorse.finalizeSpawn` (NATURAL only), `Husk.finalizeSpawn`
/// (`triedToSpawnCamelHusk = reason != NATURAL`) and `Drowned.finalizeSpawn`
/// (never a baby; vanilla also allows STRUCTURE, which comes from worldgen and
/// never reaches `spawn_jockeys`, so only `natural` is modelled here).
fn mount_rolls(species: Species, baby: bool, natural: bool) -> MountRolls {
MountRolls {
zombie_horse_rider: natural && species == Species::ZombieHorse,
camel_husk: natural && species == Species::Husk,
nautilus: natural && species == Species::Drowned && !baby,
}
}
impl Game<'_> {
/// `Entity.startRiding` for two mobs.
pub(super) fn mount_mob(&mut self, rider: u32, vehicle: u32) {
@@ -333,6 +353,7 @@ impl Game<'_> {
};
let (species, baby) = (m.species(), m.is_baby());
let at = [m.position.x, m.position.y, m.position.z];
let mounts = mount_rolls(species, baby, natural);
match species {
s if zombie_kind(s) && baby => {
if self.world.random.next_float() < CHICKEN_JOCKEY_ODDS {
@@ -358,7 +379,7 @@ impl Game<'_> {
// comes from EntityType.create, which makes no zombie in peaceful
// (canSpawn), so a peaceful zombie horse spawns without one.
Species::ZombieHorse
if natural
if mounts.zombie_horse_rider
&& (self.ui.difficulty != 0
|| horizon_client::mobs::spawn_rules::allowed_in_peaceful(
Species::Zombie,
@@ -375,8 +396,8 @@ impl Game<'_> {
}
// After Zombie.finalizeSpawn's chickens: Husk and Drowned mounts.
match species {
Species::Husk if natural => self.spawn_camel_husk_jockey(id, at),
Species::Drowned if natural && !baby => self.spawn_zombie_nautilus_jockey(id, at),
Species::Husk if mounts.camel_husk => self.spawn_camel_husk_jockey(id, at),
Species::Drowned if mounts.nautilus => self.spawn_zombie_nautilus_jockey(id, at),
_ => {}
}
}
@@ -572,3 +593,34 @@ impl Game<'_> {
Some(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn spawner_and_reinforcement_spawns_get_no_mounts() {
for (species, baby) in [
(Species::ZombieHorse, false),
(Species::Husk, false),
(Species::Drowned, false),
] {
let none = MountRolls {
zombie_horse_rider: false,
camel_husk: false,
nautilus: false,
};
assert_eq!(mount_rolls(species, baby, false), none, "{species:?}");
}
}
#[test]
fn natural_spawns_roll_their_own_mount() {
let r = |s, b| mount_rolls(s, b, true);
assert!(r(Species::ZombieHorse, false).zombie_horse_rider);
assert!(r(Species::Husk, false).camel_husk);
assert!(r(Species::Drowned, false).nautilus);
assert!(!r(Species::Drowned, true).nautilus);
assert!(!r(Species::Zombie, false).camel_husk);
}
}
+7 -5
View File
@@ -504,8 +504,9 @@ impl Game<'_> {
let mut t = super::trident::ThrownTrident::from_nbt(n);
t.id = self.take_entity_id();
// One that came through a portal keeps its cooldown.
self.restore_portal_cooldown(t.id, &tag);
let id = t.id;
self.tridents.push(t);
self.remember(id, uuid, tag);
}
}
"minecraft:firework_rocket" => {
@@ -1581,10 +1582,11 @@ impl Game<'_> {
i: usize,
keys: &HashSet<ChunkKey>,
) -> Option<(ChunkKey, Tag)> {
let t = &self.tridents[i];
keys.contains(&chunk_of(t.position)).then(|| {
let (uuid, raw) = anon_uuid_raw(&self.persist, "minecraft:trident", t.position);
(chunk_of(t.position), t.to_nbt(uuid, raw.as_ref()))
let (id, at) = (self.tridents[i].id, self.tridents[i].position);
keys.contains(&chunk_of(at)).then(|| {
let uuid = self.uuid_for(id);
let tag = self.tridents[i].to_nbt(uuid, self.persist.raw.get(&id));
(chunk_of(at), tag)
})
}
pub(super) fn save_tnt(
+20 -21
View File
@@ -2189,19 +2189,6 @@ impl Game<'_> {
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// A column the client no longer holds; far terrain keeps it as it was
/// last (`server_terrain`).
fn forget_remote_chunk(
&mut self,
x: i32,
z: i32,
) -> Option<std::sync::Arc<horizon_client::chunk::Chunk>> {
let chunk = self.streaming.forget(&mut self.world, x, z)?;
if let Some(lod) = &mut self.lod {
lod.observe(chunk.clone(), self.res.facts());
}
Some(chunk)
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
@@ -2215,7 +2202,8 @@ impl Game<'_> {
}
/// Forgets the `gone` columns (and empties the set): their chunks (freed on a thread
/// of their own when there are many), their sections in the renderer and their block
/// entities, each store once for all of them.
/// entities, each store once for all of them. Far terrain keeps them as they were last
/// (`server_terrain`).
fn forget_remote_columns(
&mut self,
gone: &mut horizon_client::fast_hash::FxHashSet<(i32, i32)>,
@@ -2223,9 +2211,11 @@ impl Game<'_> {
if gone.is_empty() {
return;
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in gone.iter() {
freed.extend(self.forget_remote_chunk(x, z));
let freed = self.streaming.forget_columns(&mut self.world, gone);
if let Some(lod) = &mut self.lod {
for chunk in &freed {
lod.observe(chunk.clone(), self.res.facts());
}
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
@@ -4411,9 +4401,18 @@ impl Game<'_> {
C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p),
C::PlaceGhostRecipe(p) => self.remote_ghost_recipe(p),
C::MerchantOffers(p) => self.remote_merchant_offers(p),
C::ContainerClose(_) if self.remote_lectern_closed() => {}
C::ContainerClose(_) => {
if self.screen == Screen::Inventory {
// `LocalPlayer.clientSideCloseContainer` sets the screen to
// null, including dialogs and WaitingForResponseScreen. This
// differs from clearDialog, which restores the parent screen.
let dialog = self.dialog_open();
if dialog {
self.close_dialogs();
self.ui.chat_screen = None;
self.book = None;
self.sign_edit = None;
}
if !self.remote_lectern_closed() && (self.screen == Screen::Inventory || dialog) {
self.remote_close_local();
self.screen = Screen::Playing;
self.capture(true);
@@ -7154,7 +7153,7 @@ impl Game<'_> {
}
// Resolve the crosshair once. A passing entity interaction keeps
// that target and may only fall back to using the item itself.
let entity = self.entity_targeted();
let entity = self.remote_use_targets_entity();
horizon_client::interaction_input::use_on_target(&mut RemoteUseWorld(self), entity);
}
@@ -8192,7 +8191,7 @@ impl Game<'_> {
}
return;
}
let off_target = if self.entity_targeted() {
let off_target = if self.remote_use_targets_entity() {
self.remote_use_entity(Hand::Off)
} else {
self.remote_use_block(Hand::Off)
@@ -514,6 +514,14 @@ impl Game<'_> {
}
} else if let Some(p) = self.creatures.projectiles.iter_mut().find(|p| p.id == id) {
match (&mut p.kind, index, value) {
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on
// a client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(_, 0, Value::Byte(flags)) => {
if let Some(a) = p.arrow.as_mut() {
a.fire_ticks = i32::from(flags & 1);
}
}
// AbstractArrow.ID_FLAGS (critical) and IN_GROUND.
(ProjectileKind::Arrow { critical, .. }, 8, Value::Byte(flags)) => {
*critical = flags & 1 != 0;
@@ -806,7 +814,8 @@ impl Game<'_> {
}
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player)}));
"player_owned": p.owner == Some(horizon_client::mobs::Who::Player),
"on_fire": p.arrow.as_ref().is_some_and(|a| a.on_fire())}));
}
for (kind, p, _) in self.potions.thrown_entities() {
out.push(json!({"kind": kind, "position": at(p)}));
@@ -26,8 +26,39 @@ pub(super) struct ObjectHit {
}
impl Game<'_> {
/// BlockAttachedEntity.isPickable: the nearest fence knot's box the view meets, with its id,
/// distance and position. Its box is wider than the post inside it, so the crosshair meets it
/// first and LeashFenceKnotEntity.interact takes the click. The fence block behind only ties
/// the player's own mobs (LeadItem.useOn), it never takes one back.
fn remote_knot_hit(&self) -> Option<(i32, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
self.remote_knots
.iter()
.filter_map(|(&id, &block)| {
let knot = horizon_client::mobs::leash::knot_state(block);
let p = knot.position;
let half = knot.width / 2.0;
let bounds = Aabb::new(
Vec3::new(p.x - half, p.y, p.z - half),
Vec3::new(p.x + half, p.y + knot.height, p.z + half),
);
ray_box(eye, dir, self.entity_reach, bounds)
.map(|d| (id as i32, d, [p.x, p.y, p.z]))
})
.min_by(|a, b| a.1.total_cmp(&b.1))
}
/// Whether a use starts on an entity: the crosshair's `EntityHitResult`, or a fence knot.
/// Only a use: Horizon's attacks still go to the fence behind a knot.
pub(super) fn remote_use_targets_entity(&self) -> bool {
self.entity_targeted()
|| self
.remote_knot_hit()
.is_some_and(|(_, d, _)| self.before_block(d))
}
/// The nearest boat, minecart, item frame, armor stand or painting the
/// view hits within reach; also End crystals when attacking.
/// view hits within reach; also End crystals when attacking, and fence knots when using.
pub(super) fn remote_object_hit(&self, attacking: bool) -> Option<ObjectHit> {
let (eye, dir) = self.aim()?;
let at = |d: f64| {
@@ -80,6 +111,9 @@ impl Game<'_> {
hits.push((id, d, [p.x, p.y, p.z]));
}
}
if !attacking && let Some((id, d, p)) = self.remote_knot_hit() {
hits.push((id, d, p));
}
if attacking {
// EndCrystal.isPickable: its 2 x 2 box, with the server's entity
// id, not special_targets' local projectile target id.
@@ -182,6 +182,7 @@ impl Game<'_> {
| Target::Tnt(id)
| Target::Falling(id)
| Target::Rocket(id)
| Target::Trident(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// It keeps its UUID with itself.
Target::Spawner(id) => self
@@ -226,6 +227,7 @@ impl Game<'_> {
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Rocket(id)
| Target::Trident(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.
+1 -1
View File
@@ -148,7 +148,7 @@ impl Game<'_> {
self.native_mobs_joined(self.mobs.len() - 1, false);
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
self.spawn_jockeys(id, true);
self.spawn_jockeys(id, false);
Some((id, equipment))
}
Planned::Loaded(tag, equipment) => {
+30 -34
View File
@@ -2565,7 +2565,7 @@ impl Game<'_> {
let atlas = &self.gui.atlas;
let (x, y, w, h) =
Self::tooltip_geometry(atlas, &lines, (x, y), (batch.width, batch.height), [0.0; 2]);
Self::tooltip_box(batch, atlas, x, y, w, h);
self.tooltip_box(batch, None, x, y, w, h);
Self::tooltip_text(batch, atlas, &lines, x, y);
}
/// A tooltip's lines with their legacy codes resolved and their colours.
@@ -2605,47 +2605,43 @@ impl Game<'_> {
}
/// The box and its frame sprites.
pub(super) fn tooltip_box(
&self,
batch: &mut HudBatch,
atlas: &horizon_client::hud::HudAtlas,
stack: Option<&ItemStack>,
x: f32,
y: f32,
w: f32,
h: f32,
) {
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, rgba(16, 0, 16, 240));
batch.fill(
atlas,
x - 3.0,
y + h + 3.0,
w + 6.0,
1.0,
rgba(16, 0, 16, 240),
let (mut background, mut border_top, mut border_bottom) =
(0xf0100010, 0x505000ff, 0x5028007f);
self.native_tooltip_color(stack, &mut background, &mut border_top, &mut border_bottom);
let convert = |color: u32| {
let [a, r, g, b] = color.to_be_bytes();
rgba(r, g, b, a)
};
let (background, border_top, border_bottom) = (
convert(background),
convert(border_top),
convert(border_bottom),
);
batch.fill(
let atlas = &self.gui.atlas;
batch.fill(atlas, x - 3.0, y - 4.0, w + 6.0, 1.0, background);
batch.fill(atlas, x - 3.0, y + h + 3.0, w + 6.0, 1.0, background);
batch.fill(atlas, x - 4.0, y - 3.0, w + 8.0, h + 6.0, background);
batch.fill(atlas, x - 3.0, y - 3.0, w + 6.0, 1.0, border_top);
batch.fill(atlas, x - 3.0, y + h + 2.0, w + 6.0, 1.0, border_bottom);
batch.fill_gradient(
atlas,
x - 4.0,
y - 3.0,
w + 8.0,
h + 6.0,
rgba(16, 0, 16, 240),
(x - 3.0, y - 2.0, 1.0, h + 4.0),
border_top,
border_bottom,
);
batch.fill(atlas, x - 3.0, y - 3.0, w + 6.0, 1.0, rgba(80, 0, 255, 80));
batch.fill(
batch.fill_gradient(
atlas,
x - 3.0,
y + h + 2.0,
w + 6.0,
1.0,
rgba(40, 0, 127, 80),
);
batch.fill(atlas, x - 3.0, y - 2.0, 1.0, h + 4.0, rgba(60, 0, 190, 80));
batch.fill(
atlas,
x + w + 2.0,
y - 2.0,
1.0,
h + 4.0,
rgba(60, 0, 190, 80),
(x + w + 2.0, y - 2.0, 1.0, h + 4.0),
border_top,
border_bottom,
);
}
pub(super) fn tooltip_text(
@@ -2709,7 +2705,7 @@ impl Game<'_> {
(batch.width, batch.height),
[min_width, extra],
);
Self::tooltip_box(batch, atlas, x, y, w, h);
self.tooltip_box(batch, Some(stack), x, y, w, h);
let text_h = h - extra;
Self::tooltip_text(batch, atlas, &lines, x, y);
if let Some(layout) =
@@ -2756,7 +2752,7 @@ impl Game<'_> {
if py + 8.0 + 3.0 > batch.height {
py = batch.height - 8.0 - 3.0;
}
Self::tooltip_box(batch, atlas, px, py, tw, 8.0);
self.tooltip_box(batch, Some(stack), px, py, tw, 8.0);
Self::tooltip_text(batch, atlas, &lines, px, py);
}
/// `ClientBundleTooltip.extractImage`: the slots, the count past them and
+9 -2
View File
@@ -37,6 +37,8 @@ pub(super) struct ThrownTrident {
pub(super) no_physics: bool,
returning: u32,
creative_only: bool,
/// `Entity.isNoGravity`: `AbstractArrow.applyGravity` then skips the pull.
pub(super) no_gravity: bool,
/// The entity id a server sent as its owner (the one Loyalty returns it to).
pub(super) owner: u32,
/// The guest that threw it (`Projectile.getOwner`); `None` for the host.
@@ -66,6 +68,7 @@ impl ThrownTrident {
t.shake = n.shake;
t.dealt_damage = n.dealt_damage;
t.creative_only = n.creative_only;
t.no_gravity = n.no_gravity;
t
}
/// `AbstractArrow.addAdditionalSaveData` + `DealtDamage`.
@@ -85,6 +88,7 @@ impl ThrownTrident {
shake: self.shake,
dealt_damage: self.dealt_damage,
creative_only: self.creative_only,
no_gravity: self.no_gravity,
item: &self.stack,
},
uuid,
@@ -109,6 +113,7 @@ impl ThrownTrident {
no_physics: false,
returning: 0,
creative_only: false,
no_gravity: false,
owner: 0,
guest: None,
}
@@ -175,7 +180,7 @@ impl ThrownTrident {
self.position = to;
// A trident's water inertia (0.99) equals the air drag.
self.velocity = self.velocity.scale(0.99);
if !self.no_physics {
if !self.no_physics && !self.no_gravity {
self.velocity.y -= 0.05;
}
}
@@ -426,6 +431,7 @@ impl Game<'_> {
no_physics: false,
returning: 0,
creative_only: self.creative,
no_gravity: false,
owner: 0,
guest: self.lan_acting,
});
@@ -733,9 +739,10 @@ impl Game<'_> {
if !t.in_ground {
// A trident's water inertia (0.99) equals the air drag; gravity 0.05.
let drag = 0.99;
let gravity = if t.no_gravity { 0.0 } else { 0.05 };
t.velocity = Vec3::new(
t.velocity.x * drag,
t.velocity.y * drag - 0.05,
t.velocity.y * drag - gravity,
t.velocity.z * drag,
);
let h = (t.velocity.x * t.velocity.x + t.velocity.z * t.velocity.z).sqrt();
+1 -1
View File
@@ -449,7 +449,7 @@ impl Game<'_> {
pub(super) fn sort_mouse_press(&mut self, button: MouseButton) -> bool {
self.sort_input(&keybinds::mouse_name(button))
}
/// The HDR settings key (`Graphics::hdr_key`): opens Graphics: HDR from the game, where
/// The HDR settings key (`Graphics::hdr_key`): opens the graphics screen's HDR page from the game, where
/// the display offers HDR; Done goes back to the game.
pub(super) fn hdr_key_press(&mut self, key: KeyCode) -> bool {
keybinds::key_name(key).is_some_and(|name| self.hdr_input(name))
@@ -58,7 +58,8 @@ impl Game<'_> {
let Some(spot) = spot else {
return;
};
let Some(id) = self.spawn_mob(&kind.id, spot.map(f64::from), false) else {
let Some(id) = self.spawn_mob_reason(&kind.id, spot.map(f64::from), false, false, false)
else {
return;
};
if let Some(reinforcement) = self.mobs.iter_mut().find(|m| m.id == id) {
+1 -1
View File
@@ -374,7 +374,7 @@ struct WarmPool {
fn warm_pool() -> Option<&'static WarmPool> {
static POOL: OnceLock<Option<Arc<WarmPool>>> = OnceLock::new();
POOL.get_or_init(|| {
let threads = std::thread::available_parallelism().map_or(1, |n| n.get()) - 1;
let threads = horizon_threads::worker_count() - 1;
let pool = Arc::new(WarmPool {
job: Default::default(),
ready: Default::default(),
+107
View File
@@ -4172,6 +4172,113 @@ mod tests {
use super::*;
use crate::fast_hash::FxHashSet;
/// A hub with one placed guest, the speed effect registered, and what the
/// guest is sent.
fn hub_with_a_guest() -> (Hub, std::sync::mpsc::Receiver<crate::lan::Out>) {
let config = crate::lan::NetConfig {
announce: false,
..crate::lan::NetConfig::lan(0, "EffectSyncTest".into(), 2)
};
let mut sync = SyncData::empty();
sync.builtin.registries.insert(
"minecraft:mob_effect".to_string(),
horizon_protocol::registry::Ids::from_names(vec!["minecraft:speed".to_string()]),
);
let sync = std::sync::Arc::new(sync);
let lan = Lan::start_with(sync.clone(), config).unwrap();
let encoder = ChunkEncoder::new(
[] as [(std::sync::Arc<str>, bool); 0],
&horizon_protocol::registry::Builtin::default(),
&[],
&[],
);
let hub_config = HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 10,
simulation_distance: 10,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 20,
integrated: false,
levels: Default::default(),
};
let mut hub = Hub::new(lan, sync, encoder, hub_config);
let (tx, rx) = std::sync::mpsc::channel();
let mut bob = Guest::new(
crate::lan::Joined {
conn: 1,
uuid: 1,
name: "Bob".into(),
properties: Vec::new(),
info: horizon_protocol::packets::configuration::ClientInformation::new(8),
brand: "vanilla".into(),
sender: crate::lan::PlayerSender::new(tx, std::sync::Arc::default()),
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
1,
[0.5, 64.0, 0.5],
8,
);
bob.placed = true;
bob.dimension = "minecraft:overworld".into();
hub.guests.push(bob);
(hub, rx)
}
/// The durations of the effect packets the guest was sent.
fn sent_durations(rx: &std::sync::mpsc::Receiver<crate::lan::Out>) -> Vec<i32> {
let mut durations = Vec::new();
while let Ok(out) = rx.try_recv() {
let crate::lan::Out::Packet(body) = out else {
continue;
};
let mut r = horizon_protocol::buf::Reader::new(&body);
let id = r.varint().unwrap();
if let Ok(C::UpdateMobEffect(p)) = C::decode(id, r.rest()) {
durations.push(p.duration);
}
}
durations
}
/// `/effect give Bob minecraft:speed 30 1` from a dedicated server's
/// console (#7115). Vanilla runs the console after the players' tick
/// (`DedicatedServer.tickConnection`) and `ServerPlayer.onEffectAdded`
/// sends the effect at once: 30 s is 600 ticks, and the tick after, which
/// counts it down to 599, sends nothing (`LivingEntity.tickEffects` only
/// refreshes it at a multiple of 600 left). The console syncs right after
/// its command (`console_command`) for the same packet.
#[test]
fn an_effect_synced_after_its_command_is_sent_once_at_its_full_duration() {
let (mut hub, rx) = hub_with_a_guest();
hub.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
// The console's command, then its sync.
hub.sync_all_effects();
// The next tick: the guests' effects count down (`tick_guests`), then
// the hub syncs them (`tick_level`).
hub.guests[0].effects.tick();
hub.sync_all_effects();
hub.guests[0].effects.tick();
hub.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![600]);
// Without the sync after the command, the first sync comes after the
// tick has counted it down: what #7115's transcript shows.
let (mut late, rx) = hub_with_a_guest();
late.guests[0]
.effects
.add(EffectInstance::new("speed", 30 * 20, 1));
late.guests[0].effects.tick();
late.sync_all_effects();
assert_eq!(sent_durations(&rx), vec![599]);
}
/// The owner's own shoulder parrots are sent when a slot changes.
#[test]
fn the_owner_is_sent_the_shoulder_slots_that_changed() {
+13
View File
@@ -24,6 +24,19 @@ pub const COLORS: [&str; 7] = [
"gray",
"darkbrown",
];
/// `Horse.Variant.getSerializedName` in id order (WHITE 0 to DARK_BROWN 6):
/// the names a `minecraft:horse/variant` component and the save's `Variant`
/// byte carry. [`COLORS`] is the same order, but names the texture
/// (`horse_darkbrown`), which drops the underscore.
pub const VARIANT_NAMES: [&str; 7] = [
"white",
"creamy",
"chestnut",
"brown",
"black",
"gray",
"dark_brown",
];
pub const MARKINGS: [&str; 5] = ["", "white", "whitefield", "whitedots", "blackdots"];
// AnimState slots.
@@ -863,6 +863,11 @@ fn age_boundary_reached(m: &mut Mob) {
Species::Goat => set_base(m, Attr::AttackDamage, if baby { 1.0 } else { 2.0 }),
Species::Hoglin => set_base(m, Attr::AttackDamage, if baby { 0.5 } else { 6.0 }),
Species::SulfurCube if !baby => sulfur_size(m, 2),
// HappyGhast.ageBoundaryReached: adultGhastSetup or babyGhastSetup,
// then Animal's no-op. A hatched ghastling needs it before it is in
// the level (DriedGhastBlock.spawnGhastling sets the baby age before
// addFreshEntity), or its first tick flies the adult goals.
Species::HappyGhast => super::happy_ghast::ensure_setup(m),
_ => {}
}
}
@@ -2171,6 +2176,12 @@ pub fn variant_index(s: Species, name: &str) -> u8 {
// Fox.Variant, read from a `minecraft:fox/variant` component; the save
// keeps the same two names under `Type`.
Species::Fox => ["red", "snow"].iter().position(|v| *v == name),
// Horse.Variant and Llama.Variant, the coat colours their
// `.../variant` components carry; a trader llama inherits the llama's.
Species::Horse => super::equine::VARIANT_NAMES.iter().position(|v| *v == name),
Species::Llama | Species::TraderLlama => {
super::llama::VARIANTS.iter().position(|v| *v == name)
}
_ => ["temperate", "warm", "cold"]
.iter()
.position(|v| *v == name),
@@ -2193,10 +2204,29 @@ pub fn species_variant_component(s: Species, variant: u8) -> Option<(&'static st
Species::Mooshroom => "minecraft:mooshroom/variant",
Species::Chicken => "minecraft:chicken/variant",
Species::ZombieNautilus => "minecraft:zombie_nautilus/variant",
Species::Horse => "minecraft:horse/variant",
Species::Llama | Species::TraderLlama => "minecraft:llama/variant",
_ => return None,
};
Some((component, format!("minecraft:{}", variant_name(s, variant))))
}
/// Whether the mob's save keeps its variant under `variant` — the registry
/// id `VariantUtils.writeVariant` stores — rather than under its own key (a
/// fox's and a mooshroom's `Type`) or a packed int (a horse's and a llama's
/// `Variant`). Only these seven classes call `writeVariant`, so reporting the
/// component does not mean the save carries the name.
pub fn saves_variant_name(s: Species) -> bool {
matches!(
s,
Species::Wolf
| Species::Cat
| Species::Frog
| Species::Pig
| Species::Cow
| Species::Chicken
| Species::ZombieNautilus
)
}
/// The `*_sound_variant` registry of a species, in registry order.
pub fn sound_variants(s: Species) -> &'static [&'static str] {
match s {
@@ -2259,6 +2289,13 @@ pub fn variant_name(s: Species, index: u8) -> &'static str {
.copied()
.unwrap_or("temperate"),
Species::Mooshroom => ["red", "brown"].get(i).copied().unwrap_or("red"),
Species::Horse => super::equine::VARIANT_NAMES
.get(i)
.copied()
.unwrap_or("white"),
Species::Llama | Species::TraderLlama => {
super::llama::VARIANTS.get(i).copied().unwrap_or("creamy")
}
_ => ["temperate", "warm", "cold"]
.get(i)
.copied()
@@ -2320,6 +2357,60 @@ mod tests {
assert_eq!(variant_index(Species::Mooshroom, "minecraft:temperate"), 0);
assert_eq!(variant_name(Species::Mooshroom, 2), "red");
}
/// `Horse.get(DataComponents.HORSE_VARIANT)` and
/// `Llama.get(DataComponents.LLAMA_VARIANT)`: the coat colour a horse or a
/// llama reports, and the names a `.../variant` component carries.
#[test]
fn a_horse_and_a_llama_report_their_variant_component() {
for (species, component, names) in [
(
Species::Horse,
"minecraft:horse/variant",
&crate::mobs::equine::VARIANT_NAMES[..],
),
(
Species::Llama,
"minecraft:llama/variant",
&crate::mobs::llama::VARIANTS[..],
),
(
Species::TraderLlama,
"minecraft:llama/variant",
&crate::mobs::llama::VARIANTS[..],
),
] {
for (index, name) in names.iter().enumerate() {
let index = index as u8;
let name = format!("minecraft:{name}");
assert_eq!(
species_variant_component(species, index),
Some((component, name.clone())),
"{species:?} {index}"
);
assert_eq!(variant_index(species, &name), index, "{name}");
assert_eq!(format!("minecraft:{}", variant_name(species, index)), name);
}
}
// Horse.Variant.WHITE and Llama.Variant.CREAMY are the defaults.
assert_eq!(variant_index(Species::Horse, "minecraft:temperate"), 0);
assert_eq!(variant_index(Species::Llama, "minecraft:temperate"), 0);
assert_eq!(variant_name(Species::Horse, 7), "white");
assert_eq!(variant_name(Species::TraderLlama, 4), "creamy");
// Only `VariantUtils.writeVariant` species save the name; a horse and
// a llama save a packed `Variant` int, a fox and a mooshroom `Type`.
for species in [Species::Wolf, Species::Cat, Species::Chicken, Species::Pig] {
assert!(saves_variant_name(species), "{species:?}");
}
for species in [
Species::Horse,
Species::Llama,
Species::TraderLlama,
Species::Fox,
Species::Mooshroom,
] {
assert!(!saves_variant_name(species), "{species:?}");
}
}
/// #2269: the offspring of a spawn egg used on a mob is a baby of that
/// kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`), and only the mob's
/// own kind takes the egg.
+4 -2
View File
@@ -2155,9 +2155,11 @@ impl Goal for Breed {
return false;
}
let species = m.species();
// Wolf.canMate: both tame and standing.
// Wolf.canMate and Cat.canMate: both tame. The sitting pose sits on the
// partner side only, and only for a wolf (`wolf.isInSittingPose()`), so
// a tamed mob ordered to sit still walks off to breed.
let tamable = matches!(species, Species::Wolf | Species::Cat);
if tamable && (!m.extra.tame || m.extra.sitting) {
if tamable && !m.extra.tame {
return false;
}
// AbstractHorse.canMate is false and Mule keeps it: mules never breed.
@@ -667,4 +667,40 @@ mod tests {
fn travel_speed_is_five_thirds_of_the_flying_speed() {
assert!((f64::from(AIR_SPEED) - FLYING_SPEED * 5.0 / 3.0).abs() < 1e-7);
}
/// `HappyGhast.ageBoundaryReached` runs the setup at the age crossing, not
/// at the next tick: `DriedGhastBlock.spawnGhastling` makes its ghastling a
/// baby before `addFreshEntity`, and `AgeableMob.setAge` calls
/// `ageBoundaryReached` there. Without it the hatched ghastling joins the
/// level with the adult's three goals, its ground navigation and its brain
/// stopped, and only its first `aiStep` (`ensure_setup`) fixes that.
#[test]
fn a_hatched_ghastling_is_set_up_as_one_at_the_age_crossing() {
use crate::mobs::Species;
let mut t = (*crate::mobs::sensor_scan_tests::kind()).clone();
t.species = Species::HappyGhast;
let mut m = Mob::new(
1,
std::sync::Arc::new(t),
horizon_core::geometry::Vec3::ZERO,
0.0,
);
// `Mob::new` registers the adult's goals (HappyGhastFloat, its tempt
// goal and FloatAround) for the age-zero ghast it makes.
assert_eq!(m.extra.scratch[0], 2, "an adult's setup at construction");
assert!(m.ai.goals.has("HappyGhastFloatGoal"));
crate::mobs::finalize::set_age(&mut m, -24000);
assert!(m.is_baby());
assert_eq!(m.extra.scratch[0], 1, "babyGhastSetup ran");
assert!(
!m.ai.goals.has("HappyGhastFloatGoal")
&& !m.ai.goals.has("RandomFloatAroundGoal")
&& !m.ai.goals.has("ForNonPathfinders"),
"a ghastling has no goals: its brain drives it"
);
assert_eq!(m.ai.nav.kind, crate::mobs::nav::NavKind::Flying);
assert!(m.ai.nav.can_float);
}
}
+52
View File
@@ -492,6 +492,23 @@ pub fn leash_offset(m: &Mob) -> [f64; 3] {
f64::from(0.55_f32 * m.eye_height() as f32),
f64::from(m.width() * 0.4),
]
} else if let Some(factor) = match m.species() {
// Pig.getLeashOffset, Rabbit.getLeashOffset and
// Strider.getLeashOffset: 0.6 of the eye height; Parrot.getLeashOffset
// and Ocelot.getLeashOffset: half of it.
super::Species::Pig | super::Species::Rabbit | super::Species::Strider => Some(0.6_f32),
super::Species::Parrot | super::Species::Ocelot => Some(0.5_f32),
_ => None,
} {
[
0.0,
f64::from(factor * m.eye_height() as f32),
f64::from(m.width() * 0.4),
]
} else if m.species() == super::Species::HappyGhast {
// HappyGhast.getLeashOffset: the origin, so the rope hangs at the
// ghast's feet rather than an eye height up.
[0.0, 0.0, 0.0]
} else {
[0.0, m.eye_height(), f64::from(m.width() * 0.4)]
}
@@ -631,6 +648,41 @@ mod tests {
}
});
}
/// `Pig`, `Rabbit`, `Strider`, `Parrot`, `Ocelot` and
/// `HappyGhast.getLeashOffset`: 0.6 or half of the eye height, and the
/// happy ghast's rope at the origin, against the default eye-height
/// height the fallthrough gave them.
#[test]
fn a_pig_a_rabbit_a_parrot_an_ocelot_and_a_ghast_hang_their_lead_where_vanilla_does() {
let _ = crate::mobs::world::with_test_world(&[], |_, types| {
for (kind, factor) in [
("minecraft:pig", 0.6),
("minecraft:rabbit", 0.6),
("minecraft:strider", 0.6),
("minecraft:parrot", 0.5),
("minecraft:ocelot", 0.5),
// `Vec3.ZERO`: no eye-height part and no forward shift.
("minecraft:happy_ghast", 0.0),
] {
let m = Mob::new(
1,
std::sync::Arc::new(types.types[kind].clone()),
Vec3::ZERO,
0.0,
);
let o = leash_offset(&m);
let h = f64::from(factor * m.eye_height() as f32);
let z = f64::from(m.width() * 0.4);
assert!((o[1] - h).abs() < 1e-9, "{kind}: height {} vs {h}", o[1]);
assert!(
(o[2] - if factor == 0.0 { 0.0 } else { z }).abs() < 1e-9,
"{kind}: forward {} vs {z}",
o[2]
);
assert_eq!(o[0], 0.0, "{kind}: centred");
}
});
}
/// `Nautilus.canBeLeashed` and `ZombieNautilus.canBeLeashed`: an
/// aggravated nautilus, one holding an `ANGRY_AT` or an `ATTACK_TARGET`
/// memory, cannot be put on a lead.
+45
View File
@@ -1874,6 +1874,51 @@ mod tests {
assert!(m.damage(1.0, Damage::player([0.0, 64.0, 1.0])));
assert!(m.extra.sitting);
}
/// `Cat.canMate` and `Wolf.canMate`: the seeker only has to be tamed, so a
/// mob ordered to sit still goes looking for a mate. Only a wolf's
/// *partner* is refused while it sits (`wolf.isInSittingPose()`), which is
/// the one sitting check vanilla makes.
#[test]
fn a_sitting_tame_mob_still_looks_for_a_mate() {
use crate::mobs::goals::{Breed, Cx, Goal};
use crate::mobs::world::with_test_world;
let named = |species, name: &str, id: u32, sat: bool| {
let mut m = sitting(species, name);
m.id = id;
m.extra.in_love = 600;
m.extra.sitting = sat;
m
};
for (species, name, seeker_sat, partner_sat, breeds) in [
// A tamed cat pairs with a tamed cat, sitting or not.
(Species::Cat, "minecraft:cat", true, true, true),
(Species::Cat, "minecraft:cat", true, false, true),
// A wolf pairs unless the wolf it would take is sitting.
(Species::Wolf, "minecraft:wolf", true, false, true),
(Species::Wolf, "minecraft:wolf", true, true, false),
(Species::Wolf, "minecraft:wolf", false, true, false),
] {
let mut seeker = named(species, name, 1, seeker_sat);
let partner = named(species, name, 2, partner_sat);
let seen = [partner.seen(None)];
with_test_world(&seen, |w, _| {
let mut events = Vec::new();
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goal = Breed::new(1.0);
assert_eq!(
goal.can_use(&mut seeker, &mut cx),
breeds,
"{name}: seeker sits {seeker_sat}, partner sits {partner_sat}"
);
});
}
}
}
/// `NeutralMob.isAngryAtAllPlayers`: with the `universal_anger` game rule on, a
+19 -1
View File
@@ -6,7 +6,7 @@
use std::sync::{Arc, OnceLock};
pub use crate::biome::BiomeColors;
pub use crate::camera::CameraEntity;
pub use crate::camera::{CameraEntity, FovPlayer};
pub use crate::command::Suggestion;
pub use crate::containers::{ModBlockEntity, ModContainer, ModEnergyBuffer, ModFluidTanks};
pub use crate::crafting::{CustomRecipe, Ingredient};
@@ -54,6 +54,24 @@ use std::time::Duration;
/// The window events `input_key` hands over (`winit::event::KeyEvent`) and their key types.
pub use winit;
/// Camera values from the most recently extracted world frame.
#[derive(Clone, Debug)]
pub struct NativeCamera {
pub position: [f64; 3],
pub eye_height: f32,
pub partial_tick: f32,
pub yaw: f32,
pub pitch: f32,
pub fov_degrees: f32,
pub near: f32,
pub far: f32,
/// Camera-relative view matrix in column-major order.
pub view: [f32; 16],
/// Reversed-Z projection matrix in column-major order.
pub projection: [f32; 16],
pub dimension: String,
}
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
pub type NativeDispensed = (Option<ItemStack>, i32);
+272
View File
@@ -4512,11 +4512,73 @@ pub trait Hooks: Send + Sync {
fn living_jump(&self, event: &mut LivingJumpEvent<'_>) {
let _ = event;
}
/// Player FOV scale, after vanilla effects and before zoom and easing.
/// Invalid values or values outside 0.01..=10 revert to base. API @API:compute_fov_modifier@.
fn compute_fov_modifier(&self, host: &dyn Host, event: &mut ComputeFovModifierEvent<'_>) {
let _ = host;
let _ = event;
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
/// the item without slots, and vanilla's items have none. Asked at each
/// call of the item container helpers (`native_item_container_slots`,
/// `native_item_container_insert`, `native_item_container_extract`, in
/// `native_item_container.rs`). The contents are kept in the stack's
/// `minecraft:container`, as a shulker box item keeps them.
/// API @API:item_container@.
///
/// Return `Some` to set `slots`; the first mod returning `Some` wins.
fn item_container(&self, host: &dyn Host, event: &mut ItemContainerEvent<'_>) -> Option<u32> {
let _ = host;
let _ = event;
None
}
/// Local player movement input, after reading it and before applying it.
fn movement_input(&self, host: &dyn Host, event: &mut MovementInputEvent) {
let _ = host;
let _ = event;
}
/// Additional first-person hand and item pose, including empty arms and maps.
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
/// API @API:render_hand_pose@.
fn render_hand_pose(&self, host: &dyn Host, event: &mut RenderHandPoseEvent<'_>) {
let _ = host;
let _ = event;
}
/// Before a visible item frame's item or map is drawn. Cancel hides the item, not the frame.
///
/// Return `Flow::Cancel` to take it: this sets `canceled`, and later mods are skipped.
fn render_item_in_frame(
&self,
host: &dyn Host,
event: &mut RenderItemInFrameEvent<'_>,
) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// A screen was observed closing, at most once per frame.
fn screen_closing(&self, host: &dyn Host, event: &mut ScreenClosingEvent<'_>) {
let _ = host;
let _ = event;
}
/// A screen was observed opening or changing rectangle, at most once per frame.
fn screen_init_post(&self, host: &dyn Host, event: &mut ScreenInitPostEvent<'_>) {
let _ = host;
let _ = event;
}
/// A screen was observed opening, at most once per frame.
fn screen_opening(&self, host: &dyn Host, event: &mut ScreenOpeningEvent<'_>) {
let _ = host;
let _ = event;
}
/// Change a tooltip box's background and border gradient colors, as ARGB.
/// Stack is None for tooltips not associated with an item.
fn tooltip_color(&self, host: &dyn Host, event: &mut TooltipColorEvent<'_>) {
let _ = host;
let _ = event;
}
}
/// What [`Hooks::frame`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
@@ -13804,6 +13866,52 @@ pub fn dispatch_living_jump(mods: &[Box<dyn Hooks>], entity: u32, kind: &str) {
mod_hook.living_jump(&mut event);
}
}
/// What [`Hooks::compute_fov_modifier`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ComputeFovModifierEvent<'a> {
pub player: &'a horizon_client::camera::FovPlayer,
pub base: f32,
pub modifier: f32,
}
pub fn dispatch_compute_fov_modifier(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
player: &horizon_client::camera::FovPlayer,
base: f32,
modifier: &mut f32,
) {
let mut event = ComputeFovModifierEvent {
player,
base,
modifier: *modifier,
};
for mod_hook in super::entered(mods, "compute_fov_modifier") {
mod_hook.compute_fov_modifier(host, &mut event);
}
*modifier = event.modifier;
}
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemContainerEvent<'a> {
pub item: &'a str,
}
pub fn dispatch_item_container(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
item: &str,
slots: &mut Option<u32>,
) {
if slots.is_some() {
return;
}
let mut event = ItemContainerEvent { item };
for mod_hook in super::entered(mods, "item_container") {
if let Some(replacement) = mod_hook.item_container(host, &mut event) {
*slots = Some(replacement);
break;
}
}
}
/// What [`Hooks::movement_input`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct MovementInputEvent {
@@ -13834,3 +13942,167 @@ pub fn dispatch_movement_input(
*jump = event.jump;
*sneak = event.sneak;
}
/// What [`Hooks::render_hand_pose`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderHandPoseEvent<'a> {
pub hand: horizon_client::first_person::Hand,
pub stack: Option<&'a ItemStack>,
pub partial: f32,
pub swing: f32,
pub equip: f32,
pub x: f32,
pub y: f32,
pub z: f32,
pub pitch: f32,
pub yaw: f32,
pub roll: f32,
pub scale: f32,
}
pub fn dispatch_render_hand_pose(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
hand: horizon_client::first_person::Hand,
stack: Option<&ItemStack>,
partial: f32,
swing: f32,
equip: f32,
x: &mut f32,
y: &mut f32,
z: &mut f32,
pitch: &mut f32,
yaw: &mut f32,
roll: &mut f32,
scale: &mut f32,
) {
let mut event = RenderHandPoseEvent {
hand,
stack,
partial,
swing,
equip,
x: *x,
y: *y,
z: *z,
pitch: *pitch,
yaw: *yaw,
roll: *roll,
scale: *scale,
};
for mod_hook in super::entered(mods, "render_hand_pose") {
mod_hook.render_hand_pose(host, &mut event);
}
*x = event.x;
*y = event.y;
*z = event.z;
*pitch = event.pitch;
*yaw = event.yaw;
*roll = event.roll;
*scale = event.scale;
}
/// What [`Hooks::render_item_in_frame`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderItemInFrameEvent<'a> {
pub entity_id: u32,
pub pos: [i32; 3],
pub stack: &'a ItemStack,
}
pub fn dispatch_render_item_in_frame(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
entity_id: u32,
pos: [i32; 3],
stack: &ItemStack,
canceled: &mut bool,
) {
if *canceled {
return;
}
let mut event = RenderItemInFrameEvent {
entity_id,
pos,
stack,
};
for mod_hook in super::entered(mods, "render_item_in_frame") {
if matches!(
mod_hook.render_item_in_frame(host, &mut event),
Flow::Cancel
) {
*canceled = true;
break;
}
}
}
/// What [`Hooks::screen_closing`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ScreenClosingEvent<'a> {
pub screen: &'a NativeScreenView<'a>,
}
pub fn dispatch_screen_closing(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
screen: &NativeScreenView<'_>,
) {
let mut event = ScreenClosingEvent { screen };
for mod_hook in super::entered(mods, "screen_closing") {
mod_hook.screen_closing(host, &mut event);
}
}
/// What [`Hooks::screen_init_post`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ScreenInitPostEvent<'a> {
pub screen: &'a NativeScreenView<'a>,
}
pub fn dispatch_screen_init_post(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
screen: &NativeScreenView<'_>,
) {
let mut event = ScreenInitPostEvent { screen };
for mod_hook in super::entered(mods, "screen_init_post") {
mod_hook.screen_init_post(host, &mut event);
}
}
/// What [`Hooks::screen_opening`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ScreenOpeningEvent<'a> {
pub screen: &'a NativeScreenView<'a>,
}
pub fn dispatch_screen_opening(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
screen: &NativeScreenView<'_>,
) {
let mut event = ScreenOpeningEvent { screen };
for mod_hook in super::entered(mods, "screen_opening") {
mod_hook.screen_opening(host, &mut event);
}
}
/// What [`Hooks::tooltip_color`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct TooltipColorEvent<'a> {
pub stack: Option<&'a ItemStack>,
pub background: u32,
pub border_top: u32,
pub border_bottom: u32,
}
pub fn dispatch_tooltip_color(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
stack: Option<&ItemStack>,
background: &mut u32,
border_top: &mut u32,
border_bottom: &mut u32,
) {
let mut event = TooltipColorEvent {
stack,
background: *background,
border_top: *border_top,
border_bottom: *border_bottom,
};
for mod_hook in super::entered(mods, "tooltip_color") {
mod_hook.tooltip_color(host, &mut event);
}
*background = event.background;
*border_top = event.border_top;
*border_bottom = event.border_bottom;
}
+49
View File
@@ -1203,4 +1203,53 @@ pub trait Host {
fn instance_dir(&self) -> PathBuf {
unimplemented!("Host::instance_dir")
}
/// The most recently extracted world frame's camera, if one exists.
fn camera(&self) -> Option<NativeCamera> {
unimplemented!("Host::camera")
}
/// Collision boxes in block-local coordinates with an empty collision context.
fn block_collision(&self, pos: [i32; 3]) -> Vec<[f64; 6]> {
let _ = pos;
unimplemented!("Host::block_collision")
}
/// `Capabilities.Item.ITEM`, read: the slots of one item of `stack`,
/// empty ones included, kept in its `minecraft:container`. None for an
/// item without slots, and for an empty stack.
fn item_container_slots(&self, stack: &ItemStack) -> Option<Vec<Option<ItemStack>>> {
let _ = stack;
unimplemented!("Host::item_container_slots")
}
/// `ResourceHandler.insert`: as much of `item` as fits into one item of
/// `stack`, topping up the slots that hold the same item and components
/// first, then the first empty ones (`SimpleContainer.addItem`'s order),
/// no slot past its limit (99, or the item's own when less). A shulker
/// box never goes in. The count that went in, and the single item it
/// becomes (the item itself when none did). The stack given is not
/// changed: shrink it by one and hand out the result, or leave the result
/// unused to simulate. None where the item has no slots.
fn item_container_insert(
&self,
stack: &ItemStack,
item: &ItemStack,
) -> Option<(u32, ItemStack)> {
let _ = stack;
let _ = item;
unimplemented!("Host::item_container_insert")
}
/// `ResourceHandler.extract`: up to `max` out of slot `slot` of one item
/// of `stack`. What came out (none when the slot is empty or past the
/// item's slots), and the single item it becomes. The stack given is not
/// changed, as with `native_item_container_insert`. None where the item
/// has no slots.
fn item_container_extract(
&self,
stack: &ItemStack,
slot: usize,
max: u32,
) -> Option<(Option<ItemStack>, ItemStack)> {
let _ = stack;
let _ = slot;
let _ = max;
unimplemented!("Host::item_container_extract")
}
}
+1 -1
View File
@@ -2247,7 +2247,7 @@ fn tables(base: &Path, plan: &Plan, overlay: &Overlay<'_>) -> Result<()> {
overlay.write(name, &bytes)
};
let next = std::sync::atomic::AtomicUsize::new(0);
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let threads = horizon_threads::worker_count();
std::thread::scope(|scope| {
let workers: Vec<_> = (0..threads.min(TABLE_THREADS))
.map(|_| {
+40 -2
View File
@@ -2100,8 +2100,8 @@ fn compress_records(
.map(|tag| ChunkRecord::from_nbt(&tag, Compression::Zlib, timestamp, Limits::saved()))
.transpose()
};
let helpers = std::thread::available_parallelism()
.map_or(1, |n| n.get().div_ceil(2))
let helpers = horizon_threads::worker_count()
.div_ceil(2)
.min(records.len() / CHUNKS_PER_HELPER);
if helpers <= 1 {
return records.iter().map(|(_, r)| compress(r)).collect();
@@ -4846,8 +4846,23 @@ fn decode_lod_surfaces(
}
}
}
// Spans are pushed top down, so the reverse puts the topmost last, as
// `Column::from_chunk` leaves them. Open water is then lit as open sky,
// exactly as the live path does: without it a saved column kept each
// water block's own faded skylight and drew a dark band through the sea
// where saved columns met the generator's (`grid`'s stride limit).
// Only where there is a sky to be lit by: the Nether's water must not
// gain skylight it never had.
let is_water = |state| {
sky_light
&& assets
.registry
.state(state)
.is_some_and(|block| block.name == "minecraft:water")
};
for column in &mut *columns {
column.spans.reverse();
column.light_open_water_where(is_water);
}
Some(Arc::from(columns))
}
@@ -4963,6 +4978,9 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
fn prepare_loaded(&self, worker: &mut G::Worker, chunk: &Chunk) {
self.inner.prepare_loaded(worker, chunk);
}
fn all_idle(&self) {
self.inner.all_idle();
}
fn idle(&self, worker: &mut G::Worker) {
self.inner.idle(worker);
}
@@ -4988,6 +5006,12 @@ impl<G: ChunkGenerator> ChunkGenerator for Persistent<G> {
) -> Option<crate::section_mask::SectionMask> {
self.inner.non_air_sections(proto)
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
self.inner.cache_census()
}
fn proto_heap_bytes(&self, proto: &crate::streaming::Proto) -> ([usize; 6], [usize; 16]) {
self.inner.proto_heap_bytes(proto)
}
fn save_protos(&self, protos: &[&crate::streaming::Proto]) {
self.save
.tables
@@ -5639,6 +5663,20 @@ mod tests {
let underwater = surfaces[4 * 16 + 4].surface.unwrap();
assert_eq!((underwater.height, underwater.state), (55, sand));
assert_eq!(underwater.water, Some((64, water)));
// Open water reads as open sky, as the generator's far columns do.
// Saved columns that kept each block's own faded skylight drew a dark
// band through the sea where the two met.
let sea = &surfaces[4 * 16 + 4].spans;
let lit: Vec<_> = sea
.iter()
.filter(|span| span.state == water)
.map(|span| span.light & 0xf0)
.collect();
assert!(!lit.is_empty(), "the column keeps its water spans");
assert!(
lit.iter().all(|sky| *sky == 0xf0),
"saved open water is lit as open sky, got {lit:?}"
);
let chorus_column = &surfaces[2 * 16 + 12];
assert_eq!(
chorus_column
+18
View File
@@ -719,6 +719,9 @@ impl ChunkGenerator for VanillaTerrain {
fn idle(&self, worker: &mut GeneratorWorker) {
worker.release();
}
fn all_idle(&self) {
self.generator.release_caches();
}
fn lod_surface(
&self,
worker: &mut GeneratorWorker,
@@ -856,6 +859,21 @@ impl ChunkGenerator for VanillaTerrain {
_ => None,
}
}
fn cache_census(&self) -> Vec<(&'static str, usize, Option<usize>)> {
let c = self.generator.cache_census();
vec![
("biome_chunks", c.biome_chunks, Some(c.biome_bytes)),
("carver_biomes", c.carver_entries, None),
("start_chunks", c.start_chunks, None),
("start_lists", c.start_lists, None),
]
}
fn proto_heap_bytes(&self, proto: &Proto) -> ([usize; 6], [usize; 16]) {
match proto.downcast_ref::<VanillaProto>() {
Some(VanillaProto::Ready(chunk)) => (chunk.heap_bytes(), chunk.terrain.storage_bits()),
_ => ([0; 6], [0; 16]),
}
}
fn non_air_sections(&self, proto: &Proto) -> Option<crate::section_mask::SectionMask> {
let Some(VanillaProto::Ready(chunk)) = proto.downcast_ref::<VanillaProto>() else {
return None;
+1 -3
View File
@@ -72,9 +72,7 @@ impl Builder {
// ticks and the frame leave, so more cores can share it: a third of
// the threads, rounded up (a 4-thread machine's one thread left idle
// time while the far trees and structures came in).
let count = thread::available_parallelism()
.map_or(1, |n| n.get().div_ceil(3))
.clamp(1, 12);
let count = horizon_threads::worker_count().div_ceil(3).clamp(1, 12);
let threads = (0..count)
.map(|_| Self::spawn(terrain.clone(), shared.clone(), tx.clone()))
.collect();
+4
View File
@@ -70,6 +70,10 @@ pub struct PackedStorage {
}
impl PackedStorage {
/// The heap bytes of the packed words (`horizon-ctl memory`).
pub fn heap_bytes(&self) -> usize {
self.words.capacity() * std::mem::size_of::<u64>()
}
pub fn new(bits: u8, len: usize) -> Result<Self, StorageError> {
let per_word = Self::per_word(bits)?;
Self::from_words(bits, len, vec![0; len.div_ceil(per_word)])
+31 -1
View File
@@ -275,7 +275,18 @@ fn strip_comments(text: &str) -> (String, Vec<(usize, String)>) {
out.push_str(before);
line += before.matches('\n').count();
if let Some(body) = from.strip_prefix("//") {
let end = body.find('\n').unwrap_or(body.len());
// A line comment ending in a backslash includes the next line (GLSL 4.60, 3.4; Iris
// does so too): such a comment runs on. Its line breaks stay.
let mut end = body.len();
for (at, _) in body.match_indices('\n') {
if !body[..at].trim_end_matches('\r').ends_with('\\') {
end = at;
break;
}
}
let joined = body[..end].matches('\n').count();
line += joined;
out.extend(std::iter::repeat_n('\n', joined));
rest = &body[end..];
} else if let Some(body) = from.strip_prefix("/*") {
let end = body.find("*/").unwrap_or(body.len());
@@ -422,6 +433,25 @@ mod tests {
assert_eq!(messages, ["#error live", "unknown directive `#bogus`"]);
}
/// A line comment ending in a backslash runs on to the next line, as in Iris: the line it
/// takes is not code and not a directive, and later lines keep their numbers.
#[test]
fn a_line_comment_runs_on_after_a_backslash() {
let p = run(&[(
"program/final.fsh",
"#version 330\n#if 1\nfloat a; // ends in a backslash \\\n#endif\n\
// alone \\\r\n#define HIDDEN\n#ifdef HIDDEN\nfloat b;\n#endif\nfloat c;\n#endif\n",
)]);
assert!(p.diagnostics.is_empty(), "{:?}", p.diagnostics);
assert_eq!(
code_lines(&p),
[
("float a;", "program/final.fsh:3".into()),
("float c;", "program/final.fsh:10".into()),
]
);
}
/// A stray `#endif` and an `#if` left open are warnings, as in Iris 1.11.7 (which draws such
/// a program): the `#endif` is ignored, the open `#if` reads on to the end of the file.
#[test]
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Hugeicons
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M21 11.002V12.4981C21 15.3265 21 16.7407 20.1213 17.6194C19.2426 18.4981 17.8284 18.4981 15 18.4981H11L7.20567 21.8181C7.07308 21.9341 6.90289 21.9981 6.72671 21.9981C6.32501 21.9981 5.99937 21.6724 5.99937 21.2707V18.4981C5.06999 18.4981 4.6053 18.4981 4.22402 18.396C3.18836 18.1187 2.37942 17.3097 2.10209 16.2741C2 15.8928 2 15.4281 2 14.4987V9.00195C2 6.17353 2 4.75931 2.87868 3.88063C3.75736 3.00195 5.17157 3.00195 8 3.00195H13" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 3.12695V5.00195M19 5.00195V6.87695M19 5.00195H17.5M19 5.00195H20.5M22 5.00195L20.6987 4.56818C20.1015 4.36911 19.6328 3.90048 19.4338 3.30327L19 2.00195L18.5662 3.30327C18.3672 3.90048 17.8985 4.36911 17.3013 4.56818L16 5.00195L17.3013 5.43573C17.8985 5.6348 18.3672 6.10343 18.5662 6.70064L19 8.00195L19.4338 6.70064C19.6328 6.10343 20.1015 5.6348 20.6987 5.43573L22 5.00195Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18 9.00005C18 9.00005 13.5811 15 12 15C10.4188 15 6 9 6 9" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M9.00005 6C9.00005 6 15 10.4189 15 12C15 13.5812 9 18 9 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 260 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.9991 19L5 5.00098" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19 5L5.00092 18.999" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 343 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M13.435 7L7.15915 13.2759M7.15915 13.2759L4.82728 15.6077C3.92569 16.5093 3.47489 16.9601 3.23745 17.5334C3 18.1066 3 18.7441 3 20.0192V21H3.98082C5.25586 21 5.89338 21 6.46663 20.7626C7.03988 20.5251 7.49068 20.0743 8.39227 19.1727L14.2891 13.2759M7.15915 13.2759H14.2891M14.2891 13.2759L17 10.565" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M19.2087 8.38869L20.82 10M19.2087 8.38869L20.0705 7.52682C20.363 7.23431 20.5093 7.08805 20.611 6.94529C21.1297 6.21676 21.1297 5.23953 20.611 4.511C20.5093 4.36824 20.363 4.22198 20.0705 3.92947C19.778 3.63697 19.6318 3.4907 19.489 3.38905C18.7605 2.87032 17.7832 2.87032 17.0547 3.38905C16.912 3.4907 16.7657 3.63695 16.4732 3.92947L15.6113 4.79133M19.2087 8.38869L15.6113 4.79133M14 3.18002L15.6113 4.79133" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 1009 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M7.5 7.5L8.72654 8.55719C9.24218 9.00163 9.5 9.22386 9.5 9.5C9.5 9.77614 9.24218 9.99836 8.72654 10.4428L7.5 11.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M11.5 12.5H15.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M12 21C15.7497 21 17.6246 21 18.9389 20.0451C19.3634 19.7367 19.7367 19.3634 20.0451 18.9389C21 17.6246 21 15.7497 21 12C21 8.25027 21 6.3754 20.0451 5.06107C19.7367 4.6366 19.3634 4.26331 18.9389 3.95491C17.6246 3 15.7497 3 12 3C8.25027 3 6.3754 3 5.06107 3.95491C4.6366 4.26331 4.26331 4.6366 3.95491 5.06107C3 6.3754 3 8.25027 3 12C3 15.7497 3 17.6246 3.95491 18.9389C4.26331 19.3634 4.6366 19.7367 5.06107 20.0451C6.3754 21 8.25027 21 12 21Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 975 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M14 21H16M14 21C13.1716 21 12.5 20.3284 12.5 19.5V17L12 17M14 21H10M10 21H8M10 21C10.8284 21 11.5 20.3284 11.5 19.5V17L12 17M12 17V21" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16 3H8C5.17157 3 3.75736 3 2.87868 3.87868C2 4.75736 2 6.17157 2 9V11C2 13.8284 2 15.2426 2.87868 16.1213C3.75736 17 5.17157 17 8 17H16C18.8284 17 20.2426 17 21.1213 16.1213C22 15.2426 22 13.8284 22 11V9C22 6.17157 22 4.75736 21.1213 3.87868C20.2426 3 18.8284 3 16 3Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 703 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M7.5 14.5C7.5 11.2002 7.5 9.55025 8.52513 8.52513C9.55025 7.5 11.2002 7.5 14.5 7.5C17.7998 7.5 19.4497 7.5 20.4749 8.52513C21.5 9.55025 21.5 11.2002 21.5 14.5C21.5 17.7998 21.5 19.4497 20.4749 20.4749C19.4497 21.5 17.7998 21.5 14.5 21.5C11.2002 21.5 9.55025 21.5 8.52513 20.4749C7.5 19.4497 7.5 17.7998 7.5 14.5Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M7.5 16.5C6.10355 16.5 5.40533 16.5 4.84402 16.3036C3.83866 15.9518 3.0482 15.1613 2.69641 14.156C2.5 13.5947 2.5 12.8964 2.5 11.5V9.5C2.5 6.20017 2.5 4.55025 3.52513 3.52513C4.55025 2.5 6.20017 2.5 9.5 2.5H11.5C12.8964 2.5 13.5947 2.5 14.156 2.69641C15.1613 3.0482 15.9518 3.83866 16.3036 4.84402C16.5 5.40533 16.5 6.10355 16.5 7.5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 946 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M13.6903 19.4567C13.5 18.9973 13.5 18.4149 13.5 17.25C13.5 16.0851 13.5 15.5027 13.6903 15.0433C13.944 14.4307 14.4307 13.944 15.0433 13.6903C15.5027 13.5 16.0851 13.5 17.25 13.5C18.4149 13.5 18.9973 13.5 19.4567 13.6903C20.0693 13.944 20.556 14.4307 20.8097 15.0433C21 15.5027 21 16.0851 21 17.25C21 18.4149 21 18.9973 20.8097 19.4567C20.556 20.0693 20.0693 20.556 19.4567 20.8097C18.9973 21 18.4149 21 17.25 21C16.0851 21 15.5027 21 15.0433 20.8097C14.4307 20.556 13.944 20.0693 13.6903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M13.6903 8.95671C13.5 8.49728 13.5 7.91485 13.5 6.75C13.5 5.58515 13.5 5.00272 13.6903 4.54329C13.944 3.93072 14.4307 3.44404 15.0433 3.1903C15.5027 3 16.0851 3 17.25 3C18.4149 3 18.9973 3 19.4567 3.1903C20.0693 3.44404 20.556 3.93072 20.8097 4.54329C21 5.00272 21 5.58515 21 6.75C21 7.91485 21 8.49728 20.8097 8.95671C20.556 9.56928 20.0693 10.056 19.4567 10.3097C18.9973 10.5 18.4149 10.5 17.25 10.5C16.0851 10.5 15.5027 10.5 15.0433 10.3097C14.4307 10.056 13.944 9.56928 13.6903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 19.4567C3 18.9973 3 18.4149 3 17.25C3 16.0851 3 15.5027 3.1903 15.0433C3.44404 14.4307 3.93072 13.944 4.54329 13.6903C5.00272 13.5 5.58515 13.5 6.75 13.5C7.91485 13.5 8.49728 13.5 8.95671 13.6903C9.56928 13.944 10.056 14.4307 10.3097 15.0433C10.5 15.5027 10.5 16.0851 10.5 17.25C10.5 18.4149 10.5 18.9973 10.3097 19.4567C10.056 20.0693 9.56928 20.556 8.95671 20.8097C8.49728 21 7.91485 21 6.75 21C5.58515 21 5.00272 21 4.54329 20.8097C3.93072 20.556 3.44404 20.0693 3.1903 19.4567Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/><path d="M3.1903 8.95671C3 8.49728 3 7.91485 3 6.75C3 5.58515 3 5.00272 3.1903 4.54329C3.44404 3.93072 3.93072 3.44404 4.54329 3.1903C5.00272 3 5.58515 3 6.75 3C7.91485 3 8.49728 3 8.95671 3.1903C9.56928 3.44404 10.056 3.93072 10.3097 4.54329C10.5 5.00272 10.5 5.58515 10.5 6.75C10.5 7.91485 10.5 8.49728 10.3097 8.95671C10.056 9.56928 9.56928 10.056 8.95671 10.3097C8.49728 10.5 7.91485 10.5 6.75 10.5C5.58515 10.5 5.00272 10.5 4.54329 10.3097C3.93072 10.056 3.44404 9.56928 3.1903 8.95671Z" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M18.8516 16.3743L19.5 6H4.5L5.14839 16.3743C5.315 19.0401 5.39831 20.373 6.26426 21.1865C7.13021 22 8.46571 22 11.1367 22H12.8633C15.5343 22 16.8698 22 17.7357 21.1865C18.6017 20.373 18.685 19.0401 18.8516 16.3743Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M16 6H21M16 6L15.3778 4.34082C15.0007 3.33524 14.8122 2.83245 14.4402 2.50292C14.3213 2.39765 14.1904 2.3069 14.0501 2.2326C13.6109 2 13.074 2 12 2C10.926 2 10.3891 2 9.9499 2.2326C9.8096 2.3069 9.67865 2.39765 9.55982 2.50292C9.18783 2.83245 8.99928 3.33524 8.62219 4.34082L8 6M16 6H8M3 6H8" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M9.5 11V17M14.5 11V17" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 928 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none"><path d="M16.9504 12.1817C17.1981 12.814 16.5076 13.5726 15.1267 15.0899C13.6702 16.6902 12.9201 17.4904 12 17.5C11.0799 17.4904 10.3298 16.6902 8.87331 15.0899C7.49239 13.5726 6.80193 12.814 7.04964 12.1817C7.05868 12.1586 7.06851 12.1359 7.0791 12.1135C7.34928 11.542 8.24477 11.5029 10 11.5002V4.99998C10 4.53501 10 4.30253 10.0511 4.11179C10.1898 3.59414 10.5941 3.1898 11.1118 3.05111C11.3025 3 11.535 3 12 3C12.4649 3 12.6974 3 12.8882 3.05111C13.4058 3.1898 13.8102 3.59414 13.9489 4.11179C14 4.30253 14 4.53501 14 4.99998V11.5002C15.7552 11.5029 16.6507 11.542 16.9209 12.1135C16.9315 12.1359 16.9413 12.1586 16.9504 12.1817Z" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/><path d="M5.00006 21H19.0001" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.6"/></svg>

After

Width:  |  Height:  |  Size: 945 B

Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more