Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 18ceb494e9 Server watchdog: put the server thread's stack in the crash report
ServerWatchdog's report only had the last 50 log lines, so a tick stuck
for 60 s on the official server (#15349) never said where. The watchdog now
interrupts the server thread with a signal (Linux, Android, Apple) whose
handler captures that thread's backtrace, and the report carries it as its
stack, as vanilla's report carries the server thread's stack. A thread that
does not answer in 5 s, or a system without the signal, is named in the
report's details instead.

Refs #15349

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-11 19:35:19 +01:00
104 changed files with 1228 additions and 3510 deletions

No files matched your search

+19 -164
View File
@@ -4,7 +4,6 @@
use horizon_core::paletted::{PalettedContainer, Strategy};
use horizon_world::nbt::{Compound, Tag};
use horizon_world::registry::BlockStateId;
use std::sync::atomic::{AtomicI16, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
pub mod block_entity;
@@ -458,44 +457,6 @@ impl Light {
}
}
}
/// One of a chunk's heightmaps (`Chunk::surface`): the heights, built whole on the first read,
/// and the columns a write may have moved since, each found again on its next read. Reads share
/// the chunk (helper threads read the world): a stale column is found the same by any of them.
#[derive(Default)]
struct Heights {
heights: OnceLock<Box<[AtomicI16; 256]>>,
stale: [AtomicU64; 4],
}
impl Heights {
/// `Heightmap.update`: a write two or more below column `i`'s height can't move it; any
/// other marks the column.
fn write(&mut self, i: usize, y: i32) {
let Some(heights) = self.heights.get_mut() else {
return;
};
let stale = self.stale[i >> 6].get_mut();
if *stale & 1 << (i & 63) == 0 && y >= i32::from(*heights[i].get_mut()) - 1 {
*stale |= 1 << (i & 63);
}
}
}
impl Clone for Heights {
fn clone(&self) -> Self {
let copy = Self::default();
// A reader can refresh a height and then clear its stale bit while we clone. Copy the
// bits first: a stale snapshot will rescan; a clean one acquires the refreshed height.
// Copying heights first could keep an old height with a newly cleared bit.
for (to, from) in copy.stale.iter().zip(&self.stale) {
to.store(from.load(Ordering::Acquire), Ordering::Relaxed);
}
if let Some(heights) = self.heights.get() {
let _ = copy.heights.set(Box::new(std::array::from_fn(|i| {
AtomicI16::new(heights[i].load(Ordering::Relaxed))
})));
}
copy
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
((z & 15) << 4 | (x & 15)) as usize
@@ -613,10 +574,10 @@ pub struct Chunk {
pub sections: Vec<Arc<Section>>,
/// `Heightmap.Types.WORLD_SURFACE` per column (`z << 4 | x`): one above
/// the highest non-air block, or the column's minimum Y. Built on the first read; a
/// `set` that can move a column's surface marks that column, found again on its next read.
surface: Heights,
/// `set` that can move a column's surface drops it.
surface: OnceLock<Box<[i16; 256]>>,
/// `Heightmap.Types.MOTION_BLOCKING` per column, kept like `surface`.
motion_blocking: Heights,
motion_blocking: OnceLock<Box<[i16; 256]>>,
pub lit: bool,
/// Light read from a save with `isLightOn`: no light job is needed.
pub light_loaded: bool,
@@ -657,8 +618,8 @@ impl Chunk {
z,
min_y: layout.min_y(),
sections: (0..layout.sections()).map(|_| empty.clone()).collect(),
surface: Heights::default(),
motion_blocking: Heights::default(),
surface: OnceLock::new(),
motion_blocking: OnceLock::new(),
lit: false,
light_loaded: false,
fresh: false,
@@ -737,7 +698,12 @@ impl Chunk {
// move it. Any other change rebuilds that map on the next read.
if previous != v {
for map in [&mut self.surface, &mut self.motion_blocking] {
map.write(column(x, z), y);
if map
.get()
.is_some_and(|heights| y >= i32::from(heights[column(x, z)]) - 1)
{
*map = OnceLock::new();
}
}
}
previous
@@ -748,55 +714,24 @@ impl Chunk {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = Heights::default();
self.motion_blocking = Heights::default();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
/// Sections must change only through `set` once this has been read.
pub fn world_surface(&self, x: i32, z: i32, air: impl Fn(u32) -> bool) -> i32 {
self.height(&self.surface, x, z, |v| !air(v))
let surface = self.surface.get_or_init(|| self.prime(|v| !air(v)));
i32::from(surface[column(x, z)])
}
/// `getHeight(Heightmap.Types.MOTION_BLOCKING, x, z)` (`x`, `z` within
/// the chunk): one above the highest state where `blocks` (a motion
/// blocking block or a fluid), or the column's minimum Y. Kept as
/// `world_surface` is.
pub fn motion_blocking(&self, x: i32, z: i32, blocks: impl Fn(u32) -> bool) -> i32 {
self.height(&self.motion_blocking, x, z, blocks)
}
/// One column's height in `map`: the whole map built on its first read, a column a write
/// marked found again down that column alone.
fn height(&self, map: &Heights, x: i32, z: i32, counts: impl Fn(u32) -> bool) -> i32 {
let heights = map.heights.get_or_init(|| {
let built = self.prime(&counts);
Box::new(std::array::from_fn(|i| AtomicI16::new(built[i])))
});
let i = column(x, z);
let (word, bit) = (i >> 6, 1u64 << (i & 63));
if map.stale[word].load(Ordering::Acquire) & bit != 0 {
let height = self.column_height(i, &counts);
heights[i].store(height, Ordering::Relaxed);
map.stale[word].fetch_and(!bit, Ordering::Release);
return i32::from(height);
}
i32::from(heights[i].load(Ordering::Relaxed))
}
/// [`Self::prime`] for column `i` alone.
fn column_height(&self, i: usize, counts: impl Fn(u32) -> bool) -> i16 {
for (s, section) in self.sections.iter().enumerate().rev() {
if section.blocks.uniform().is_some_and(|v| !counts(v)) {
continue;
}
let base = self.min_y + s as i32 * 16;
if let Some(dy) = (0..16)
.rev()
.find(|&dy| counts(section.blocks.get(dy << 8 | i)))
{
return (base + dy as i32 + 1) as i16;
}
}
self.min_y as i16
let heights = self.motion_blocking.get_or_init(|| self.prime(blocks));
i32::from(heights[column(x, z)])
}
/// `Heightmap.primeHeightmaps`: each section from the top, skipping
/// those of one state that doesn't count, until every column is found.
@@ -862,7 +797,7 @@ impl Chunk {
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.heights.get().is_some())
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
@@ -1085,89 +1020,9 @@ mod tests {
let before = chunk.world_surface(3, 3, is_air);
chunk.set(3, MIN_Y + HEIGHT, 3, stone);
chunk.set(3, before - 1, 3, chunk.get(3, before - 1, 3));
assert!(chunk.surface.heights.get().is_some());
assert!(chunk.surface.get().is_some());
assert_eq!(chunk.world_surface(3, 3, |_| unreachable!()), before);
}
/// A write two below a column's height can't move it, so the next read asks about no block;
/// a write at or above it (placing on top, clearing the top block) is found again on the next
/// read by looking down that column alone, and the other columns keep their heights.
#[test]
fn a_write_finds_its_own_column_again() {
// Each column holds its own state (16 + i), so a read that looks at another column's
// blocks shows up as a state that isn't air or this column's.
let state = |i: usize| 16 + i as u32;
let mut chunk = Chunk::empty(0, 0);
for i in 0..256 {
for y in MIN_Y..MIN_Y + 20 + (i as i32 % 7) {
chunk.set(i as i32 & 15, y, i as i32 >> 4, state(i));
}
}
let seen = std::cell::RefCell::new(Vec::new());
let surface = |c: &Chunk, i: usize| {
c.world_surface(i as i32 & 15, i as i32 >> 4, |v| {
seen.borrow_mut().push(v);
v == 0
})
};
let heights: Vec<i32> = (0..256).map(|i| surface(&chunk, i)).collect();
assert_eq!(heights[0], MIN_Y + 20);
assert_eq!(heights[6], MIN_Y + 26);
seen.borrow_mut().clear();
let (column, x, z) = (0x35, 5, 3);
let top = heights[column];
// Two below the height: the column keeps its map entry.
chunk.set(x, top - 2, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().is_empty());
chunk.set(x, top - 2, z, state(column));
// At the height: placed on top, found again down this column only.
chunk.set(x, top, z, state(column));
assert_eq!(surface(&chunk, column), top + 1);
assert!(!seen.borrow().is_empty());
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
seen.borrow_mut().clear();
assert_eq!(surface(&chunk, column), top + 1);
for i in (0..256).filter(|&i| i != column) {
assert_eq!(surface(&chunk, i), heights[i]);
}
assert!(
seen.borrow().is_empty(),
"a read found again an untouched column"
);
// One below the height: the top block cleared, the height drops by one.
chunk.set(x, top, z, 0);
assert_eq!(surface(&chunk, column), top);
assert!(seen.borrow().iter().all(|&v| v == 0 || v == state(column)));
}
/// Chunk snapshots can be cloned while helper threads refresh a stale height. No snapshot
/// may retain the old height with its stale bit cleared, for either map.
#[test]
fn cloning_during_a_height_refresh_keeps_the_new_height() {
for _ in 0..4096 {
let mut chunk = Chunk::empty(0, 0);
chunk.set(0, MIN_Y + 4, 0, 1);
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 5);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 5);
chunk.set(0, MIN_Y + 5, 0, 1);
let ready = std::sync::Barrier::new(2);
std::thread::scope(|scope| {
scope.spawn(|| {
ready.wait();
assert_eq!(chunk.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(chunk.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
});
ready.wait();
for _ in 0..4 {
let snapshot = chunk.clone();
assert_eq!(snapshot.world_surface(0, 0, |v| v == 0), MIN_Y + 6);
assert_eq!(snapshot.motion_blocking(0, 0, |v| v != 0), MIN_Y + 6);
}
});
}
}
/// MOTION_BLOCKING ignores states that don't count (air, grass) and
/// follows writes as the surface map does, both maps kept at once.
#[test]
+19 -6
View File
@@ -439,20 +439,33 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
}
}
/// The report for a hung server tick, written by the watchdog thread
/// (`ServerWatchdog`): the stuck thread's stack is not available to Rust, so
/// the report carries none.
pub fn report_watchdog(home: &Path, side: &'static str, message: String) -> Option<PathBuf> {
/// The report for a hung server tick, written by the watchdog thread (`ServerWatchdog`): the
/// stack is the server thread's, read from the watchdog (`horizon_threads::stack_dump`), as
/// vanilla's report carries the server thread's stack; without it, the details say why.
pub fn report_watchdog(
home: &Path,
side: &'static str,
message: String,
server_stack: Result<String, horizon_threads::stack_dump::NoStack>,
) -> Option<PathBuf> {
let mut details = system_details(side);
let stack = match server_stack {
Ok(stack) => stack_lines(&stack, false),
Err(why) => {
details.push(("Server thread stack".into(), why.to_string()));
String::new()
}
};
deliver(
home,
Report {
time: Local::now(),
description: "Watching Server".into(),
error: format!("{message} (Watching Server)"),
stack: String::new(),
stack,
thread: thread_name(side),
side,
details: system_details(side),
details,
log: crate::logging::recent_lines(),
},
)
+165 -57
View File
@@ -75,7 +75,6 @@ pub use entity_batch::{
WATER_MASK_OVERLAY, WORLD_TEXT_OVERLAY, WORLD_TEXT_POLYGON_OFFSET_OVERLAY, is_native_terrain,
mark_oit,
};
use external::{ShadowCull, shadow_box, shadow_casters, shadow_runs};
use section_fade::{
CHUNK_VISIBILITY_THRESHOLD, FadeInputs, chunk_visibility, fade_millis, section_instance,
section_visibility,
@@ -195,7 +194,6 @@ mod cull;
mod dx12_compiler;
mod end_sky;
pub mod entity_skin;
pub mod external;
mod flat;
mod frame_gen;
mod gpu_culling;
@@ -1371,10 +1369,8 @@ pub struct Renderer {
/// Per-frame scratch, kept to avoid allocating every frame.
visible: Vec<resident::Visible>,
visible_lods: Vec<lod::Visible>,
/// The sections a shader pack's shadow camera sees (`external::ShadowView`).
/// The sections a shader pack's shadow camera sees (`pack_post::ShadowView`).
shadow_visible: Vec<resident::Visible>,
/// The external shader pipeline world frames call (`external`); `None` draws vanilla's way.
external: Option<Box<dyn external::ExternalPipeline>>,
translucent_draws: Vec<(u64, usize, [u32; 5], (u32, Option<u32>))>,
origins: Vec<[f32; 4]>,
/// `DrawIndexedIndirectArgs` words: index count, instance count, first
@@ -1581,9 +1577,9 @@ fn requested_split() -> Option<u32> {
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
}
fn adapter_requested_split(info: &wgpu::AdapterInfo, asked: Option<u32>, n: u32) -> Option<u32> {
// Metal can fail on big mipmapped arrays (#8471): 512 layers each, more only for n > 8 x 512.
asked.or((info.backend == wgpu::Backend::Metal).then(|| n.div_ceil(SPRITE_ARRAYS).max(512)))
fn adapter_requested_split(info: &wgpu::AdapterInfo, requested: Option<u32>) -> Option<u32> {
// Metal can fail to sample large mipmapped array textures despite advertising the layer limit.
requested.or_else(|| (info.backend == wgpu::Backend::Metal).then_some(512))
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
@@ -2112,7 +2108,7 @@ fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
info.backend == wgpu::Backend::Gl,
adapter_requested_split(&info, requested_split(), layers),
adapter_requested_split(&info, requested_split()),
layers,
)?)
}
@@ -2827,8 +2823,10 @@ impl Renderer {
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
let split = check_layers(&adapter, layers)?;
if split < sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
let info = adapter.get_info();
surface_setup::log_sprite_split(&info, split, layers);
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
@@ -5244,7 +5242,6 @@ impl Renderer {
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
external: None,
block_entities: Default::default(),
block_entity_section_revisions: Default::default(),
block_entity_membership_revisions: Default::default(),
@@ -5628,9 +5625,6 @@ impl Renderer {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config);
if let Some(pipeline) = &mut self.external {
pipeline.resize([width, height]);
}
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -7267,19 +7261,12 @@ impl Renderer {
}
Ok(report)
}
/// Sets the external shader pipeline world frames call (`external`), or none.
pub fn set_external_pipeline(&mut self, pipeline: Option<Box<dyn external::ExternalPipeline>>) {
self.external = pipeline;
}
/// Makes the running shader pack, of either kind, forget what it kept from earlier frames:
/// the next frame draws as a freshly loaded pack's first. Nothing to do without a pack.
pub fn reset_shader_pack_history(&mut self) {
// A frame still on the submit thread uses the targets and readbacks replaced
// here.
self.land_frame();
if let Some(pipeline) = &mut self.external {
pipeline.reset_history();
}
if let Some((_, Some(post))) = &mut self.pack_post {
post.reset_history(&self.device);
}
@@ -8613,13 +8600,6 @@ impl Renderer {
// A shader pack's shadow pass draws the sections in its camera's box, all of them (the
// occlusion graph follows the eye, not the light), within shadowDistanceRenderMul; for
// an Iris-format pack, only those whose shadow can fall into the camera's view.
let state = external::FrameState {
frame,
output: &output,
};
if let Some(pipeline) = self.external.as_mut().filter(|_| !frame.title) {
pipeline.begin_frame(&state);
}
let shadow_view = self
.pack_post
.as_ref()
@@ -8631,12 +8611,6 @@ impl Renderer {
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
})
.or_else(|| {
self.external
.as_ref()
.filter(|_| !frame.title)?
.shadow_request(&state)
});
// `LevelRenderer.extractSectionDrawGroups`' fade-in time, for the instances.
let fade_ms = fade_millis(frame.chunk_fade);
@@ -11926,12 +11900,13 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
let lifts_world = output::lifts_world(
frame.title,
self.output.frame.format == LINEAR_FRAME,
self.shader_pack_kind() == Some("iris"),
self.native.as_ref().map(|n| n.frame.api),
);
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self.output.frame.format != LINEAR_FRAME
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
@@ -12767,6 +12742,61 @@ impl Cull for Frusta {
self.0.contains(min, max) || self.1.is_some_and(|f| f.contains(min, max))
}
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The block entities of `sections` whose box, a block wider on every side, comes within the
/// 64 blocks block entities are drawn to (`Renderer::visible_block_entities_in_frustum`).
fn block_entities_within_reach<'a>(
@@ -12789,6 +12819,17 @@ fn block_entities_within_reach<'a>(
})
.flatten()
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
fn shadow_box(view: &pack_post::ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// Camera-relative clip planes (`Frustum`).
#[derive(Clone, Copy, Debug)]
pub struct Frustum {
@@ -12912,6 +12953,21 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
let b = DVec3::new(rows[0].w, rows[1].w, rows[2].w);
Some((a.inverse() * -b).as_vec3())
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
fn shadow_runs(
sides: &Sides,
view: &pack_post::ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// Calls `draw(first, quads)` for each run of a mesh's solid quads that can
/// face `eye` (`projection_center`), the mesh at the camera-relative
@@ -12938,6 +12994,68 @@ fn facing_runs_from(
None => sides.every(part, draw),
}
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
fn taa_frustum(view_proj: Mat4, rendered_size: [u32; 2]) -> Frustum {
// TAA shifts clip x/y by at most half a rendered pixel times clip w.
// Widen x/y before extracting the planes: this includes every such shifted
@@ -14947,21 +15065,11 @@ mod sprite_upload_tests {
limit_bucket: None,
};
info.backend = wgpu::Backend::Metal;
assert_eq!(adapter_requested_split(&info, None, 1200), Some(512));
assert_eq!(
adapter_requested_split(&info, None, 512 * SPRITE_ARRAYS),
Some(512)
);
assert_eq!(adapter_requested_split(&info, Some(1024), 1200), Some(1024));
// More sprites than the arrays hold at 512: the arrays grow, up to the GPU's layers.
let split = adapter_requested_split(&info, None, 10_271);
assert_eq!(split, Some(1284));
assert_eq!(layer_split(2048, false, split, 10_271), Ok(1284));
let split = adapter_requested_split(&info, None, 2048 * SPRITE_ARRAYS + 1);
assert!(layer_split(2048, false, split, 2048 * SPRITE_ARRAYS + 1).is_err());
assert_eq!(adapter_requested_split(&info, None), Some(512));
assert_eq!(adapter_requested_split(&info, Some(1024)), Some(1024));
info.backend = wgpu::Backend::Vulkan;
assert_eq!(adapter_requested_split(&info, None, 10_271), None);
assert_eq!(adapter_requested_split(&info, None), None);
}
#[test]
@@ -15246,7 +15354,7 @@ mod shadow_face_tests {
}
/// The layers (faces) of the quads the shadow pass draws of `solid`.
fn drawn(view: &external::ShadowView, origin: [f32; 3]) -> Vec<u16> {
fn drawn(view: &pack_post::ShadowView, origin: [f32; 3]) -> Vec<u16> {
let mut solid = block([7.0, 7.0, 7.0]);
let sides = crate::mesher::sort_tinted(&mut solid, &mut Vec::new());
let mut layers = Vec::new();
@@ -15259,8 +15367,8 @@ mod shadow_face_tests {
layers
}
fn view(every_face: bool) -> external::ShadowView {
external::ShadowView {
fn view(every_face: bool) -> pack_post::ShadowView {
pack_post::ShadowView {
cull: Mat4::IDENTITY,
light: Vec3::Y,
distance: None,
@@ -15474,7 +15582,7 @@ mod shadow_caster_tests {
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = external::ShadowView {
let view = pack_post::ShadowView {
cull: pack_uniforms::shadow_culling_at(Vec3::Y, camera, &shadow),
light: Vec3::Y,
distance: None,
@@ -1,214 +0,0 @@
//! The hook an external shader pipeline fills (#14035, layer plan M6). A shader pack's runtime
//! (a platform crate, later) implements [`ExternalPipeline`]; the renderer calls it at fixed
//! points of a world frame and draws vanilla's way wherever it doesn't answer. With none set
//! (`Renderer::set_external_pipeline`), each point is one `Option` test.
//!
//! Workspace-internal: the trait names the engine's frame, its mesh format and its pass order,
//! has one implementer in this workspace, carries no stability promise and is never exposed to
//! mods. A mesher or pass change updates its implementer in the same PR. HDR output
//! (`output.rs`, `frame_policy`) is not an implementer: it stays the one output stage after the
//! frame, and a pipeline draws into the frame it gives.
//!
//! Shadows are an engine frame point, on request: a pipeline that wants a shadow pass answers
//! [`ShadowView`], the engine culls the sections from it ([`ShadowCull`]) and hands back the
//! terrain seen from the light. Vanilla's path never asks, so it never computes shadow
//! visibility.
use glam::{Mat4, Vec3, Vec4};
use super::{Cull, EffectiveOutput, Frame, Frustum, Sides, facing_runs, frustum};
/// What a world frame gives an external pipeline at its points: the frame's own values, and
/// the display's from the renderer's one output snapshot (`EffectiveOutput`: whether HDR is on,
/// paper white, peak), never worked out again, so each split-screen window keeps its own.
pub struct FrameState<'a> {
pub frame: &'a Frame<'a>,
pub output: &'a EffectiveOutput,
}
/// An external shader pipeline, called by the renderer at fixed points of a world frame (not
/// title frames). Every method has a default that leaves vanilla's way in place.
pub trait ExternalPipeline: Send {
/// A world frame starts, before culling.
fn begin_frame(&mut self, _state: &FrameState<'_>) {}
/// The shadow pass this frame wants; `None` draws none.
fn shadow_request(&self, _state: &FrameState<'_>) -> Option<ShadowView> {
None
}
/// The window's drawable size changed.
fn resize(&mut self, _size: [u32; 2]) {}
/// Forget what was kept from earlier frames: the next is drawn as a fresh pipeline's first.
fn reset_history(&mut self) {}
}
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// A shader pack's shadow camera's box, and with `casters` only what can shade the camera's view
/// (`shadow_casters`): a box must meet all of them.
#[derive(Clone, Copy, Debug)]
pub struct ShadowCull {
pub light: Frustum,
pub casters: Option<[Frustum; 2]>,
/// `shadowDistance × shadowDistanceRenderMul`: only what lies within this many blocks of the
/// camera along each axis, a cube around it. Our probe's Iris 1.11.7 maps keep the sections
/// whose box, grown by `SHADOW_CASTER_MARGIN` as for the casters, meets that cube (#8464):
/// out to its corners, and a section 1 block past its side but not one 1.5 blocks past.
pub reach: Option<f32>,
}
impl ShadowCull {
/// Whether the camera-relative box `min..max`, grown by the margin, meets (`whole`: lies
/// within) `reach`'s cube.
fn within_reach(&self, min: Vec3, max: Vec3, whole: bool) -> bool {
let (min, max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.reach.is_none_or(|d| {
if whole {
min.cmpge(Vec3::splat(-d)).all() && max.cmple(Vec3::splat(d)).all()
} else {
max.cmpge(Vec3::splat(-d)).all() && min.cmple(Vec3::splat(d)).all()
}
})
}
}
/// How far past a section's 16 blocks the shadow pass looks for what can shade the view: our
/// probes' Iris 1.11.7 maps keep the sections whose box, grown by this much on every side, meets
/// `shadow_casters`' shape. Over 76 scenes at fields of view 30 to 110, Iris keeps every section
/// that needs up to 1.1224 blocks and drops every one that needs 1.1252 or more (#8464): one
/// growth, whatever the field of view, of 9/8 (a fit, not Iris's code). Without it the edges of
/// the wedge lose a column of terrain.
const SHADOW_CASTER_MARGIN: f32 = 1.125;
impl Cull for ShadowCull {
fn intersects(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, false)
&& self.light.intersects(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.intersects(grown_min, grown_max)))
}
fn contains(&self, min: Vec3, max: Vec3) -> bool {
let (grown_min, grown_max) = (min - SHADOW_CASTER_MARGIN, max + SHADOW_CASTER_MARGIN);
self.within_reach(min, max, true)
&& self.light.contains(min, max)
&& self
.casters
.is_none_or(|c| c.iter().all(|f| f.contains(grown_min, grown_max)))
}
/// Our probe's Iris 1.11.7 maps fit testing the casters' whole sections (a guess that matched).
fn tight(&self) -> bool {
self.casters.is_none()
}
}
/// The shadow camera's box that the shadow pass draws terrain in: the sides of `view.cull`, and
/// its near and far planes only with `depth_planes`.
pub(super) fn shadow_box(view: &ShadowView) -> Frustum {
let mut planes = frustum(view.cull);
if !view.depth_planes {
// `frustum`'s faces at depth 0 and 1 take in everything.
planes[4] = Vec4::W;
planes[5] = Vec4::W;
}
Frustum { planes }
}
/// The runs of a section's solid quads the shadow pass draws: every face within the unculled
/// distance, or everywhere for `every_face`; else the quads that can face the light.
pub(super) fn shadow_runs(
sides: &Sides,
view: &ShadowView,
origin: [f32; 3],
draw: impl FnMut(u32, u32),
) {
let unculled = view.unculled.is_some_and(|d| {
let o = Vec3::from(origin) + 8.0;
o.x.abs().max(o.z.abs()) <= d + 8.0
});
let toward = (!unculled && !view.every_face).then_some(view.light * 1.0e5);
facing_runs(sides, toward, origin, draw);
}
/// The camera's view `view_proj` (wgpu depth, reversed or not) stretched without end toward
/// `light` (a direction): the boxes whose shadow, cast away from the light, can fall into the
/// view. Its planes are the view's faces turned toward the light, and through each edge between
/// such a face and one turned away, the plane along the light; unused ones take in everything.
pub(super) fn shadow_casters(view_proj: Mat4, light: Vec3) -> [Frustum; 2] {
let faces = frustum(view_proj);
let toward = faces.map(|p| p.truncate().dot(light) >= 0.0);
let inverse = view_proj.inverse();
// Corner (x, y, z) of clip space's box; `frustum`'s faces at x -1 and 1 are 0 and 1, at
// y -1 and 1 are 2 and 3, at depth 0 and 1 are 4 and 5.
let corner = |c: [usize; 3]| {
inverse.project_point3(Vec3::new(
c[0] as f32 * 2.0 - 1.0,
c[1] as f32 * 2.0 - 1.0,
c[2] as f32,
))
};
let corners: Vec<[usize; 3]> = (0..8).map(|i| [i & 1, (i >> 1) & 1, i >> 2]).collect();
let centre = corners.iter().map(|&c| corner(c)).sum::<Vec3>() / 8.0;
let mut planes = [Vec4::W; 12];
let mut n = 0;
for (k, &face) in faces.iter().enumerate() {
if toward[k] {
planes[n] = face;
n += 1;
}
}
for a in &corners {
for axis in 0..3 {
if a[axis] == 1 {
continue;
}
let mut b = *a;
b[axis] = 1;
// The edge from a to b lies on the faces of the two other axes.
let [f, g] = [(axis + 1) % 3, (axis + 2) % 3].map(|o| o * 2 + a[o]);
if toward[f] == toward[g] {
continue;
}
let (p, q) = (corner(*a), corner(b));
let normal = (q - p).cross(light);
if normal.length_squared() < 1e-12 {
continue;
}
let normal = normal.normalize();
let side = if normal.dot(centre - p) < 0.0 {
-1.0
} else {
1.0
};
// At most six such edges with at most four faces toward the light: 12 planes.
if let Some(plane) = planes.get_mut(n) {
*plane = (normal * side).extend(-normal.dot(p) * side);
n += 1;
}
}
}
let [a, b] = [0, 6].map(|i| Frustum {
planes: planes[i..i + 6].try_into().expect("six planes"),
});
[a, b]
}
@@ -295,22 +295,6 @@ pub(crate) fn resolve_output(
}
}
/// Whether HDR output lifts the world's light-source marks above paper white. Not on the title
/// screen or the float frame, and not under a pack that lights its world itself: a 0.2 pack is
/// shown at paper white, and a `shaders/` pack's `final` pass leaves its own alpha in the
/// frame, which isn't marks (HDR lighting is off under it too).
pub(crate) fn lifts_world(
title: bool,
float_frame: bool,
shaders_pack: bool,
native_api: Option<horizon_shader_api::Api>,
) -> bool {
!title
&& !float_frame
&& !shaders_pack
&& native_api.is_none_or(|api| api == horizon_shader_api::Api::V0_1)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
@@ -353,29 +337,6 @@ pub(crate) mod tests {
resolve_output(settings, caps, reading, frame, true)
}
/// HDR output lifts light-source marks only on the 8/16-bit frame's world with no pack or
/// a 0.1 pack: a `shaders/` pack's alpha isn't marks, and a 0.2 pack is shown at paper white.
#[test]
fn only_horizons_own_gamma_world_is_lifted() {
use horizon_shader_api::Api::{V0_1, V0_2};
// (title, float frame, `shaders/` pack, Horizon pack) -> lifts
let cases = [
((false, false, false, None), true),
((false, false, false, Some(V0_1)), true),
((false, false, true, None), false),
((false, false, false, Some(V0_2)), false),
((false, true, false, None), false),
((true, false, false, None), false),
];
for ((title, float, shaders, api), lifts) in cases {
assert_eq!(
lifts_world(title, float, shaders, api),
lifts,
"title {title}, float {float}, shaders/ pack {shaders}, pack {api:?}"
);
}
}
/// Every mode against every offer, system report, pack and device: HDR output only where
/// the mode asks for it and the destination offers it, and the frame each combination
/// draws in, as `Renderer::new` picked it before the snapshot.
@@ -1201,7 +1201,31 @@ pub(super) type TerrainDraw<'a> = dyn FnMut(
Option<(u32, &wgpu::BindGroupLayout)>,
) + 'a;
pub(super) use super::external::ShadowView;
/// What the shadow camera sees of the terrain, for the game's draw lists: its view and
/// projection in wgpu's clip space, the direction toward the light, and how far from the camera
/// terrain is drawn (`shadowDistanceRenderMul`).
pub(super) struct ShadowView {
pub cull: glam::Mat4,
pub light: glam::Vec3,
pub distance: Option<f32>,
/// Draw everything, every face, within this many blocks (`shadow.culling=false|reversed`):
/// packs that voxelize the world in their shadow pass need all of it.
pub unculled: Option<f32>,
/// Every face of the drawn sections, not only those that can face the light. Iris 1.11.7's
/// shadow map holds the faces turned away from the light too (#9033): under a hill at low sun
/// a cave's ceiling, which faces away, is often all that shadows the cave.
pub every_face: bool,
/// Only the sections whose shadow can fall into the camera's view: those the camera's
/// frustum, stretched toward the light, takes in (`shadow_casters`). Our probes' Iris 1.11.7
/// maps fit keeping only those with `shadow.culling` unset (#8464): under a noon sun, only
/// the terrain above the ground the camera sees; with a low sun ahead, none behind the camera.
pub view_casters: bool,
/// Whether the projection's near and far planes in `cull` drop sections too. Our probes'
/// Iris 1.11.7 maps hold the terrain at every depth we put it along the light (from 535 blocks
/// beyond the shadow camera to about 120 before it), whatever `shadowNearPlane` and
/// `shadowFarPlane` the pack sets, or none (#10403): only its sides cull.
pub depth_planes: bool,
}
/// An arena page's key, native vertices and optional per-quad source records.
pub(super) type TerrainPage = (u32, wgpu::Buffer, Option<wgpu::Buffer>);
@@ -3807,7 +3831,7 @@ impl PackPost {
timestamp_writes: self
.timing
.as_mut()
.and_then(|t| t.compute_writes(&format!("{} (compute)", pass.name))),
.and_then(|t| t.compute_writes(&pass.name)),
});
cp.set_pipeline(&pipeline);
cp.set_bind_group(0, &group, &[]);
@@ -151,9 +151,7 @@ impl PassTiming {
Some(stamps(&self.queries, i))
}
/// Timestamp writes for the compute pass `name`, in a timed frame. Shader packs' compute
/// passes come as `<program> (compute)`, apart from the fragment program of the same name
/// (`deferred2.csh` beside `deferred2.fsh`), whose time theirs would otherwise add to.
/// Timestamp writes for the compute pass `name`, in a timed frame.
pub fn compute_writes(&mut self, name: &str) -> Option<wgpu::ComputePassTimestampWrites<'_>> {
let i = self.push(name)?;
Some(compute_stamps(&self.queries, i))
@@ -238,7 +236,23 @@ impl PassTiming {
drop(data);
self.read.unmap();
let times = pass_times(&stamps, self.period_ns);
record(&mut self.samples, self.names.iter().zip(times));
for (name, ms) in self.names.iter().zip(times) {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
let i = match self.samples.iter().position(|s| s.0 == *name) {
Some(i) => i,
None => {
self.samples.push((name.clone(), VecDeque::new()));
self.samples.len() - 1
}
};
let window = &mut self.samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
self.reports += 1;
if self.log && self.reports.is_multiple_of(REPORT) {
let medians = self.medians();
@@ -269,40 +283,6 @@ impl PassTiming {
}
}
/// Adds one timed frame's pass times to `samples`, each pass's window of its last `WINDOW`
/// frames. Passes of one name in a frame (a program drawn over several render passes) count as
/// one sample, their sum: as separate samples they shared one window, and its median was the
/// time of neither. Unnamed and unstamped passes are left out.
fn record<'a>(
samples: &mut Vec<(String, VecDeque<f32>)>,
frame: impl Iterator<Item = (&'a String, Option<f32>)>,
) {
let mut sums: Vec<(&str, f32)> = Vec::new();
for (name, ms) in frame {
let Some(ms) = ms.filter(|_| !name.is_empty()) else {
continue;
};
match sums.iter_mut().find(|s| s.0 == name) {
Some(s) => s.1 += ms,
None => sums.push((name, ms)),
}
}
for (name, ms) in sums {
let i = match samples.iter().position(|s| s.0 == name) {
Some(i) => i,
None => {
samples.push((name.to_owned(), VecDeque::new()));
samples.len() - 1
}
};
let window = &mut samples[i].1;
if window.len() == WINDOW {
window.pop_front();
}
window.push_back(ms);
}
}
/// Pass `i`'s beginning and end timestamps.
fn stamps(queries: &wgpu::QuerySet, i: u32) -> wgpu::RenderPassTimestampWrites<'_> {
wgpu::RenderPassTimestampWrites {
@@ -343,21 +323,7 @@ fn pass_times(stamps: &[u64], period_ns: f32) -> Vec<Option<f32>> {
#[cfg(test)]
mod tests {
use super::{pass_times, record};
use std::collections::VecDeque;
#[test]
fn passes_of_one_name_count_as_their_sum() {
let names = ["terrain", "sky", "terrain", "", "water"].map(String::from);
let times = [Some(1.0), Some(2.0), Some(3.0), Some(9.0), None];
let mut samples: Vec<(String, VecDeque<f32>)> = Vec::new();
record(&mut samples, names.iter().zip(times));
let got: Vec<(&str, Vec<f32>)> = samples
.iter()
.map(|(n, w)| (n.as_str(), w.iter().copied().collect()))
.collect();
assert_eq!(got, [("terrain", vec![4.0]), ("sky", vec![2.0])]);
}
use super::pass_times;
#[test]
fn overlapping_passes_cost_from_the_previous_end() {
@@ -221,18 +221,6 @@ pub(super) async fn open_adapter(
configured,
})
}
/// Logs a sprite split below the CPU's (the GPU's limit), and warns when Metal's arrays grew
/// past 512 layers (#12155), so a big pack's texture-corruption report ties to it (#8471).
pub(super) fn log_sprite_split(info: &wgpu::AdapterInfo, split: u32, layers: u32) {
if info.backend == wgpu::Backend::Metal && split > 512 {
log::warn!(
"Horizon: {split}-layer Metal sprite arrays for {layers} on {}",
info.name
);
} else if split < super::sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
}
#[cfg(test)]
mod tests {
use super::{AdapterChoice, pick_adapter};
+87 -6
View File
@@ -928,7 +928,7 @@ impl Opt {
Opt::CloudRange => s.cloud_range = step(2.0, 128.0) as i32,
Opt::EntityDistance => s.entity_distance = step(2.0, 20.0) / 4.0,
Opt::BiomeBlend => s.biome_blend = step(0.0, 7.0) as i32,
Opt::Preset => s.graphics_preset = PRESETS[preset_from_slider(v)].into(),
Opt::Preset => s.graphics_preset = PRESETS[step(0.0, 3.0) as usize].into(),
Opt::Mipmaps => s.mipmap_levels = step(0.0, 4.0) as i32,
Opt::MaxAnisotropy => s.file.set(
"maxAnisotropyBit",
@@ -1218,6 +1218,92 @@ impl Opt {
}
}
}
const PRESETS: [&str; 4] = ["fast", "fancy", "fabulous", "custom"];
fn preset_index(p: &str) -> usize {
PRESETS.iter().position(|x| *x == p).unwrap_or(1)
}
/// A slider moved to `v` (OptionInstance.set): the graphics preset applies
/// its options when its value changes (`Options.applyGraphicsPreset`).
pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
let preset = preset_index(&s.graphics_preset);
opt.set_slider(s, v);
if opt == Opt::Preset && preset_index(&s.graphics_preset) != preset {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
pub fn apply_preset(s: &mut Settings) {
let fabulous = s.graphics_preset == "fabulous";
match s.graphics_preset.as_str() {
"fast" => {
s.biome_blend = 1;
s.render_distance = 8;
s.prioritize_chunk_updates = 0;
s.simulation_distance = 6;
s.smooth_lighting = false;
s.clouds = "fast".into();
s.particles = 1;
s.mipmap_levels = 2;
s.entity_shadows = false;
s.entity_distance = 0.75;
s.menu_blur = 2;
s.cloud_range = 32;
s.cutout_leaves = false;
s.improved_transparency = false;
s.weather_radius = 5;
set_filtering(s, 1, 0);
}
"fancy" | "fabulous" => {
s.biome_blend = 2;
s.render_distance = if fabulous { 32 } else { 16 };
s.prioritize_chunk_updates = 1;
s.simulation_distance = 12;
s.smooth_lighting = true;
s.clouds = "true".into();
s.particles = 0;
s.mipmap_levels = 4;
s.entity_shadows = true;
s.entity_distance = if fabulous { 1.25 } else { 1.0 };
s.menu_blur = 5;
s.cloud_range = if fabulous { 128 } else { 64 };
s.cutout_leaves = true;
s.improved_transparency = fabulous;
s.weather_radius = 10;
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
// wgpu does not report).
set_filtering(
s,
if fabulous { 2 } else { 1 },
if fabulous { 2 } else { 1 },
);
}
_ => {}
}
}
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
use crate::options_file::V;
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
s.file.set("textureFiltering", V::Int(filtering));
}
/// `Options.preferredGraphicsBackend`'s tooltip: the restart note once the choice differs from
/// the startup one, the description, and Vulkan's warning.
@@ -8198,11 +8284,6 @@ mod tests {
}
}
#[path = "options_ui_preset.rs"]
mod preset;
pub(crate) use preset::{
PRESETS, apply_preset, drag_slider, preset_from_slider, preset_index, set_slider,
};
#[cfg(test)]
#[path = "options_ui_graphics_tests.rs"]
mod graphics_tests;
@@ -1,121 +0,0 @@
//! The graphics preset: `Options.graphicsPreset`'s slider position and the
//! options `GraphicsPreset.apply` sets.
use super::*;
pub(crate) const PRESETS: [&str; 4] = ["fast", "fancy", "fabulous", "custom"];
/// `SliderableEnum.fromSliderValue`: the slider's 0..1 position maps by
/// `floor(v * values)` with 1.0 just below it, clamped to the last stop (4
/// graphics presets), not by rounding `v * 3`.
pub(crate) fn preset_from_slider(v: f32) -> usize {
let v = if v >= 1.0 { 0.99999 } else { v };
((v * 4.0).floor() as usize).min(3)
}
pub(crate) fn preset_index(p: &str) -> usize {
PRESETS.iter().position(|x| *x == p).unwrap_or(1)
}
/// A slider moved to `v` (OptionInstance.set): the graphics preset applies
/// its options when its value changes (`Options.applyGraphicsPreset`).
pub(crate) fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
let preset = preset_index(&s.graphics_preset);
opt.set_slider(s, v);
if opt == Opt::Preset && preset_index(&s.graphics_preset) != preset {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
pub(crate) fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
pub(crate) fn apply_preset(s: &mut Settings) {
let fabulous = s.graphics_preset == "fabulous";
match s.graphics_preset.as_str() {
"fast" => {
s.biome_blend = 1;
s.render_distance = 8;
s.prioritize_chunk_updates = 0;
s.simulation_distance = 6;
s.smooth_lighting = false;
s.clouds = "fast".into();
s.particles = 1;
s.mipmap_levels = 2;
s.entity_shadows = false;
s.entity_distance = 0.75;
s.menu_blur = 2;
s.cloud_range = 32;
s.cutout_leaves = false;
s.improved_transparency = false;
s.weather_radius = 5;
set_filtering(s, 1, 0);
}
"fancy" | "fabulous" => {
s.biome_blend = 2;
s.render_distance = if fabulous { 32 } else { 16 };
s.prioritize_chunk_updates = 1;
s.simulation_distance = 12;
s.smooth_lighting = true;
s.clouds = "true".into();
s.particles = 0;
s.mipmap_levels = 4;
s.entity_shadows = true;
s.entity_distance = if fabulous { 1.25 } else { 1.0 };
s.menu_blur = 5;
s.cloud_range = if fabulous { 128 } else { 64 };
s.cutout_leaves = true;
s.improved_transparency = fabulous;
s.weather_radius = 10;
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
// wgpu does not report).
set_filtering(
s,
if fabulous { 2 } else { 1 },
if fabulous { 2 } else { 1 },
);
}
_ => {}
}
}
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
use crate::options_file::V;
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
s.file.set("textureFiltering", V::Int(filtering));
}
#[cfg(test)]
mod tests {
use super::*;
/// `SliderableEnum.fromSliderValue`: the graphics preset slider's stops are
/// floor(v * 4), 1.0 landing on the last, and the value setter follows.
#[test]
fn the_graphics_preset_slider_uses_floor_stops() {
assert_eq!(preset_from_slider(0.0), 0);
assert_eq!(preset_from_slider(0.24), 0);
assert_eq!(preset_from_slider(0.25), 1);
assert_eq!(preset_from_slider(0.49), 1);
assert_eq!(preset_from_slider(0.5), 2);
assert_eq!(preset_from_slider(0.74), 2);
assert_eq!(preset_from_slider(0.75), 3);
assert_eq!(preset_from_slider(1.0), 3);
let mut s = Settings::default();
set_slider(Opt::Preset, &mut s, 0.2);
assert_eq!(s.graphics_preset, "fast");
set_slider(Opt::Preset, &mut s, 0.8);
assert_eq!(s.graphics_preset, "custom");
}
}
@@ -681,36 +681,26 @@ impl TextField {
}
/// `setValue`.
pub fn set_value(&mut self, m: &Measure, value: &str) {
self.value = crate::ui::utf16_prefix(value, self.max_length);
self.value = value.chars().take(self.max_length).collect();
self.move_cursor_to(m, chars(&self.value), false);
self.set_highlight(m, self.cursor);
}
/// `insertText`: replaces the selection within the maximum length. True
/// when the value changed (its responder runs). The limit is in UTF-16
/// code units, as Java's `String.length()` counts them.
/// when the value changed (its responder runs).
pub fn insert_text(&mut self, m: &Measure, input: &str) -> bool {
let (start, end) = (
self.cursor.min(self.highlight),
self.cursor.max(self.highlight),
);
let selected_units: usize = self
.value
.chars()
.skip(start)
.take(end - start)
.map(char::len_utf16)
.sum();
let room = self
.max_length
.saturating_sub(crate::ui::utf16_len(&self.value) - selected_units);
if room == 0 {
let room = self.max_length as isize - chars(&self.value) as isize + (end - start) as isize;
if room <= 0 {
return false;
}
let filtered: String = input
.chars()
.filter(|c| *c >= ' ' && *c != '\u{7f}' && *c != '§')
.collect();
let insert = crate::ui::utf16_prefix(&filtered, room);
let insert: String = filtered.chars().take(room as usize).collect();
let n = chars(&insert);
self.value = format!(
"{}{}{}",
@@ -922,8 +912,7 @@ impl TextField {
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
draw_x += w + 1;
}
// `value.length() >= maxLength` is Java's length: UTF-16 units.
let insert = self.cursor < len || crate::ui::utf16_len(&self.value) >= self.max_length;
let insert = self.cursor < len || len >= self.max_length;
let mut cursor_x = draw_x;
if !on_screen {
cursor_x = if rel_cursor > 0 { tx + self.width } else { tx };
@@ -1438,32 +1427,3 @@ mod scissor_tests {
);
}
}
#[cfg(test)]
mod max_length_tests {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
/// `EditBox.maxLength` counts Java `String.length()` (UTF-16 code units),
/// and the chat box is this field: ChatScreen's 256 takes 128 emoji, not
/// 256, and a half-fitted emoji is dropped rather than split.
#[test]
fn the_chat_box_maximum_counts_utf16_units() {
let atlas = atlas();
let m = Measure(&atlas.font);
let mut field = TextField::new(0, 0, 200, 20);
field.max_length = 3;
field.insert_text(&m, "ab😀");
assert_eq!(
field.value, "ab",
"three units: a, b and one of the emoji's"
);
field.set_value(&m, "");
field.max_length = 4;
field.insert_text(&m, "ab😀");
assert_eq!(field.value, "ab😀", "four units, both of the emoji's");
}
}
@@ -501,12 +501,7 @@ pub(crate) fn row(batch: &mut HudBatch, atlas: &HudAtlas, cols: &Columns, y: f32
let lit = [rect[0] + 2.0, y + 1.0, rect[2] - 4.0, style::ROW - 2.0];
style::fill(batch, atlas, lit, style::hover());
}
// AbstractSliderButton.handleCursor: a dragged slider resizes, buttons point.
if slider.is_some() {
crate::widgets::slider_cursor(hover, live, held);
} else {
crate::widgets::widget_cursor(hover, live);
}
crate::widgets::widget_cursor(hover, live);
// The control socket's widget tree, the narrator and a controller's focus.
let kind = match slider {
Some(value) => crate::widgets::Kind::Slider {
+186 -3
View File
@@ -12,11 +12,8 @@
//! or an empty answer keeps the cached lists, `Retry-After` sets the poll interval, and
//! `getFriendData` waits out a ten-second cooldown between reads. The presence statuses
//! are `PresenceHandler.getPresenceStatus`'s, supplied by the client each tick.
mod list_updates;
mod model;
use list_updates::{Handler, spawn_updater};
pub use list_updates::{State, set_foreground};
use model::presence_of;
pub use model::{
Friend, FriendData, Presence, PresenceStatus, ResultCode, parse_friends, parse_presence,
@@ -33,6 +30,36 @@ use std::{
time::{Duration, Instant},
};
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `Options.sharePresence` (`PresenceSharing`): how much of the player the service sees.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum SharePresence {
@@ -76,6 +103,45 @@ impl Service {
}
}
/// `RemoteFriendListUpdateHandler`.
struct Handler {
enabled: bool,
state: State,
latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `PresenceHandler`.
struct PresenceHandler {
latest: Vec<Presence>,
@@ -204,6 +270,11 @@ pub fn latest_presence() -> Vec<Presence> {
lock().presence.latest.clone()
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `Options.sharePresence`: what presence the handler posts.
pub fn set_share_presence(share: SharePresence) {
lock().share = share;
@@ -353,6 +424,100 @@ fn friend_action(body: Value) -> ResultCode {
code
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
/// `MinecraftServicesFriendsService.presence`: post one status, cache the answer.
/// `None` means the answer said nothing new (304, an empty body or a failure); the
/// handler keeps its last presence then.
@@ -509,6 +674,24 @@ fn http_error(error: ureq::Error) -> Failure {
mod tests {
use super::*;
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
#[test]
fn the_share_presence_option_maps_to_its_levels() {
assert_eq!(SharePresence::of(0), SharePresence::None);
@@ -1,199 +0,0 @@
use super::{
Failure, FriendData, ResultCode, Service, fetch_friends, lock, parse_friends, session,
};
use std::{
sync::atomic::{AtomicBool, Ordering},
thread,
time::{Duration, Instant},
};
/// `RemoteFriendListUpdateHandler.State`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum State {
Loading,
UpgradeNeeded,
ConnectionIssue,
UserMayLackActiveProfile,
Unauthorized,
TemporaryUnavailable,
GenericError,
Success,
}
impl State {
/// `RemoteFriendListUpdateHandler.mapResultCodeToState`.
fn of(code: ResultCode) -> Self {
match code {
ResultCode::Success => Self::Success,
ResultCode::UpgradeNeeded => Self::UpgradeNeeded,
ResultCode::ConnectionIssue => Self::ConnectionIssue,
ResultCode::UnknownProfile => Self::UserMayLackActiveProfile,
ResultCode::Unauthorized => Self::Unauthorized,
ResultCode::TemporaryUnavailable
| ResultCode::Forbidden
| ResultCode::ServiceNotAvailable
| ResultCode::TooManyRequests => Self::TemporaryUnavailable,
ResultCode::Error | ResultCode::GenericError => Self::GenericError,
}
}
}
/// `RemoteFriendListUpdateHandler`.
pub(super) struct Handler {
pub(super) enabled: bool,
pub(super) state: State,
pub(super) latest: FriendData,
/// `lastUpdateNanos`: a read at that moment is due; `None` reads at once.
pub(super) last_update: Option<Instant>,
/// A forced read (`forceUpdate`) is due, whatever the interval.
pub(super) forced: bool,
/// `getUpdateIntervalNanos`'s foreground: the friends overlay is open.
foreground: bool,
}
impl Default for Handler {
fn default() -> Self {
Self {
enabled: false,
state: State::Loading,
latest: FriendData::default(),
last_update: None,
forced: false,
foreground: false,
}
}
}
impl Handler {
/// `getUpdateIntervalNanos`: the service's interval (a minute when it named none),
/// five times as long in the background.
fn interval(&self, service: &Service) -> Duration {
let foreground = service.friends_poll.unwrap_or(Duration::from_secs(60));
if self.foreground {
foreground
} else {
foreground * 5
}
}
}
/// `RemoteFriendListUpdateHandler`'s foreground interval: the friends overlay is open.
pub fn set_foreground(open: bool) {
lock().handler.foreground = open;
}
/// `RemoteFriendListUpdateHandler.start`'s worker: one read per interval while the list
/// is enabled, the first one as soon as it starts.
pub(super) fn spawn_updater() {
static STARTED: AtomicBool = AtomicBool::new(false);
if STARTED.swap(true, Ordering::Relaxed) {
return;
}
let _ = thread::Builder::new()
.name("Friends List".into())
.spawn(|| {
loop {
thread::sleep(Duration::from_secs(1));
let due = {
let inner = lock();
if !inner.handler.enabled {
continue;
}
inner.handler.forced
|| inner
.handler
.last_update
.is_none_or(|at| at.elapsed() >= inner.handler.interval(&inner.service))
};
if due {
update_friend_data();
}
}
});
}
/// `RemoteFriendListUpdateHandler.runUpdateFriendDataInternal`.
pub(super) fn update_friend_data() {
let etag = {
let mut inner = lock();
if inner.service.pending {
// Another read is in flight; its answer is the cache's.
return;
}
if !inner.service.can_make_request() {
inner.handler.forced = false;
inner.handler.state = State::Success;
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.last_update = Some(Instant::now());
return;
}
inner.service.pending = true;
inner.service.cooldown = Some(Instant::now() + Duration::from_secs(10));
inner.service.friends_etag.clone()
};
log::debug!("Performing Friends List update");
let result = match session() {
Some(session) => fetch_friends(&session, etag.as_deref()),
None => Err(Failure {
code: ResultCode::Unauthorized,
}),
};
let mut inner = lock();
inner.service.pending = false;
inner.handler.forced = false;
inner.handler.last_update = Some(Instant::now());
match result {
Ok(answer) => {
if let Some(retry) = answer.retry_after {
inner.service.friends_poll = Some(retry);
}
if let Some(etag) = answer.etag {
inner.service.friends_etag = Some(etag);
}
if answer.status != 304 && !answer.body.is_empty() {
match parse_friends(&answer.body) {
Some(data) => inner.service.friends_cache = data,
None => {
// authlib's parse failure is an ERROR result: the handler keeps
// the cached lists and shows the generic error.
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::GenericError;
return;
}
}
}
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::Success;
}
Err(failure) => {
inner.handler.latest = inner.service.friends_cache.clone();
inner.handler.state = State::of(failure.code);
log::warn!(
"Friends List update failed with result code: {:?}",
failure.code
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn result_codes_map_to_the_handlers_states() {
assert_eq!(State::of(ResultCode::Success), State::Success);
assert_eq!(
State::of(ResultCode::TooManyRequests),
State::TemporaryUnavailable
);
assert_eq!(
State::of(ResultCode::UnknownProfile),
State::UserMayLackActiveProfile
);
assert_eq!(State::of(ResultCode::UpgradeNeeded), State::UpgradeNeeded);
assert_eq!(
State::of(ResultCode::ConnectionIssue),
State::ConnectionIssue
);
}
}
+6 -65
View File
@@ -1421,7 +1421,7 @@ pub fn append_field(
batch.text(atlas, value, x, y, color, shadow);
cursor_x += HudBatch::text_width(atlas, value) + 1.0;
}
let insert = utf16_len(value) >= max_chars;
let insert = char_len(value) >= max_chars;
if insert {
cursor_x -= 1.0;
}
@@ -1444,29 +1444,6 @@ fn byte_index(s: &str, chars: usize) -> usize {
pub fn char_len(s: &str) -> usize {
s.chars().count()
}
/// Java's `String.length()`: UTF-16 code units, the unit `EditBox.maxLength`
/// counts. A character outside the BMP — an emoji, say — is two of them, so
/// a box with room for one more character takes an `a` and rejects a `😀`.
pub fn utf16_len(s: &str) -> usize {
s.encode_utf16().count()
}
/// The longest prefix of `s` that fits `max` UTF-16 code units without
/// splitting a character. `EditBox.insertText` drops the half surrogate it
/// would otherwise keep, so this is what vanilla inserts; Rust strings cannot
/// hold the lone surrogate that `setValue`'s unguarded `substring` may leave,
/// so a value too long by half a character keeps the whole characters before
/// the cut.
pub fn utf16_prefix(s: &str, max: usize) -> String {
let mut units = 0;
for (i, c) in s.char_indices() {
let n = c.len_utf16();
if units + n > max {
return s[..i].to_owned();
}
units += n;
}
s.to_owned()
}
/// `EditBox.getWordPosition(dir, from)`: the character index `dir` words away
/// from `from`. Forward, past the rest of the word and its spaces; backward,
/// over any spaces to the start of the word.
@@ -1608,7 +1585,7 @@ impl EditBox {
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2)
}
pub fn set_value(&mut self, value: &str) {
self.value = utf16_prefix(value, self.max_length);
self.value = value.chars().take(self.max_length).collect();
self.move_cursor_to_end(false);
}
fn selected(&self) -> (usize, usize) {
@@ -1621,9 +1598,7 @@ impl EditBox {
let (a, b) = self.selected();
self.value.chars().skip(a).take(b - a).collect()
}
/// EditBox.insertText: replace the selection, respecting the maximum
/// length. The limit is in UTF-16 code units, as Java's `String.length()`
/// counts them.
/// EditBox.insertText: replace the selection, respecting the maximum length.
pub fn insert_text(&mut self, text: &str) {
// `EditBox.charTyped` only inserts through `canConsumeInput`, which a
// read-only box fails; the screens here hand typed text to this method
@@ -1633,17 +1608,10 @@ impl EditBox {
}
let (a, b) = self.selected();
let filtered = crate::clipboard::filter_text(text, self.multiline);
let selected_units: usize = self
.value
.chars()
.skip(a)
.take(b - a)
.map(char::len_utf16)
.sum();
let room = self
.max_length
.saturating_sub(utf16_len(&self.value) - selected_units);
let insert = utf16_prefix(&filtered, room);
.saturating_sub(char_len(&self.value) - (b - a));
let insert: String = filtered.chars().take(room).collect();
let (ba, bb) = (byte_index(&self.value, a), byte_index(&self.value, b));
self.value.replace_range(ba..bb, &insert);
let pos = a + char_len(&insert);
@@ -1921,10 +1889,8 @@ impl EditBox {
batch.text_styled(atlas, &t, draw_x, ty, color, true);
draw_x += atlas.font.width_text(&t) + 1.0;
}
// EditBox.extractWidgetRenderState: `value.length() >= maxLength`
// is Java's length, so the caret turns into a bar at the unit count.
let insert =
self.cursor < char_len(&self.value) || utf16_len(&self.value) >= self.max_length;
self.cursor < char_len(&self.value) || char_len(&self.value) >= self.max_length;
let mut cursor_x = draw_x;
if !on_screen {
cursor_x = if rel_cursor > 0 { tx + self.width } else { tx };
@@ -2670,31 +2636,6 @@ mod tests {
);
}
/// `EditBox.maxLength` counts Java `String.length()`: UTF-16 code units.
/// A pasted emoji takes two of them and is never split (vanilla's
/// `Character.isHighSurrogate` guard drops the half it would keep).
#[test]
fn an_edit_box_maximum_counts_utf16_units() {
assert_eq!(utf16_len("ab😀"), 4);
assert_eq!(utf16_prefix("ab😀", 3), "ab");
assert_eq!(utf16_prefix("ab😀", 4), "ab😀");
let mut edit = EditBox::new(0.0, 0.0, 200.0, 20.0);
edit.max_length = 3;
edit.insert_text("ab😀");
assert_eq!(edit.value, "ab", "three units: a, b and one of the emoji's");
edit.set_value("");
edit.max_length = 4;
edit.insert_text("ab😀");
assert_eq!(edit.value, "ab😀", "four units, both of the emoji's");
// The selection's own units give their room back: with "ab"
// selected there are two units left, enough for the emoji, not
// for the emoji and the x.
edit.set_cursor(0, false);
edit.set_cursor(2, true);
edit.insert_text("😀x");
assert_eq!(edit.value, "😀😀");
}
/// `EditBox.setInvertHighlightedTextColor`: a selection inverts its text
/// unless the box asks it not to (the anvil's rename box).
#[test]
@@ -16,8 +16,6 @@ pub enum Cursor {
NotAllowed,
/// `CursorTypes.RESIZE_NS`: a scroller held vertically (`MerchantScreen`).
ResizeNs,
/// `CursorTypes.RESIZE_EW`: a slider held horizontally.
ResizeEw,
}
thread_local! {
@@ -41,19 +39,6 @@ pub fn widget_cursor(hovered: bool, active: bool) {
});
}
}
/// `AbstractSliderButton.handleCursor`: a dragged slider resizes, a hovered
/// one points, and an inactive one is not allowed.
pub fn slider_cursor(hovered: bool, active: bool, dragging: bool) {
if hovered {
request_cursor(if !active {
Cursor::NotAllowed
} else if dragging {
Cursor::ResizeEw
} else {
Cursor::PointingHand
});
}
}
/// The cursor the frame asked for, and back to the arrow for the next one
/// (the window shows the arrow unless a widget asks).
@@ -429,18 +414,6 @@ pub fn record_as(label: &str, [x, y, w, h]: [f32; 4], enabled: bool, kind: Kind)
mod cursor_tests {
use super::*;
#[test]
fn a_dragged_slider_asks_for_the_horizontal_resize_cursor() {
let _ = take_cursor();
slider_cursor(false, true, true);
assert_eq!(take_cursor(), Cursor::Arrow);
slider_cursor(true, true, true);
assert_eq!(take_cursor(), Cursor::ResizeEw);
slider_cursor(true, true, false);
assert_eq!(take_cursor(), Cursor::PointingHand);
slider_cursor(true, false, true);
assert_eq!(take_cursor(), Cursor::NotAllowed);
}
#[test]
fn hovered_widgets_ask_for_the_hand_or_not_allowed() {
let _ = take_cursor();
+6 -4
View File
@@ -9855,10 +9855,12 @@ impl Game<'_> {
border,
game_time: self.world.game_time as f32,
shader_game_time: ((self.world.game_time % 24000) as f32 + alpha as f32) / 24000.0,
crumbling_sprite: frame_geometry::crumbling_sprite(
&self.gui.atlas.sprites,
self.settings.improved_transparency,
),
crumbling_sprite: self
.gui
.atlas
.sprites
.get("block/destroy_stage_0")
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0]),
end_portal_sprites: ["environment/end_sky", "entity/end_portal/end_portal"].map(|n| {
self.gui
.atlas
+1 -2
View File
@@ -2,7 +2,6 @@
//! paddling controls, collisions, riding (`positionRider`, `clampRotation`,
//! dismounting), `BoatItem` placement, `VehicleEntity.hurtServer` breaking,
//! saving and `AbstractBoatRenderer` with animated paddles.
mod below_world;
mod placement;
use super::{Game, InteractTarget, entity::Entity, entity_culling::at, view_vector};
@@ -978,7 +977,7 @@ impl Game<'_> {
};
self.boats[i].wear_off_hurt();
// Entity.baseTick burns it before it moves; the fire may break it.
if !self.boat_fire_tick(i) && !self.boat_below_world(i) {
if !self.boat_fire_tick(i) {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
@@ -1,110 +0,0 @@
//! `Entity.checkBelowWorld` for boats and minecarts: one more than 64 blocks
//! under its level's floor is discarded (`onBelowWorld`) before it moves.
//! `AbstractBoat.tick` reaches it through `Entity.baseTick`, after the fire;
//! `AbstractMinecart.tick` calls it itself, after its hurt and damage wear off.
//! A discard is a destroying removal: the riders get off where they are and a
//! chest boat or container minecart spills its slots
//! (`AbstractChestBoat.remove`, `AbstractMinecartContainer.remove`), but no
//! ENTITY_DIE and no item of the vehicle itself.
use super::super::{minecarts::cart_at, native_ticks::TickRound};
use super::Game;
/// `Entity.checkBelowWorld`: `getY() < level.getMinY() - 64`.
pub(super) fn below_world(y: f64, min_y: i32) -> bool {
y < f64::from(min_y - 64)
}
impl Game<'_> {
/// `Entity.checkBelowWorld` for the boat at `i`: true when it fell out of
/// the world and is gone.
pub(in crate::game) fn boat_below_world(&mut self, i: usize) -> bool {
if !below_world(self.boats[i].position.y, self.world.bottom_y()) {
return false;
}
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.drop_vehicle_contents(b.position.components(), &b.items);
self.boat_removed(&b);
true
}
/// The minecart `id` at or after `slot`, unless `AbstractMinecart.tick`'s
/// `checkBelowWorld` discards it: then its tick is over and the round's
/// post hook runs for it.
pub(in crate::game) fn cart_in_world(
&mut self,
id: u32,
slot: usize,
round: &TickRound,
kind: &str,
) -> Option<usize> {
let i = cart_at(&self.carts, id, slot)?;
if !below_world(self.carts[i].position.y, self.world.bottom_y()) {
return Some(i);
}
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.drop_vehicle_contents(c.position.components(), &c.items);
self.native_tick_post_one(round, id, kind);
None
}
}
#[cfg(test)]
mod tests {
use super::below_world;
/// The line is 64 under the floor and the vehicle must be below it:
/// -128 in the overworld (floor -64), -64 in the Nether and the End.
#[test]
fn a_vehicle_falls_out_of_the_world_64_blocks_under_the_floor() {
assert!(!below_world(-128.0, -64));
assert!(below_world(-128.001, -64));
assert!(!below_world(-100.0, -64));
assert!(!below_world(-64.0, 0));
assert!(below_world(-64.5, 0));
}
/// A tick discards the boat and the chest minecart under -128 in the
/// overworld, before they move, and spills the cart's slots; the two on
/// the line or above it stay, whatever their fall this tick.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_tick_discards_the_vehicles_under_the_world() {
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
let mut g = crate::game::native_machines::tests::server_game();
g.build_registries();
g.set_allow_commands(true);
g.world
.chunks
.insert((0, 0), std::sync::Arc::new(Chunk::empty(0, 0)));
g.block_ticks.add_empty_chunk(ChunkPos::new(0, 0)).unwrap();
assert!(g.run_command("forceload add 0 0"));
for summon in [
"summon oak_boat 0.5 -128 0.5",
"summon oak_boat 4.5 -128.5 4.5",
"summon minecart 6.5 -127 6.5",
"summon chest_minecart 2.5 -129 2.5 {Items:[{Slot:0b,id:\"minecraft:diamond\",count:3}]}",
] {
assert!(g.run_command(summon), "{summon}");
}
g.sync_tick_chunks();
g.tick_boats();
g.tick_carts();
let boats: Vec<f64> = g.boats.iter().map(|b| b.position.x).collect();
assert_eq!(boats, [0.5]);
let carts: Vec<f64> = g.carts.iter().map(|c| c.position.x).collect();
assert_eq!(carts, [6.5]);
let spilled: u32 = g
.dropped
.iter()
.filter(|i| &*i.stack.item == "minecraft:diamond")
.map(|i| i.stack.count)
.sum();
assert_eq!(spilled, 3);
}
}
+8 -44
View File
@@ -392,10 +392,14 @@ impl Game<'_> {
.get(self.world.block(c.x, c.y, c.z))
.name
.clone();
let trapdoor = (self.res.items().block_tags)
.get("minecraft:trapdoors")
.is_some_and(|tag| tag.contains(&*name));
Some(fall_location_of(&name, trapdoor))
Some(match &*name {
"minecraft:ladder" => "ladder",
"minecraft:vine" => "vines",
"minecraft:weeping_vines" | "minecraft:weeping_vines_plant" => "weeping_vines",
"minecraft:twisting_vines" | "minecraft:twisting_vines_plant" => "twisting_vines",
"minecraft:scaffolding" => "scaffolding",
_ => "other_climbable",
})
} else {
let f = self.res.facts().get(self.world.block(
p.position.x.floor() as i32,
@@ -733,46 +737,6 @@ fn damage_taken_award(amount: f32) -> Option<i32> {
(amount < 3.402_823_5e37).then(|| horizon_core::jmath::round_f32(amount * 10.0))
}
/// `FallLocation.blockToFallLocation`: what a climbable block stands for in a fall's death message.
/// `trapdoor` is whether the block is in `#minecraft:trapdoors`, which counts as a ladder.
fn fall_location_of(name: &str, trapdoor: bool) -> &'static str {
match name {
_ if trapdoor => "ladder",
"minecraft:ladder" => "ladder",
"minecraft:vine" => "vines",
"minecraft:weeping_vines" | "minecraft:weeping_vines_plant" => "weeping_vines",
"minecraft:twisting_vines" | "minecraft:twisting_vines_plant" => "twisting_vines",
"minecraft:scaffolding" => "scaffolding",
_ => "other_climbable",
}
}
#[cfg(test)]
mod fall_location_tests {
use super::fall_location_of;
/// The branches of `blockToFallLocation`: a ladder or a block of `#minecraft:trapdoors` is
/// `ladder`, then vine, the weeping and twisting vines (heads and plants), scaffolding, and any
/// other climbable (cave vines, here) is `other_climbable`.
#[test]
fn a_trapdoor_counts_as_a_ladder_and_the_other_climbables_keep_their_own_place() {
for (name, trapdoor, want) in [
("minecraft:ladder", false, "ladder"),
("minecraft:oak_trapdoor", true, "ladder"),
("minecraft:iron_trapdoor", true, "ladder"),
("minecraft:vine", false, "vines"),
("minecraft:weeping_vines", false, "weeping_vines"),
("minecraft:weeping_vines_plant", false, "weeping_vines"),
("minecraft:twisting_vines", false, "twisting_vines"),
("minecraft:twisting_vines_plant", false, "twisting_vines"),
("minecraft:scaffolding", false, "scaffolding"),
("minecraft:cave_vines", false, "other_climbable"),
] {
assert_eq!(fall_location_of(name, trapdoor), want, "{name}");
}
}
}
/// Hits one mob's tick deals another, handled after the mob loop.
impl Game<'_> {
/// A melee hit on another mob, then `postAttack`; whether it landed.
@@ -46,13 +46,13 @@ pub(super) struct CommandBlockScreen {
}
/// What `ServerboundSetCommandBlockPacket` carries.
pub(super) struct CommandBlockEdit {
pub(super) pos: Pos,
pub(super) command: String,
pub(super) track_output: bool,
pub(super) mode: Mode,
pub(super) conditional: bool,
pub(super) auto: bool,
struct CommandBlockEdit {
pos: Pos,
command: String,
track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
}
#[derive(Clone, Copy, PartialEq)]
@@ -948,6 +948,8 @@ impl Game<'_> {
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
game.mark_block_entity_updated(edit.pos);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
@@ -982,7 +984,7 @@ impl Game<'_> {
);
}
/// What `handleSetCommandBlock` does once the player may edit the block.
pub(super) fn apply_command_block(&mut self, s: &CommandBlockEdit) {
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
let registry = self.pack.assets.registry;
let Some(current) = self.block_state_at(pos) else {
@@ -1040,9 +1042,6 @@ impl Game<'_> {
};
self.system_message(Text::translate(key, &[Text::literal(command)]));
}
// `BaseCommandBlock.onUpdated`: the edited block entity is saved and
// its clients (and comparators) see the new command.
self.mark_block_entity_updated(pos);
}
}
+16 -32
View File
@@ -780,13 +780,6 @@ impl Game<'_> {
if self.pot_data(pos).is_some() {
return self.set_pot_data(pos, &tag).unwrap_or(false);
}
if let Some(changed) = self.load_comparator_output(pos, &tag) {
let changed = changed.unwrap_or(false);
if changed {
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
return changed;
}
let key = (pos[0], pos[1], pos[2]);
let was_auto = self.command_block_auto(key);
let Some(e) = self.world.block_entities.get(&key) else {
@@ -894,29 +887,6 @@ impl Game<'_> {
true
}
/// `ComparatorBlockEntity.loadAdditional` (`getIntOr("OutputSignal", 0)`)
/// for a comparator at `p`: None for any other block; else whether the
/// loaded output differs from the one it had (None inside when the tag
/// does not decode). The block's own metadata comes from `comparator_data`.
fn load_comparator_output(&mut self, p: [i32; 3], tag: &Tag) -> Option<Option<bool>> {
let mut input = self.comparator_data(p)?;
let before = input.get("OutputSignal").cloned();
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let Some(normalized) = horizon_client::block_entity_nbt::canonical(&input) else {
return Some(None);
};
let output = normalized.get("OutputSignal").cloned();
if let Some(Tag::Int(value)) = output {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.block_ticks.comparator_outputs.insert(pos, value);
}
Some(Some(output != before))
}
/// `ComparatorBlockEntity.saveAdditional`: expose the circuit's live output,
/// rather than keeping a second copy in the ordinary block entity map.
fn comparator_data(&self, p: [i32; 3]) -> Option<Tag> {
@@ -1080,8 +1050,22 @@ impl Game<'_> {
return Ok(());
}
let key = (p[0], p[1], p[2]);
if let Some(changed) = self.load_comparator_output(*p, &tag) {
changed.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(mut input) = self.comparator_data(*p) {
// ComparatorBlockEntity.loadAdditional reads getIntOr(0).
// Reuse the existing codec, with the block's own metadata.
if let Some(value) = tag.get("OutputSignal") {
set_key(&mut input, "OutputSignal", value.clone());
} else if let Tag::Compound(map) = &mut input {
map.remove(&"OutputSignal".into());
}
let normalized = horizon_client::block_entity_nbt::canonical(&input)
.ok_or_else(|| err("commands.data.block.invalid", &[]))?;
if let Some(Tag::Int(output)) = normalized.get("OutputSignal") {
self.block_ticks.comparator_outputs.insert(
horizon_core::position::BlockPos::new(p[0], p[1], p[2]),
*output,
);
}
self.mark_block_entity_updated(key);
return Ok(());
}
+13 -125
View File
@@ -1592,147 +1592,35 @@ fn merge_compound(into: &mut Tag, from: &Tag) {
}
}
/// `ListOperation.apply` (`mode`: replace_all, replace_section, insert,
/// append) with a 256-entry limit (`SetLoreFunction`'s). An append, insert or section
/// replacement that cannot be done, an offset past the end or a result over the limit, gives
/// the old list back unchanged, as vanilla does (it logs an error). `replace_all` is the new
/// list as it is in vanilla, whose `ItemLore` then refuses more than 256 lines; here it is cut.
/// append) with a 256-entry limit.
fn list_operation(m: &serde_json::Value, old: Vec<Tag>, new: Vec<Tag>) -> Vec<Tag> {
const LIMIT: usize = 256;
let offset = m["offset"].as_u64().unwrap_or(0) as usize;
match m["mode"].as_str().unwrap_or("") {
"replace_all" => {
let mut out = new;
out.truncate(LIMIT);
out
}
let mut out = match m["mode"].as_str().unwrap_or("") {
"replace_all" => new,
"insert" => {
if offset > old.len() || old.len() + new.len() > LIMIT {
return old;
}
let mut out = old[..offset].to_vec();
let at = offset.min(old.len());
let mut out = old[..at].to_vec();
out.extend(new);
out.extend_from_slice(&old[offset..]);
out.extend_from_slice(&old[at..]);
out
}
"replace_section" => {
if offset > old.len() {
return old;
}
let at = offset.min(old.len());
let size = m["size"].as_u64().map_or(new.len(), |s| s as usize);
let mut out = old[..offset].to_vec();
let mut out = old[..at].to_vec();
out.extend(new);
let end = offset.saturating_add(size);
if end < old.len() {
out.extend_from_slice(&old[end..]);
}
if out.len() > LIMIT {
return old;
}
let end = (at + size).min(old.len());
out.extend_from_slice(&old[end..]);
out
}
_ => {
if old.len() + new.len() > LIMIT {
return old;
}
let mut out = old;
out.extend(new);
out
}
}
}
/// `ListOperation.Append/Insert/ReplaceSection.apply` with `/item modify`'s lore limit of 256
/// (`SetLoreFunction` passes 256): an operation that cannot be done logs an error in vanilla and
/// gives the old list back unchanged. The expected lists are worked out from the vanilla bytecode
/// (see the pull request), not from the code under test.
#[cfg(test)]
mod list_operation_tests {
use super::*;
use serde_json::json;
fn ints(range: std::ops::Range<i32>) -> Vec<Tag> {
range.map(Tag::Int).collect()
}
#[test]
fn an_append_that_would_pass_256_entries_changes_nothing() {
let old = ints(0..250);
let m = json!({"mode": "append"});
// 250 + 7 = 257 is over the limit: the old list, whole.
assert_eq!(list_operation(&m, old.clone(), ints(1000..1007)), old);
// 250 + 6 = 256 is exactly the limit: all of it.
let mut full = old.clone();
full.extend(ints(1000..1006));
assert_eq!(list_operation(&m, old, ints(1000..1006)), full);
}
#[test]
fn an_insert_past_the_end_or_over_the_limit_changes_nothing() {
let old = ints(0..3);
let at = |offset| json!({"mode": "insert", "offset": offset});
// Offset 4 is past the end of three entries.
assert_eq!(list_operation(&at(4), old.clone(), ints(9..10)), old);
// Offset 3 is the end: an insert there is an append.
assert_eq!(
list_operation(&at(3), old.clone(), ints(9..11)),
[0, 1, 2, 9, 10].map(Tag::Int)
);
// Offset 1 in the middle.
assert_eq!(
list_operation(&at(1), old, ints(9..11)),
[0, 9, 10, 1, 2].map(Tag::Int)
);
// 255 + 2 = 257 is over the limit, at any offset.
let big = ints(0..255);
assert_eq!(list_operation(&at(0), big.clone(), ints(9..11)), big);
}
#[test]
fn a_section_replaced_past_the_end_or_over_the_limit_changes_nothing() {
let old = ints(0..4);
let section = |offset, size: Option<u64>| {
let mut m = json!({"mode": "replace_section", "offset": offset});
if let Some(size) = size {
m["size"] = json!(size);
}
m
};
// Offset 5 is past the end of four entries.
assert_eq!(
list_operation(&section(5, None), old.clone(), ints(9..10)),
old
);
// Offset 4 is the end: nothing to replace, the new entries go after.
assert_eq!(
list_operation(&section(4, None), old.clone(), ints(9..10)),
[0, 1, 2, 3, 9].map(Tag::Int)
);
// Entries 1 and 2 (size 2) become one entry.
assert_eq!(
list_operation(&section(1, Some(2)), old.clone(), ints(9..10)),
[0, 9, 3].map(Tag::Int)
);
// No size: as many entries as the new ones.
assert_eq!(
list_operation(&section(0, None), ints(0..3), ints(7..9)),
[7, 8, 2].map(Tag::Int)
);
// 256 entries, one replaced by two: 257 is over the limit.
let full = ints(0..256);
assert_eq!(
list_operation(&section(0, Some(1)), full.clone(), ints(9..11)),
full
);
}
#[test]
fn replacing_everything_gives_the_new_list() {
let m = json!({"mode": "replace_all"});
assert_eq!(
list_operation(&m, ints(0..3), ints(7..9)),
[7, 8].map(Tag::Int)
);
}
};
out.truncate(256);
out
}
#[cfg(test)]
+47 -22
View File
@@ -1129,7 +1129,17 @@ impl Game<'_> {
self.refresh_gear_knockback_resistance();
self.tick_leads();
// Vanilla's spawning and raids run before ChunkMap.tick.
self.tick_spawners();
let from = self.lan_synced_from();
horizon_client::spans::time("spawn_natural", || self.spawn_natural());
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
}
self.tick_raids();
self.lan_synced_since(from);
let ticked =
horizon_client::spans::time("tick_creature_entities", || self.tick_creature_entities());
self.dismount_underwater_mob_riders();
@@ -2270,11 +2280,6 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// The UUID its own random made, unless it already has one (a client
// copy's from the spawn packet).
if let Some(uuid) = p.uuid.take() {
self.persist.uuids.entry(p.id).or_insert(uuid);
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
@@ -2642,9 +2647,20 @@ impl Game<'_> {
x,
z,
source,
apply_blocking,
} => match target {
Who::Player => self.mob_pushes_player(power, x, z, source, apply_blocking),
// A player's source names no melee attacker, as its damage's doesn't.
Who::Player => {
let _attacker = self.knockback_scope(match source.attacker {
Some(Who::Mob(id)) => id as i32,
_ => 0,
});
let source = HurtSource {
damage_type: source_damage_type(source),
causing: 0,
direct: 0,
};
self.knock_back_player(power, x, z, source);
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.knockback(power, x, z, &source);
@@ -6550,7 +6566,7 @@ impl Game<'_> {
let mut equip_event: Option<bool> = None;
let mut consume = false;
let mut give: Option<&str> = None;
let mut sound: Option<(&str, f32, f32)> = None;
let mut sound: Option<&str> = None;
// Sounds played as vanilla plays them: (event, volume, pitch) at the feet.
let mut eat: Option<(&'static str, f32, f32)> = None;
let feet = [m.position.x, m.position.y, m.position.z];
@@ -6583,18 +6599,17 @@ impl Game<'_> {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
sound = Some(("entity.cow.milk", 1.0, 1.0));
sound = Some("entity.cow.milk");
}
// Goat.mobInteract: a bucket on an adult gives milk.
Species::Goat if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
consume = true;
let milk = if m.extra.screaming {
sound = Some(if m.extra.screaming {
"entity.goat.screaming.milk"
} else {
"entity.goat.milk"
};
sound = Some((milk, 1.0, 1.0));
});
}
// MushroomCow.mobInteract: a bowl gives back what a flower left as
// a suspicious stew, or plain stew when it holds no effects.
@@ -6609,7 +6624,7 @@ impl Game<'_> {
}
None => {
give = Some("minecraft:mushroom_stew");
sound = Some(("entity.mooshroom.milk", 1.0, 1.0));
sound = Some("entity.mooshroom.milk");
}
}
}
@@ -6621,7 +6636,10 @@ impl Game<'_> {
"minecraft:red_mushroom"
};
drops.push((mushroom.into(), 5));
shear_puff = Some((feet, f64::from(m.height())));
shear_puff = Some((
[m.position.x, m.position.y, m.position.z],
f64::from(m.height()),
));
self.audio.play_bound(
"minecraft:entity.mooshroom.shear",
7,
@@ -6643,8 +6661,6 @@ impl Game<'_> {
at: pos,
setup,
}])?;
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", feet, ctx);
self.damage_held_tool();
}
// MushroomCow.mobInteract: a brown adult takes the flower's effects
@@ -6680,8 +6696,6 @@ impl Game<'_> {
);
drops.extend(stacks.into_iter().map(|s| (s.item.to_string(), s.count)));
wool = true;
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
self.post_game_event("minecraft:shear", feet, ctx);
self.damage_held_tool();
}
// Entity.interact: shears take a tamed wolf's armor off for its owner.
@@ -7129,9 +7143,11 @@ impl Game<'_> {
}])?;
}
self.swing_arm();
if let Some((s, volume, pitch)) = sound {
if let Some(s) = sound {
let pitch =
(self.world.random.next_float() - self.world.random.next_float()) * 0.2 + 1.0;
self.audio
.play(&format!("minecraft:{s}"), Some(center), volume, pitch);
.play(&format!("minecraft:{s}"), Some(center), 1.0, pitch);
}
if let Some((s, volume, pitch)) = eat {
let event = format!("minecraft:{s}");
@@ -7373,7 +7389,16 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(eye[0], eye[1] - 0.1, eye[2]);
let mut proj = Projectile::new(kind.clone(), start, Some(Who::Player));
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
self.add_projectile(proj);
// SnowballItem.use / EggItem.use / EnderpearlItem.use: awardStat(ITEM_USED).
self.award_stat("used", item, 1);
+1 -1
View File
@@ -371,7 +371,7 @@ impl Game<'_> {
}
// Flame: the arrow is spawned burning (EnchantmentHelper.onProjectileSpawned).
self.ignite_spawned_arrow(&weapon, &mut arrow);
arrow.shoot_own(dir.to_array(), power, 1.0);
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
@@ -44,6 +44,8 @@ mod transfer;
/// What the tick loop tells `ServerWatchdog`: when the tick in progress was
/// due (`getNextTickTime`), how long a tick is, and whether the server runs.
struct Watchdog {
/// The thread running the ticks, whose stack the report carries.
server: horizon_threads::stack_dump::Watched,
base: Instant,
next_nanos: AtomicU64,
tick_nanos: AtomicU64,
@@ -53,9 +55,11 @@ struct Watchdog {
impl Watchdog {
/// `DedicatedServer.initServer`: a thread that stops a server whose tick
/// runs `max_tick_time` ms past its due time; none when that is 0 or less.
/// Started from the thread that runs the ticks.
fn start(max_tick_time: i64) -> Option<Arc<Self>> {
let max = Duration::from_millis(u64::try_from(max_tick_time).ok().filter(|m| *m > 0)?);
let watchdog = Arc::new(Self {
server: horizon_threads::stack_dump::Watched::current(),
base: Instant::now(),
next_nanos: AtomicU64::new(0),
tick_nanos: AtomicU64::new(50_000_000),
@@ -84,6 +88,8 @@ impl Watchdog {
let now = u64::try_from(self.base.elapsed().as_nanos()).unwrap_or(u64::MAX);
let behind = now.saturating_sub(next);
if behind > max {
// ServerWatchdog: the server thread's stack first, while it is still stuck.
let stack = self.server.stack(Duration::from_secs(5));
let took = format!("{:.2}", behind as f32 / 1e9);
let should = format!(
"{:.2}",
@@ -106,6 +112,7 @@ impl Watchdog {
std::path::Path::new("."),
"server",
message,
stack,
) {
Some(path) => log_from(
"Server Watchdog",
+3 -2
View File
@@ -1193,12 +1193,13 @@ impl Game<'_> {
return;
}
// Projectile.shoot spreads the shot with the projectile's own random.
let mut own = random_source::entity();
match item.as_str() {
"minecraft:experience_bottle"
| "minecraft:splash_potion"
| "minecraft:lingering_potion" => {
let mut shot = Projectile::new(ProjectileKind::Snowball, at, None);
shot.shoot(&mut random_source::entity(), dir, power, uncertainty);
shot.shoot(&mut own, dir, power, uncertainty);
self.launch_thrown(one, at, shot.velocity, None);
}
_ => {
@@ -1245,7 +1246,7 @@ impl Game<'_> {
a.set_item(&one);
a.pickup = horizon_client::arrows::Pickup::Allowed;
}
shot.shoot_own(dir, power, uncertainty);
shot.shoot(&mut own, dir, power, uncertainty);
self.add_projectile(shot);
}
}
@@ -124,15 +124,6 @@ impl Game<'_> {
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
// ItemStack.isDamageableItem: an unbreakable stack, or one without
// damage/max_damage, takes no wear before Unbreaking or mods are asked.
let component = |id| stack.data().and_then(|data| data.other(id));
if component("minecraft:unbreakable").is_some()
|| component("!minecraft:max_damage").is_some()
|| component("!minecraft:damage").is_some()
{
return 0;
}
let mut loss = self.durability_loss(stack, amount);
// The mods hear a server player's wear once Unbreaking has had its say,
// and may change it or stop it.
@@ -1,140 +0,0 @@
//! Milking a farm animal and shearing a sheep or mooshroom by hand: the milk
//! pitch is fixed at 1.0 (`AbstractCow.mobInteract`, `Goat.mobInteract`,
//! `MushroomCow.mobInteract`) and shearing posts `GameEvent.SHEAR` with the
//! player (`Sheep.mobInteract`, `MushroomCow.mobInteract`).
use super::super::*;
use horizon_client::chunk::Chunk;
use horizon_client::mobs::{Mob, Who};
use horizon_client::motion::PlayerMotion;
use horizon_core::{geometry::Vec3, position::ChunkPos};
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
game.screen = Screen::Playing;
game.selected = 0;
game.set_mode_id(0);
game
}
fn spawn(game: &mut Game<'_>, kind: &str, id: u32, at: [f64; 3]) {
game.mobs.clear();
let kind = game.res.mob_types().get(kind).unwrap().clone();
game.mobs
.push(Mob::new(id, kind, Vec3::new(at[0], at[1], at[2]), 0.0));
}
fn target(id: u32) -> Option<InteractTarget> {
Some(InteractTarget {
id: id as i32,
location: [0.0, 0.0, 0.0],
})
}
fn take_shear_events() -> Vec<(&'static str, [f64; 3], Option<Who>)> {
super::TEST_POSTED_EVENTS.with(|events| {
std::mem::take(events.borrow_mut().as_mut().unwrap())
.into_iter()
.filter(|event| event.0 == "minecraft:shear")
.collect()
})
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn milk_sounds_carry_pitch_one_and_draw_no_level_random() {
let mut game = game();
for (kind, item, sound) in [
(
"minecraft:cow",
"minecraft:bucket",
"minecraft:entity.cow.milk",
),
(
"minecraft:goat",
"minecraft:bucket",
"minecraft:entity.goat.milk",
),
(
"minecraft:mooshroom",
"minecraft:bowl",
"minecraft:entity.mooshroom.milk",
),
] {
spawn(&mut game, kind, 7, [2.5, 64.0, 2.5]);
game.inventory[0] = Some(ItemStack::new(item, 1));
game.audio.capture = Some(Vec::new());
let before = game.world.random.state();
assert!(game.interact_mob(target(7)).unwrap(), "{kind}");
let sounds = game.audio.capture.take().unwrap();
let heard: Vec<_> = sounds.iter().filter(|s| s.event == sound).collect();
assert_eq!(heard.len(), 1, "{kind}: {sounds:?}");
assert_eq!(heard[0].pitch, 1.0, "{kind} milk pitch");
assert_eq!(
game.world.random.state(),
before,
"{kind} milk drew the level random"
);
}
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn shearing_by_hand_posts_the_shear_game_event() {
let mut game = game();
game.inventory[0] = Some(ItemStack::new("minecraft:shears", 1));
super::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = Some(Vec::new()));
for (kind, id, at) in [
("minecraft:sheep", 11, [1.5, 64.0, 2.5]),
("minecraft:mooshroom", 12, [2.5, 64.0, 2.5]),
("minecraft:snow_golem", 13, [3.5, 64.0, 2.5]),
] {
spawn(&mut game, kind, id, at);
assert!(game.interact_mob(target(id)).unwrap(), "{kind}");
assert_eq!(
take_shear_events(),
vec![("minecraft:shear", at, Some(Who::Player))],
"{kind}"
);
}
super::TEST_POSTED_EVENTS.with(|events| *events.borrow_mut() = None);
}
@@ -8,7 +8,39 @@ impl Game<'_> {
/// other players' (`renderBlockDestroyAnimation`: within 32 blocks of the camera, the
/// furthest progress on each block).
pub(super) fn cracks(&self, camera: glam::DVec3) -> Vec<([i32; 3], [f32; 2])> {
let names = crack_stage_names(self.settings.improved_transparency);
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
let names = if self.settings.improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
};
let mut stages: Vec<([i32; 3], u32)> = Vec::new();
let mut add = |pos: [i32; 3], stage: u32| match stages.iter_mut().find(|c| c.0 == pos) {
Some(c) => c.1 = c.1.max(stage),
@@ -535,86 +567,3 @@ impl Game<'_> {
rain
}
}
// `destroy_stage_0` .. `destroy_stage_9`: the sprite name by stage, so the
// lookup does not format a string per crack.
const DESTROY_STAGES: [&str; 10] = [
"block/destroy_stage_0",
"block/destroy_stage_1",
"block/destroy_stage_2",
"block/destroy_stage_3",
"block/destroy_stage_4",
"block/destroy_stage_5",
"block/destroy_stage_6",
"block/destroy_stage_7",
"block/destroy_stage_8",
"block/destroy_stage_9",
];
// `ModelBakery.DESTROY_STAGES_OIT`: with improved transparency the cracks are
// ordinary translucent quads (`OIT_CRUMBLING`) and use their own sprites.
const DESTROY_STAGES_OIT: [&str; 10] = [
"block/destroy_oit_stage_0",
"block/destroy_oit_stage_1",
"block/destroy_oit_stage_2",
"block/destroy_oit_stage_3",
"block/destroy_oit_stage_4",
"block/destroy_oit_stage_5",
"block/destroy_oit_stage_6",
"block/destroy_oit_stage_7",
"block/destroy_oit_stage_8",
"block/destroy_oit_stage_9",
];
/// The crack sprites drawn: with improved transparency vanilla's own OIT set.
fn crack_stage_names(improved_transparency: bool) -> &'static [&'static str; 10] {
if improved_transparency {
&DESTROY_STAGES_OIT
} else {
&DESTROY_STAGES
}
}
/// The crack's sprite size (and stage 0's origin) the crumbling shaders read: of the set drawn,
/// as a resource pack may replace one set and not the other (a 32x pack's `destroy_stage_*`
/// beside vanilla's 16x `destroy_oit_stage_*`, #11164).
pub(super) fn crumbling_sprite(
sprites: &std::collections::HashMap<String, horizon_client::hud::Rect>,
improved_transparency: bool,
) -> [f32; 4] {
sprites
.get(crack_stage_names(improved_transparency)[0])
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
}
#[cfg(test)]
mod crumbling_sprite_tests {
use super::*;
#[test]
fn the_crack_takes_the_size_of_the_set_it_draws() {
let rect = |u0: f32, size: f32| horizon_client::hud::Rect {
u0,
v0: 0.0,
u1: u0 + size,
v1: size,
w: 16,
h: 16,
};
// A pack's 32x destroy stages beside vanilla's 16x OIT stages, in a 1024-wide atlas.
let sprites = std::collections::HashMap::from([
("block/destroy_stage_0".to_owned(), rect(0.0, 32.0 / 1024.0)),
(
"block/destroy_oit_stage_0".to_owned(),
rect(0.5, 16.0 / 1024.0),
),
]);
assert_eq!(
crumbling_sprite(&sprites, false),
[0.0, 0.0, 32.0 / 1024.0, 32.0 / 1024.0]
);
assert_eq!(
crumbling_sprite(&sprites, true),
[0.5, 0.0, 16.0 / 1024.0, 16.0 / 1024.0]
);
}
}
+2 -20
View File
@@ -54,12 +54,6 @@ impl Game<'_> {
}
}
fn set_has_record(&mut self, pos: [i32; 3], has: bool) {
self.set_has_record_notify(pos, has, true);
}
/// The state change alone (`level.setBlock` with flag 2); `notify` adds
/// `notifyItemChangedInJukebox`'s `block_change`, which
/// `JukeboxBlock.setPlacedBy` does not post.
fn set_has_record_notify(&mut self, pos: [i32; 3], has: bool, notify: bool) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let registry = self.pack.assets.registry;
let Some(mut s) = registry.state(state).cloned() else {
@@ -76,7 +70,7 @@ impl Game<'_> {
});
// `JukeboxBlockEntity.notifyItemChangedInJukebox`: the disc coming
// or going is a block change the sculk sensors hear.
if notify && self.world.block(pos[0], pos[1], pos[2]) != state {
if self.world.block(pos[0], pos[1], pos[2]) != state {
self.post_block_game_event("minecraft:block_change", pos, None, Some(new));
}
}
@@ -88,8 +82,7 @@ impl Game<'_> {
/// `updateCustomBlockEntityTag` has filled the block entity.
pub(super) fn jukebox_placed(&mut self, pos: [i32; 3], stack: &ItemStack) {
if block_entity_item::carries_record(stack) {
// #15320: no `block_change` here, only the `block_place` after it.
self.set_has_record_notify(pos, true, false);
self.set_has_record(pos, true);
}
}
/// `onSongChanged`: neighbours (the signal) and comparators.
@@ -838,16 +831,5 @@ mod block_change_tests {
events.borrow()
);
});
// #15320: `JukeboxBlock.setPlacedBy` sets the state alone; the
// placement's `block_place` is the only event.
game.world.set(2, 4, 5, jukebox).unwrap();
super::super::sculk::TEST_POSTED_EVENTS
.with(|events| *events.borrow_mut() = Some(Vec::new()));
game.set_has_record_notify([2, 4, 5], true, false);
let with_record = game.world.block(2, 4, 5);
assert_ne!(with_record, jukebox, "the state still turns has_record on");
super::super::sculk::TEST_POSTED_EVENTS.with(|events| {
assert_eq!(events.borrow().as_ref().unwrap(), &[], "nothing posted");
});
}
}
+2 -2
View File
@@ -191,8 +191,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
let ignite = self.weapon_ignite_seconds();
Some(Strike {
-4
View File
@@ -1101,8 +1101,6 @@ mod tests {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let home = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap()).join("h260");
let world = home.join("saves/h260");
// A new world each run: a border saved by the last one would already be 50.
let _ = std::fs::remove_dir_all(&home);
{
let mut game = open_game(&root, &home, &world);
// A server ticks before it saves.
@@ -1140,8 +1138,6 @@ mod tests {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let home = std::path::PathBuf::from(std::env::var_os("HORIZON_HOME").unwrap())
.join("h260-controls");
// A new world each run, as the first open expects.
let _ = std::fs::remove_dir_all(&home);
let world = home.join("saves/h260");
let sizes = |game: &mut Game<'_>| {
assert!(game.run_command("execute in minecraft:overworld run worldborder get"));
+10 -130
View File
@@ -30,7 +30,8 @@ use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::{CartIndex, cart_at};
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -1677,10 +1678,9 @@ impl Game<'_> {
near.clear();
near.extend(0..self.carts.len() as u32);
}
let allowed = self.cart_push_allowed(&self.carts, i, &near);
for (k, &j) in near.iter().enumerate() {
for &j in &near {
let j = j as usize;
if !allowed[k] || j == i || !hitbox.intersects(self.carts[j].bounds()) {
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
continue;
}
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
@@ -1740,8 +1740,9 @@ impl Game<'_> {
self.pickup_from_cart(i, index);
}
/// `OldMinecartBehavior.pushAndPickupEntities` for living entities in cart
/// `i`'s box (0.2 wider on each side): an eligible mob boards a moving
/// rideable cart (`startRiding`); others get `Entity.push`.
/// `i`'s box (0.2 wider on each side): only a rideable cart moving at
/// `horizontalDistanceSqr() >= 0.01` picks up; an eligible mob boards an
/// empty cart (`startRiding`); others get `Entity.push`.
fn pickup_from_cart(&mut self, i: usize, index: &mut CartIndex) {
let v = self.carts[i].velocity;
if self.carts[i].kind != CartKind::Rideable || v.x * v.x + v.z * v.z < 0.01 {
@@ -1768,10 +1769,10 @@ impl Game<'_> {
let candidates: Vec<u32> = if near.is_empty() {
Vec::new()
} else {
let (teams, own) = (self.mob_teams(), self.team_with_owner(id, || None));
let teams = self.mob_teams();
near.iter()
.map(|&j| &self.mobs[j as usize])
.filter(|m| m.pushable() && pushable_by(own, teams.of(Who::Mob(m.id)), true))
.filter(|m| m.pushable() && pushable_by(None, teams.of(Who::Mob(m.id)), true))
.map(|m| m.id)
.collect()
};
@@ -1894,7 +1895,7 @@ impl Game<'_> {
if !self.native_tick_pre_one(&mut round, id, kind) {
continue;
}
let Some(i) = self.cart_in_world(id, i, &round, kind) else {
let Some(i) = cart_at(&self.carts, id, i) else {
continue;
};
// The ticked copy takes the loot roll instead of copying it: only
@@ -3763,127 +3764,6 @@ mod cart_push_tests {
assert_eq!(cart(1).loot.as_ref(), Some(&loot));
assert_eq!(cart(2).loot, None);
}
/// `OldMinecartBehavior.pushAndPickupEntities` for carts: a moving rideable
/// cart only pushes what `EntitySelector.pushableBy(cart)` allows, by its
/// own team's collision rule and the target's; a still cart keeps vanilla's
/// unfiltered minecart push.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_moving_carts_team_rules_which_carts_it_pushes() {
use crate::game::{Game, commands::Target};
use horizon_client::scoreboard::CollisionRule;
/// Puts a cart on a team of its own, as `/team join` would.
fn on_a_team(game: &mut Game<'_>, id: u32, rule: CollisionRule) {
let name = game.holder_name(Target::Cart(id)).expect("a cart's UUID");
let team = format!("cart-{id}");
game.score.board.add_team(&team).collision_rule = rule;
game.score.board.add_to_team(&name, &team);
}
/// Pushes with cart `pusher` moving at `velocity`, and returns the
/// target's velocity afterwards.
fn push_apart(game: &mut Game<'_>, pusher: usize, target: usize, velocity: Vec3) -> Vec3 {
game.carts[target].velocity = Vec3::ZERO;
game.carts[pusher].velocity = velocity;
let mut index = super::CartIndex::default();
game.push_from_cart(pusher, &mut index);
game.carts[target].velocity
}
let mut game = server_game();
// Cart 2 moves east, into cart 1's box; yaw 0 faces east, along the push.
game.carts.push(Minecart::new(
1,
CartKind::Rideable,
Vec3::new(8.5, 64.0, 8.5),
));
game.carts.push(Minecart::new(
2,
CartKind::Rideable,
Vec3::new(8.1, 64.0, 8.5),
));
let east = Vec3::new(0.2, 0.0, 0.0);
// No teams: the moving cart pushes the still one.
assert_ne!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the moving cart pushes the still one"
);
// The target's `never` rule holds the push back.
on_a_team(&mut game, 1, CollisionRule::Never);
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the target's collisionRule never keeps it off"
);
// The moving cart's own rule does too.
game.score.board.remove_team("cart-1");
on_a_team(&mut game, 2, CollisionRule::Never);
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"the moving cart's collisionRule never keeps it off"
);
// Allies with `pushOwnTeam` do not push each other.
game.score.board.remove_team("cart-2");
on_a_team(&mut game, 1, CollisionRule::PushOwnTeam);
let name = game.holder_name(Target::Cart(2)).expect("a cart's UUID");
game.score.board.add_to_team(&name, "cart-1");
assert_eq!(
push_apart(&mut game, 1, 0, east),
Vec3::ZERO,
"pushOwnTeam keeps allies apart"
);
// A still cart is vanilla's else branch: no team filter at all.
assert_ne!(
push_apart(&mut game, 1, 0, Vec3::ZERO),
Vec3::ZERO,
"a still cart pushes the minecart in its box whatever the teams"
);
}
/// `EntitySelector.pushableBy(cart)` for mobs: a moving rideable cart on a
/// `never` team neither boards nor pushes the cow in its path.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_moving_carts_team_keeps_it_from_boarding_a_cow() {
use crate::game::commands::Target;
use horizon_client::{mobs::Mob, scoreboard::CollisionRule};
let mut game = server_game();
let kind = game.res.mob_types().get("minecraft:cow").cloned().unwrap();
let mut cart = Minecart::new(2, CartKind::Rideable, Vec3::new(8.5, 64.0, 8.5));
cart.velocity = Vec3::new(0.2, 0.0, 0.0);
game.carts.push(cart);
let at = Vec3::new(8.5, 64.0, 8.5);
game.mobs.push(Mob::new(7, kind, at, 0.0));
let mut index = super::CartIndex::default();
game.push_from_cart(0, &mut index);
assert_eq!(
game.mobs[0].extra.mount,
Some(2),
"the cow boards the empty moving cart"
);
game.mobs[0].extra.mount = None;
game.mobs[0].velocity = Vec3::ZERO;
// The cart's own `never` rule: no boarding and no push.
let name = game.holder_name(Target::Cart(2)).expect("a cart's UUID");
game.score.board.add_team("cart-2").collision_rule = CollisionRule::Never;
game.score.board.add_to_team(&name, "cart-2");
game.carts[0].velocity = Vec3::new(0.2, 0.0, 0.0);
let mut index = super::CartIndex::default();
game.push_from_cart(0, &mut index);
assert_eq!(
game.mobs[0].extra.mount, None,
"the cart's collisionRule never keeps the cow off"
);
assert_eq!(
game.mobs[0].velocity,
Vec3::ZERO,
"and the cow is not pushed either"
);
}
}
#[cfg(test)]
@@ -97,7 +97,7 @@ impl CartIndex {
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(in crate::game) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
@@ -395,10 +395,9 @@ mod tests {
field.max_length = usize::MAX;
field.multiline = true;
field.apply_edit(&mut edit);
// maxLength is 4 UTF-16 code units: a, b and the emoji's two.
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}b", 3, 1)
("a\u{1f600}bc", 4, 1)
);
assert_eq!((edit.max_length, edit.multiline), (4, false));
edit.focused = false;
@@ -524,11 +523,9 @@ mod tests {
field.cursor = 1;
field.highlight = usize::MAX;
field.apply_text(&mut edit, &measure);
// maxLength 3 is three UTF-16 code units, so the emoji fills the
// last two and the b past them is cut.
assert_eq!(
(edit.value.as_str(), edit.cursor, edit.highlight),
("a\u{1f600}", 1, 2)
("a\u{1f600}b", 1, 3)
);
}
}
@@ -800,26 +800,6 @@ impl<'a> Game<'a> {
levels.loaded_ticking(cx, cz) && super::ticking_chunk(&self.world, cx, cz)
})
}
/// The tick's spawning phases in `ServerLevel.tick`'s order: the raids
/// tick first (`this.raids.tick`), then the chunk source's
/// (`getChunkSource().tick`): natural spawning, then the custom spawners
/// of `ServerChunkCache.tickChunks`. A wave that lands this tick therefore
/// joins before that tick's spawns, with the lower entity ids.
pub(super) fn tick_spawners(&mut self) {
let from = self.lan_synced_from();
horizon_client::spans::time("spawn_raids", || self.tick_raids());
horizon_client::spans::time("spawn_natural", || self.spawn_natural());
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
horizon_client::spans::time("spawn_custom", || {
self.tick_wandering_trader();
self.tick_patrol_spawner();
self.tick_cat_spawner();
self.tick_village_siege();
});
}
self.lan_synced_since(from);
}
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
/// the categories under their global caps (creatures every 400 ticks),
/// then `tickSpawningChunk` in the shuffled spawning chunks of every
@@ -1360,29 +1340,4 @@ mod tests {
.is_none()
);
}
/// `ServerLevel.tick` ticks the raids before the chunk source spawns
/// (`raids.tick`, then `getChunkSource().tick`): a wave that lands in a
/// tick joins before that tick's natural and custom spawns, with the
/// lower entity ids. The phases name themselves in the spans they run in.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn raids_tick_before_natural_and_custom_spawning() {
let mut game = crate::game::native_machines::tests::server_game();
horizon_client::spans::record_here(true);
let _ = horizon_client::spans::take_raw();
game.tick_spawners();
let raw = horizon_client::spans::take_raw();
horizon_client::spans::record_here(false);
let phases: Vec<&str> = raw
.iter()
.map(|s| s.name)
.filter(|name| name.starts_with("spawn_"))
.collect();
assert_eq!(
phases,
["spawn_raids", "spawn_natural", "spawn_custom"],
"ServerLevel.tick ticks the raids before the chunk source's spawns"
);
}
}
+5 -64
View File
@@ -26,31 +26,6 @@ pub(in crate::game) struct Shown {
/// A drawn quad covering this much of the view is the screen's dimming, not something to avoid.
const GUIDE_BACKDROP_SHARE: f32 = 0.9;
/// `ItemStack.get(EQUIPPABLE)` for the guide: the stack's slot and
/// `swappable`, else the item's own.
fn guide_equippable(
stack: &ItemStack,
default_slot: Option<&str>,
default_swappable: bool,
) -> Option<(String, bool)> {
let data = stack.data();
if data.is_some_and(|d| d.other("!minecraft:equippable").is_some()) {
return None;
}
if let Some(tag) = data.and_then(|d| d.other("minecraft:equippable")) {
let slot = match tag.get("slot") {
Some(horizon_world::nbt::Tag::String(slot)) => slot.to_string_lossy(),
_ => return None,
};
let swappable = tag
.get("swappable")
.and_then(horizon_client::item_data::component_bool)
.unwrap_or(true);
return Some((slot, swappable));
}
Some((default_slot?.to_owned(), default_swappable))
}
impl Game<'_> {
/// The guide shows while the pad plays: not over a menu, a prompt, the debug screen or a sleeping
/// or missing player, not once the mouse or keyboard took over, and not when the setting is off.
@@ -655,10 +630,11 @@ impl Game<'_> {
}
}
// Equippable.swapWithEquipmentSlot: only what can be swapped onto the armour slots.
_ if guide_equippable(&stack, info.equip_slot.as_deref(), info.swappable)
.is_some_and(|(slot, swappable)| {
swappable && matches!(slot.as_str(), "head" | "chest" | "legs" | "feet")
}) =>
_ if info.swappable
&& matches!(
info.equip_slot.as_deref(),
Some("head" | "chest" | "legs" | "feet")
) =>
{
Verb::Equip
}
@@ -691,38 +667,3 @@ impl Game<'_> {
(self.creative || self.projectile_slot(&weapon).is_some()).then_some(())
}
}
#[cfg(test)]
mod guide_equippable_tests {
use super::guide_equippable;
use horizon_client::items::ItemStack;
#[test]
fn a_stack_swappable_flag_comes_from_its_equippable() {
let mut helmet = ItemStack::new("minecraft:golden_helmet", 1);
let default = Some("head");
assert_eq!(
guide_equippable(&helmet, default, true),
Some(("head".into(), true))
);
helmet.data_mut().set_other(
"minecraft:equippable",
Some(horizon_world::nbt::Tag::compound([
("slot", horizon_world::nbt::Tag::String("head".into())),
("swappable", horizon_world::nbt::Tag::Byte(0)),
])),
);
assert_eq!(
guide_equippable(&helmet, default, true),
Some(("head".into(), false))
);
helmet.data_mut().set_other(
"!minecraft:equippable",
Some(horizon_world::nbt::Tag::compound(Vec::<(
&str,
horizon_world::nbt::Tag,
)>::new())),
);
assert_eq!(guide_equippable(&helmet, default, true), None);
}
}
+5 -5
View File
@@ -707,7 +707,7 @@ impl Game<'_> {
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_tags(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
}
}
@@ -725,8 +725,8 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
// Entity.load: the saved or summoned UUID, else its own random's.
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
// Entity.load: the saved or summoned UUID, as the other projectiles keep theirs.
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -752,7 +752,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -771,7 +771,7 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.remember_uuid(p.id, Some(p.own_uuid(uuid)), &tag);
self.remember_uuid(p.id, uuid, &tag);
self.add_projectile(p);
self.remember_last_projectile_tags(&tag);
}
@@ -340,7 +340,6 @@ impl Game<'_> {
Cursor::PointingHand => winit::window::CursorIcon::Pointer,
Cursor::NotAllowed => winit::window::CursorIcon::NotAllowed,
Cursor::ResizeNs => winit::window::CursorIcon::NsResize,
Cursor::ResizeEw => winit::window::CursorIcon::EwResize,
});
}
self.cursor_icon = effective;
+1 -26
View File
@@ -582,7 +582,7 @@ impl Game<'_> {
self.team_with_owner(m.id, || tame_owner(m))
}
/// A mob's own team, else its tamed owner's (`owner` is asked only then).
pub(super) fn team_with_owner(
fn team_with_owner(
&self,
id: u32,
owner: impl FnOnce() -> Option<[i32; 4]>,
@@ -643,31 +643,6 @@ impl Game<'_> {
.collect(),
}
}
/// The carts (indices into `carts`, as `near` holds them) that cart `i`
/// pushes this tick, as `OldMinecartBehavior.pushAndPickupEntities` asks
/// `EntitySelector.pushableBy(cart)`: a moving rideable cart keeps only
/// what its own team's and the target's collision rules allow; a still or
/// unrideable cart pushes every minecart in its box (no team filter).
pub(super) fn cart_push_allowed(
&self,
carts: &[super::minecarts::Minecart],
i: usize,
near: &[u32],
) -> Vec<bool> {
let v = carts[i].velocity;
let moving =
carts[i].kind == super::minecarts::CartKind::Rideable && v.x * v.x + v.z * v.z >= 0.01;
if !moving {
return vec![true; near.len()];
}
let own = self.team_with_owner(carts[i].id, || None);
near.iter()
.map(|&j| {
let target = &carts[j as usize];
sb::pushable_by(own, self.team_with_owner(target.id, || None), true)
})
.collect()
}
/// The mobs on the player's team (`isAlliedTo`).
pub(super) fn player_allies(&self) -> HashSet<u32> {
let Some(team) = self.player_team() else {
-5
View File
@@ -2951,8 +2951,3 @@ mod shriek_capture_tests {
}
}
}
// Milking and shearing farm animals; lives here for `TEST_POSTED_EVENTS`.
#[cfg(test)]
#[path = "farm_animal_interaction_tests.rs"]
mod farm_animal_interaction_tests;
@@ -110,33 +110,6 @@ fn a_torch_that_stays_connects_the_wire_in_a_line_and_powers_it() {
);
}
/// `handleSetCommandBlock` edits the block entity through
/// `BaseCommandBlock.setCommand`/`onUpdated`: the command block is marked
/// changed and its update tag reaches the clients. Horizon applied the edit
/// without marking the chunk.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn editing_a_command_block_marks_its_chunk() {
let mut game = game();
run(&mut game, &["setblock 5 100 2 command_block[facing=up]"]);
game.world.modified.clear();
if let Some(log) = game.world.block_log.as_mut() {
log.clear();
}
game.apply_command_block(&super::command_blocks::CommandBlockEdit {
pos: (5, 100, 2),
command: "say hi".into(),
track_output: false,
mode: horizon_client::command_block::Mode::Redstone,
conditional: false,
auto: false,
});
assert!(
game.world.modified.contains(&(0, 0)),
"the edited command block's chunk is dirty"
);
}
/// `TestBlock.ownSignal` reads the block state's `mode`, not the block
/// entity's saved copy: a `start` state with a powered block entity answers
/// 15 even when `/data merge` left a different mode in the entity.
-52
View File
@@ -4,19 +4,13 @@
//! the block sound, attacker knockback (`blockedByItem`) and axes disabling
//! the shield (`Player.blockUsingItem`, `BlocksAttacks.disable`).
use super::Game;
use super::creatures::source_damage_type;
use horizon_client::{
health::{BlockOutcome, Blocking, HurtSource},
items::ItemStack,
mobs::{Damage, Who},
};
use horizon_core::random::RandomSource;
use horizon_world::blocking_rules::{BlockingRules, DamageReduction, ItemDamageFunction};
#[cfg(test)]
#[path = "shield_ram_tests.rs"]
mod ram_tests;
/// `Inventory.SLOT_OFFHAND`: the `using` slot of an off-hand item.
pub(super) const OFFHAND_SLOT: usize = 40;
@@ -119,52 +113,6 @@ impl Game<'_> {
angle,
})
}
/// `LivingEntity.knockback` of the player by a mob event. A ram
/// (`RamTarget.tick`) names its hit's damage: `applyItemBlocking` runs a
/// second time for it, and `blockingFactor` halves the power when it
/// blocked part of it. What follows that block is the hurt's too
/// (`hurtBlockingItem`, `blockedByItem`), but not its sound: `onBlocked`
/// belongs to `hurtServer`.
pub(super) fn mob_pushes_player(
&mut self,
mut power: f64,
x: f64,
z: f64,
source: Damage,
ram_damage: Option<f32>,
) {
let damage_type = source_damage_type(source);
let attacker = match source.attacker {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
if let Some(damage) = ram_damage
&& let Some(blocking) = self.player_blocking(source.from, damage_type, false)
&& let blocked = blocking.blocked(damage)
&& blocked > 0.0
{
let outcome = BlockOutcome {
blocked,
fully_blocked: blocked >= damage,
..Default::default()
};
let hurt = HurtSource {
damage_type,
causing: attacker.map_or(0, |id| id as i32),
direct: 0,
};
self.after_block(outcome, (attacker, false), hurt);
power *= 0.5;
}
// A player's source names no melee attacker, as its damage's doesn't.
let _attacker = self.knockback_scope(attacker.map_or(0, |id| id as i32));
let source = HurtSource {
damage_type,
causing: 0,
direct: 0,
};
self.knock_back_player(power, x, z, source);
}
/// After a hurt with the shield raised: `hurtBlockingItem`, `onBlocked`
/// and, for melee from a mob, `blockUsingItem`. `source` is the hit's.
pub(super) fn after_block(
@@ -1,84 +0,0 @@
//! `RamTarget.tick`: the push a ram gives, and the shield that softens it
//! (`applyItemBlocking`'s `blockingFactor`).
use super::super::*;
use horizon_client::chunk::Chunk;
use horizon_client::mobs::{Damage, MobEvent, Who};
use std::sync::Arc;
fn game() -> Game<'static> {
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES")
.expect("set HORIZON_TEST_RESOURCES to prepared resources"),
);
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = execution_role::ExecutionRole::IntegratedServer;
let res: &'static _ = Box::leak(Box::new(loaded.resources));
let mut game = Game::new(
res,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.player = Some(PlayerMotion::new(game.motion, Vec3::new(0.5, 64.0, 0.5)).unwrap());
// Facing the goat, which stands to the south.
game.yaw = 0.0;
game.build_registries();
game.set_allow_commands(true);
game.world
.chunks
.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
assert!(game.run_command("summon minecraft:goat 0.5 64 1.5 {NoAI:1b,PersistenceRequired:1b}"));
game
}
/// `RamTarget.tick` on a goat with `speedFactor` 3.0 and force 2.5: the hit's
/// 2 damage, then the push away from the goat.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_shield_halves_the_push_of_a_ram() {
let mut game = game();
let goat = game.mobs[0].id;
let from = [0.5, 64.0, 1.5];
let ram = |game: &mut Game<'_>| {
game.handle_mob_events(vec![MobEvent::Knockback {
target: Who::Player,
power: 7.5,
x: 0.0,
z: 1.0,
source: Damage::mob(goat, from),
apply_blocking: Some(2.0),
}])
.unwrap();
game.player.as_ref().unwrap().velocity
};
// An open victim takes the whole push.
assert_eq!(ram(&mut game).z, -7.5, "an unblocked ram's push");
// A raised shield blocks the hit (`blockingFactor` 0.5), and the push with it.
game.player.as_mut().unwrap().velocity = Vec3::ZERO;
game.offhand = Some(ItemStack::new("minecraft:shield", 1));
// `BlocksAttacks.blockDelayTicks` of the shield: 0.25s.
game.using = Some((super::OFFHAND_SLOT, 5));
assert_eq!(
ram(&mut game).z,
-3.75,
"a ram a shield blocks pushes half as far"
);
}
@@ -161,8 +161,8 @@ impl Game<'_> {
let disable_blocking = if self.using.is_some() {
0.0
} else {
self.held()
.map_or(0.0, |stack| self.items().weapon_disable_blocking(stack))
self.attack_item_info()
.map_or(0.0, |i| i.disable_blocking_seconds)
};
Some(PlayerStab {
victim,
+5 -32
View File
@@ -317,7 +317,11 @@ impl Game<'_> {
"minecraft:block.ender_chest.close"
}
} else if name.contains("chest") {
horizon_client::mobs::transport_items::chest_hinge_sound(&name, open)
if open {
"minecraft:block.chest.open"
} else {
"minecraft:block.chest.close"
}
} else if &*name == "minecraft:barrel" {
if open {
"minecraft:block.barrel.open"
@@ -560,37 +564,6 @@ impl Game<'_> {
mod tests {
use super::*;
/// `ChestBlockEntity`'s opener counter plays the block's own hinge
/// sound: a copper chest's, by weather state, not the wooden chest's.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_player_opens_and_closes_a_chest_with_its_own_hinge_sound() {
let mut game = super::super::native_machines::tests::server_game();
for (block, sound) in [
("minecraft:chest", "minecraft:block.chest"),
(
"minecraft:exposed_copper_chest",
"minecraft:block.copper_chest",
),
(
"minecraft:waxed_oxidized_copper_chest",
"minecraft:block.copper_chest_oxidized",
),
] {
let state = game.pack.assets.registry.default_state(block).unwrap();
game.world.set(8, 64, 8, state).unwrap();
game.audio.heard.clear();
game.container_edge_sound((8, 64, 8), true);
game.container_edge_sound((8, 64, 8), false);
let heard: Vec<_> = game.audio.heard.keys().cloned().collect();
assert_eq!(
heard,
[format!("{sound}.close"), format!("{sound}.open")],
"{block}"
);
}
}
#[test]
fn double_chest_halves_connect_around_the_facing() {
let half = |kind, facing| ChestHalf {
+10 -4
View File
@@ -577,11 +577,17 @@ impl Game<'_> {
let (v, on_ground) = self.player.as_ref().map_or((Vec3::ZERO, true), |p| {
(self.known_player_movement(), p.ground.on_ground)
});
projectile.shoot_from_rotation(self.pitch, self.yaw, 0.0, 2.5, 1.0, v, on_ground);
projectile.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
2.5,
1.0,
v,
on_ground,
);
let id = self.take_entity_id();
if let Some(uuid) = projectile.uuid {
self.persist.uuids.insert(id, uuid);
}
let owner_uuid = self.acting_player_uuid();
self.tridents.push(ThrownTrident {
join: Default::default(),
@@ -628,6 +628,7 @@ impl Game<'_> {
(self.known_player_movement(), pl.ground.on_ground)
});
arrow.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
+10 -1
View File
@@ -101,7 +101,16 @@ impl Game<'_> {
let velocity = self.known_player_movement();
let start = Vec3::new(pos.x, eye[1], pos.z);
let mut proj = Projectile::new(ProjectileKind::WindCharge, start, Some(Who::Player));
proj.shoot_from_rotation(self.pitch, self.yaw, 0.0, 1.5, 1.0, velocity, on_ground);
proj.shoot_from_rotation(
&mut self.world.random,
self.pitch,
self.yaw,
0.0,
1.5,
1.0,
velocity,
on_ground,
);
self.add_projectile(proj);
let pitch = 0.4 / (self.world.random.next_float() * 0.4 + 0.8);
self.audio
-12
View File
@@ -647,18 +647,6 @@ pub struct Blocking {
/// Angle between the head's view and the source position (pi without one).
pub angle: f64,
}
impl Blocking {
/// `BlocksAttacks.resolveBlockedDamage`: what the item stops of `damage`,
/// nothing for a source it bypasses or an arrow that pierces it.
pub fn blocked(&self, damage: f32) -> f32 {
if damage <= 0.0 || self.bypassed || self.pierce {
return 0.0;
}
self.rules
.resolve(&mut MatchedSets, damage, self.angle)
.unwrap_or(0.0)
}
}
/// How many hurts `PlayerMotion::hurt_log` keeps.
const HURT_LOG: usize = 64;
/// One hurt as it was taken: what came in, what a raised blocking item
+3 -12
View File
@@ -881,19 +881,10 @@ mod tests {
unprompted: Some(body(0x21, 2)),
};
let served = serve(Connection::local(b), probe, Duration::from_secs(30));
// The queued packet and the one the session sends of its own accord
// are framed by different paths (`Outbox::take` and `Session::tick`
// in `sweep`), so which of the two goes out first is the sweep's
// timing, not an order the pool promises: take both, then tell them
// apart. The kick still has to come after the queued one.
served.send(vec![body(3, 10)], None);
let mut first = client.recv().unwrap();
let mut second = client.recv().unwrap();
if first == packet(&body(0x21, 2)) {
std::mem::swap(&mut first, &mut second);
}
assert_eq!(first, packet(&body(3, 10)));
assert_eq!(second, packet(&body(0x21, 2)));
assert_eq!(client.recv().unwrap(), packet(&body(3, 10)));
// What the session sends of its own accord.
assert_eq!(client.recv().unwrap(), packet(&body(0x21, 2)));
client.send(&body(9, 1)).unwrap();
client.send(&body(10, 1)).unwrap();
assert_eq!(client.recv().unwrap(), packet(&body(0x7f, 3)));
+38 -11
View File
@@ -1435,6 +1435,31 @@ impl Extra {
}
}
/// `DeathProtection.TOTEM_EFFECTS`: clear, then regeneration II, absorption II
/// and fire resistance.
fn totem_death_effects() -> Vec<crate::items::ConsumeEffect> {
use crate::items::{ConsumeEffect, ConsumeEffectSpec};
let spec = |id: &str, duration, amplifier| ConsumeEffectSpec {
id: id.to_owned(),
duration,
amplifier,
ambient: false,
show_particles: true,
show_icon: true,
};
vec![
ConsumeEffect::ClearAllEffects,
ConsumeEffect::ApplyEffects {
effects: vec![
spec("minecraft:regeneration", 900, 1),
spec("minecraft:absorption", 100, 1),
spec("minecraft:fire_resistance", 800, 0),
],
probability: 1.0,
},
]
}
/// How far the boxes of a set of mobs reach from their positions: the widest reach sideways, below
/// and above among them, worked out from each mob's real box ([`Mob::bounds`]: a shulker's grows
/// toward its open lid, sideways or below its position). A box query over many mobs (a vehicle's,
@@ -2059,14 +2084,14 @@ pub enum MobEvent {
StabDismount {
target: Who,
},
/// `LivingEntity.knockback(power, x, z, source)`; a ram's blocked again first (`RamTarget.tick`).
/// `LivingEntity.knockback(power, x, z, source)`: pushed away from the
/// direction.
Knockback {
target: Who,
power: f64,
x: f64,
z: f64,
source: Damage,
apply_blocking: Option<f32>,
},
/// Set another mob's attack target (HurtByTargetGoal alerts).
SetTarget {
@@ -2574,9 +2599,6 @@ pub struct Seen {
pub delay: i32,
/// `getLastHurtByMob` and its timestamp.
pub hurt_by: Option<(Who, i32)>,
/// `PathfinderMob.isPanicking`: a running panic goal or the brain's
/// `IS_PANICKING` memory.
pub panicking: bool,
/// A raider in an active raid.
pub active_raid: bool,
/// A copper golem's `openedChestPos` (`ContainerUser.hasContainerOpen`).
@@ -2587,6 +2609,8 @@ pub struct Seen {
pub vehicle: Option<u32>,
/// `LivingEntity.getHitbox`: the box, cut below the seat when riding.
pub hitbox: Aabb,
/// A villager's reputation of the player (`getPlayerReputation`).
pub player_rep: i32,
/// `isPushable` and pushed by others (`Entity.push(Entity)` moves it).
pub pushable: bool,
/// A sleeping villager, wandering trader or fox vetoes its own `push(Entity)`.
@@ -2769,11 +2793,11 @@ impl Mob {
self.ticks_frozen >= crate::motion::PlayerMotion::TICKS_REQUIRED_TO_FREEZE
}
/// What other mobs see of this one this tick (`_player` is unused: it keyed the villager
/// reputation that nothing read). Inline: the game's snapshot of every mob writes each one in
/// its slot, not through a copy.
/// What other mobs see of this one this tick (`player`: the player's
/// UUID, which villagers' reputation is keyed by). Inline: the game's
/// snapshot of every mob writes each one in its slot, not through a copy.
#[inline]
pub fn seen(&self, _player: Option<[i32; 4]>) -> Seen {
pub fn seen(&self, player: Option<[i32; 4]>) -> Seen {
let m = self;
let villager = m.species() == Species::Villager;
Seen {
@@ -2814,11 +2838,14 @@ impl Mob {
section: (m.extra.section, m.extra.section_seq),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
panicking: m.ai.panicking,
active_raid: raider::has_active_raid(m),
opened_chest: m.extra.opened_chest,
passenger: m.extra.mount.is_some(),
vehicle: m.extra.mount,
player_rep: match (villager, player) {
(true, Some(uuid)) => villager::ext(m).map_or(0, |e| e.gossips.reputation(uuid)),
_ => 0,
},
pushable: m.pushable(),
sleeping: villager::sleeping(m)
|| (m.species() == Species::Fox && fox::flag(m, fox::SLEEPING)),
@@ -4334,7 +4361,7 @@ impl Mob {
let (main, off) = match self.extra.hand_protection.clone() {
Some(h) => (h.main, h.off),
None => {
let totem = || Some(living::totem_death_effects());
let totem = || Some(totem_death_effects());
(
(self.extra.main_hand.as_deref() == Some(TOTEM))
.then(totem)
+13 -41
View File
@@ -2835,13 +2835,13 @@ pub fn do_hurt_target(m: &mut Mob, who: Who, cx: &mut Cx) {
let power = (m.attrs.attack_knockback as f32 + super::gear::knockback(m) as f32) / 2.0;
if power > 0.0 {
use horizon_core::geometry::trig::Mth;
let to_rad = std::f32::consts::PI / 180.0;
cx.events.push(MobEvent::Knockback {
target: who,
power: f64::from(power),
x: f64::from(m.yaw.to_radians().msin()),
z: f64::from(-m.yaw.to_radians().mcos()),
x: f64::from((m.yaw * to_rad).msin()),
z: f64::from(-(m.yaw * to_rad).mcos()),
source,
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -3048,7 +3048,6 @@ fn tick_effects(
// a totem adds its effects to it). An effect a tick appends ticks from
// the next tick on.
let first = m.extra.effects.len();
let old_max_absorption = super::armor::max_absorption(m);
let old_boost_amp = m
.extra
.effects
@@ -3141,13 +3140,17 @@ fn tick_effects(
}
i += 1;
}
let absorption_gone = m
.extra
.effects
.iter()
.any(|e| e.0 == "minecraft:absorption" && e.1 <= 0 && e.1 != -1);
m.extra.effects.retain(|e| e.1 > 0 || e.1 == -1);
// LivingEntity.onEffectsRemoved, or onEffectUpdated for a promoted hidden
// instance (a weaker Absorption) -> refreshDirtyAttributes ->
// onAttributeUpdated(MAX_ABSORPTION): the amount is clamped to the new max.
let max_absorption = super::armor::max_absorption(m);
if max_absorption != old_max_absorption {
m.absorption = m.absorption.min(max_absorption);
// LivingEntity.onEffectsRemoved -> refreshDirtyAttributes ->
// onAttributeUpdated(MAX_ABSORPTION): the amount is clamped to the
// maximum left without the effect's modifier.
if absorption_gone {
m.absorption = m.absorption.min(super::armor::max_absorption(m));
}
// LivingEntity.onEffectsRemoved / onEffectUpdated -> refreshDirtyAttributes.
let new_boost_amp = m
@@ -6170,37 +6173,6 @@ mod hurt_teleport_contact_tests {
}
}
#[cfg(test)]
mod hidden_absorption_promotion_tests {
use super::*;
use std::sync::Arc;
/// #11384: a promoted hidden instance goes through `onEffectUpdated(effect,
/// true)`, whose `refreshDirtyAttributes` clamps the absorption amount to the
/// weaker effect's `max_absorption` (Absorption II's 8 down to I's 4).
#[test]
fn a_promoted_weaker_absorption_clamps_the_amount() {
let mut kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
kind.id = "minecraft:pig".into();
kind.species = Species::Pig;
let mut m = Mob::new(203, Arc::new(kind), Vec3::ZERO, 0.0);
m.add_effect("absorption", 600, 0);
m.add_effect("absorption", 2, 1);
assert_eq!(m.extra.effect_fallbacks.len(), 1, "Absorption I waits");
assert_eq!(
(m.absorption, super::super::armor::max_absorption(&m)),
(8.0, 8.0)
);
for _ in 0..3 {
tick_effects(&mut m, None, &mut |_| {});
}
assert_eq!(m.extra.effects.len(), 1);
assert_eq!(m.extra.effects[0].2, 0, "Absorption I took over");
assert_eq!(super::super::armor::max_absorption(&m), 4.0);
assert_eq!(m.absorption, 4.0);
}
}
#[cfg(test)]
mod baby_speed_tests {
use super::*;
+24 -31
View File
@@ -276,37 +276,8 @@ pub(super) fn no_ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>)
if m.species() == Species::GlowSquid {
glow_squid_dark_tick(m);
}
if m.species() == Species::Tadpole {
// Tadpole.aiStep ages it outside serverAiStep too.
grow_up(m, events);
super::ai::make_age_locked_particle(m, events);
}
}
/// `Tadpole.aiStep`: growing up into a frog after 24000 ticks.
fn grow_up(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.age >= 0 && !m.extra.age_locked {
m.extra.age += 1;
if m.extra.age >= 24_000 && !m.removed {
m.removed = true;
// Tadpole.ageUp: playSound at its feet, volume 0.15 (not
// when silent: Entity.playSound).
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.tadpole.grow_up", at, 0.15, 1.0));
}
// `ConversionParams.single(this, false, false)`; the game
// finalizes the frog and keeps it persistent.
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind: "minecraft:frog",
params: super::conversion::Params {
keep_equipment: false,
preserve_can_pick_up_loot: false,
},
});
}
}
}
/// Species aiStep additions.
pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
use Species as S;
@@ -317,7 +288,29 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
glow_squid_ai_step(m, events);
}
S::Tadpole => {
grow_up(m, events);
// Tadpole.aiStep: growing up into a frog after 24000 ticks.
if m.extra.age >= 0 && !m.extra.age_locked {
m.extra.age += 1;
if m.extra.age >= 24_000 && !m.removed {
m.removed = true;
// Tadpole.ageUp: playSound at its feet, volume 0.15 (not
// when silent: Entity.playSound).
if !m.silent {
let at = [m.position.x, m.position.y, m.position.z];
events.push(MobEvent::Sound("entity.tadpole.grow_up", at, 0.15, 1.0));
}
// `ConversionParams.single(this, false, false)`; the game
// finalizes the frog and keeps it persistent.
events.push(MobEvent::Convert {
from: Box::new(m.clone()),
kind: "minecraft:frog",
params: super::conversion::Params {
keep_equipment: false,
preserve_can_pick_up_loot: false,
},
});
}
}
if !m.fluids.in_water && m.on_ground && m.vertical_collision_below {
let (a, b) = (m.random.next_float(), m.random.next_float());
m.velocity = Vec3::new(
+1 -40
View File
@@ -1,5 +1,4 @@
//! Effective mob armor and toughness at the damage boundary.
use super::finalize::{self, Attr};
use super::{Mob, Species};
use horizon_world::attributes::{Definition, Instance, Observer, entity::EntityAttributes};
use std::convert::Infallible;
@@ -49,12 +48,10 @@ pub fn values(mob: &Mob) -> (f32, f32) {
0.0
};
let map = mob.extra.armor_attributes.as_deref();
let armor_base =
finalize::base_value_with(mob, Attr::Armor, || Some(f64::from(mob.kind.armor)));
let armor = effective(
map,
"minecraft:armor",
armor_base,
f64::from(mob.kind.armor),
f64::from(worn) + covered,
30.0,
);
@@ -248,40 +245,4 @@ mod tests {
assert_eq!(max_absorption_of(None, 0.0, 8.0), 8.0);
assert_eq!(max_absorption_of(None, 0.0, -4.0), 0.0);
}
fn magma_cube(size: i32) -> Mob {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let ty = Arc::make_mut(&mut kind);
ty.id = "minecraft:magma_cube".into();
ty.species = Species::MagmaCube;
let mut mob = Mob::new(1, kind, Vec3::ZERO, 0.0);
crate::mobs::cube::set_size(&mut mob, size);
mob
}
/// MagmaCube.setSize writes `size * 3` into `Attributes.ARMOR`, and
/// `LivingEntity.getArmorValue` reads that base back (no armor map needed).
#[test]
fn magma_cube_armor_is_its_size_times_three_and_softens_hits() {
for size in [2, 4] {
assert_eq!(
values(&magma_cube(size)),
((size * 3) as f32, 0.0),
"size {size}"
);
}
let mut small = magma_cube(1);
let mut big = magma_cube(4);
let hit = Damage::of(DamageKind::Cactus);
let small_before = small.health;
let big_before = big.health;
assert!(small.damage(0.5, hit));
assert!(big.damage(0.5, hit));
assert!(
big_before - big.health < small_before - small.health,
"size 4 lost {} and size 1 lost {}",
big_before - big.health,
small_before - small.health
);
}
}
+1 -62
View File
@@ -224,9 +224,7 @@ impl Goal for DefendTrader {
let Some(t) = cx.w.target_info(by) else {
return false;
};
if !t.alive
|| t.is_player && (t.creative || cx.w.player.as_ref().is_some_and(|p| p.spectator))
{
if !t.alive || t.is_player && t.creative {
return false;
}
let follow = m.attrs.follow_range;
@@ -273,62 +271,3 @@ pub fn trader_llama_tick(m: &mut Mob, w: &MobWorld) {
m.removed = true;
}
}
#[cfg(test)]
mod defend_trader_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
use crate::mobs::{Mob, Species, sensor_scan_tests::kind};
use horizon_core::geometry::Vec3;
use horizon_core::random::LegacyRandom;
use std::sync::Arc;
/// `TraderLlamaDefendWanderingTraderGoal` runs the trader's attacker
/// through `TargetingConditions.DEFAULT`, which never accepts a
/// spectator.
#[test]
fn a_trader_llama_does_not_defend_the_trader_from_a_spectator() {
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for spectator in [false, true] {
let mut world = w.clone();
let mut p = test_player(Vec3::new(3.5, 64.0, 0.5));
p.spectator = spectator;
world.player = Some(p);
let mut owner_kind = (*kind()).clone();
owner_kind.species = Species::WanderingTrader;
let mut owner = Mob::new(2, Arc::new(owner_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
owner.ai.last_hurt_by = Some(Who::Player);
owner.ai.last_hurt_by_time = 5;
let seen = [owner.seen(None)];
let world = crate::mobs::MobWorld {
others: &seen,
..world
};
let mut llama_kind = (*kind()).clone();
llama_kind.species = Species::TraderLlama;
let mut llama = Mob::new(1, Arc::new(llama_kind), Vec3::new(0.5, 64.0, 0.5), 0.0);
llama.extra.leash = Some(super::super::leash::Holder::Mob(2));
llama.ai.goals.add(1, DefendTrader::default());
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut llama.ai.goals);
goals.tick(&mut llama, &mut cx);
outcomes.push(goals.is_running("DefendTrader"));
llama.ai.goals = goals;
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"a survival player is defended against; a spectator is not"
);
}
}
+21 -73
View File
@@ -189,9 +189,26 @@ impl GoalSelector {
// --- random positions (DefaultRandomPos / LandRandomPos / RandomPos) -------
#[path = "random_pos.rs"]
mod random_pos;
use random_pos::move_up_to_above_solid;
/// RandomPos.moveUpToAboveSolid: out of the solid blocks, then up to
/// `above` more blocks of air.
fn move_up_to_above_solid(w: &MobWorld, mut p: [i32; 3], above: i32) -> [i32; 3] {
if !w.solid(p[0], p[1], p[2]) {
return p;
}
p[1] += 1;
while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) {
p[1] += 1;
}
let first = p[1];
while p[1] < w.world.top_y() && p[1] - first < above {
p[1] += 1;
if w.solid(p[0], p[1], p[2]) {
p[1] -= 1;
break;
}
}
p
}
#[path = "home.rs"]
mod home;
pub use home::within_home;
@@ -2202,8 +2219,6 @@ impl Goal for Breed {
// AbstractHorse.canParent of the partner.
&& (!(equine || camel) || s.tame && !s.ridden && s.health >= s.stats[0] as f32)
&& (!sniffer || super::sniffer::can_mate_state(s.variant))
// BreedGoal.getFreePartner: `!potentialPartner.isPanicking()`.
&& !s.panicking
})
.find(|s| m.distance_sqr(s.position) <= 64.0)
.map(|s| s.id);
@@ -2212,8 +2227,7 @@ impl Goal for Breed {
fn can_continue(&mut self, _: &mut Mob, cx: &mut Cx) -> bool {
self.partner
.and_then(|p| cx.w.seen(p))
// BreedGoal.canContinueToUse: `!this.partner.isPanicking()`.
.is_some_and(|s| s.alive && s.in_love && !s.panicking)
.is_some_and(|s| s.alive && s.in_love)
&& self.love_time < 60
}
fn stop(&mut self, _: &mut Mob, _: &mut Cx) {
@@ -3415,69 +3429,3 @@ mod hurt_by_anger_tests {
);
}
}
#[cfg(test)]
mod breed_continue_tests {
use super::*;
use crate::mobs::world::with_test_world;
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `BreedGoal.canContinueToUse` refuses a partner that is panicking. The
/// goal starts with a calm partner and must stop when the partner panics.
#[test]
fn a_breed_goal_stops_when_the_partner_panics() {
let mut kind = None;
if !with_test_world(&[], |_, types| {
kind = Some(Arc::new(types.types["minecraft:cow"].clone()));
}) {
return;
}
let Some(kind) = kind else { return };
let mut partner = Mob::new(2, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
partner.extra.in_love = 600;
let mut m = Mob::new(1, kind, Vec3::new(0.5, 64.0, 0.5), 0.0);
m.extra.in_love = 600;
m.ai.goals.add(1, Breed::new(1.0));
let mut started = None;
let calm = [partner.seen(None)];
if !with_test_world(&calm, |w, _| {
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
started = Some(goals.is_running("Breed"));
m.ai.goals = goals;
}) {
return;
}
partner.ai.panicking = true;
let panicking = [partner.seen(None)];
let mut stopped = None;
if !with_test_world(&panicking, |w, _| {
let mut lr = LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut m.ai.goals);
goals.tick(&mut m, &mut cx);
stopped = Some(!goals.is_running("Breed"));
m.ai.goals = goals;
}) {
return;
}
assert_eq!(
(started, stopped),
(Some(true), Some(true)),
"the breed goal starts for a calm partner and stops when it panics"
);
}
}
+7 -129
View File
@@ -273,25 +273,17 @@ impl Goal for Ram {
fn flags(&self) -> u8 {
MOVE | LOOK
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// `GoatAi.initRamActivity`: the ram activity wants the cooldown over,
// no tempting player and no breed target, and `updateActivity` picks
// RAM before IDLE: a goat being fed or bred only follows.
ram_cooldown(m) <= 0 && !tempted(m, cx.w) && !breeding(m, cx.w)
fn can_use(&mut self, m: &mut Mob, _cx: &mut Cx) -> bool {
// The ram activity needs the cooldown to be over (it also waits for
// no tempting player and no breed target, which goats lack here).
ram_cooldown(m) <= 0
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
let g = &m.extra.goat;
match g.phase {
Phase::Done => false,
Phase::Prepare => {
// `GoatAi.updateActivity` runs every tick: a player who starts
// feeding the goat, or a breed target that appears, switches the
// activity away and `PrepareRamNearestTarget.stop` starts the
// cooldown. (A charge under way keeps `RAM_TARGET`; only its own
// `canStillUse` ends that one.)
!tempted(m, cx.w)
&& !breeding(m, cx.w)
&& g.time <= PREPARE_TIME_OUT
g.time <= PREPARE_TIME_OUT
&& g.candidate
.is_some_and(|c| cx.w.target_info(c.target).is_some_and(|t| t.alive))
}
@@ -435,20 +427,15 @@ fn ram_tick(m: &mut Mob, cx: &mut Cx) {
};
let boost = 0.25 * (level("minecraft:speed") - level("minecraft:slowness")) as f32;
let factor = (m.ai.speed * 1.65).clamp(0.2, 3.0) + boost;
// `blockingFactor * speedFactor` stays a float, then the force (a
// double) scales it.
let force = if m.is_baby() { 1.0 } else { 2.5 };
let power = f64::from(factor) * force;
let d = m.extra.goat.ram_direction;
// The knockback's source is `mobAttack`, not the hurt's `noAggroMobAttack`,
// and `applyItemBlocking` runs again for it before the push.
// The knockback's source is `mobAttack`, not the hurt's `noAggroMobAttack`.
cx.events.push(MobEvent::Knockback {
target,
power,
power: f64::from(factor * force),
x: d.x,
z: d.z,
source: Damage::mob(m.id, from),
apply_blocking: Some(amount),
});
finish_ram(m, cx.events);
play(m, cx.events, impact, 1.0);
@@ -589,112 +576,3 @@ mod sound_tests {
assert_eq!(goat.random.next_long(), expected.next_long());
}
}
#[cfg(test)]
mod ram_tests {
use super::*;
use crate::mobs::world::{test_player, with_test_world};
fn fake_goat() -> std::sync::Arc<super::super::MobType> {
let mut kind = crate::mobs::sensor_scan_tests::kind();
let k = std::sync::Arc::make_mut(&mut kind);
k.species = super::super::Species::Goat;
k.food = std::sync::Arc::from(["minecraft:wheat".to_string()]);
kind
}
/// `GoatAi.initRamActivity` wants `TEMPTING_PLAYER` and `BREED_TARGET`
/// absent, and `GoatAi.updateActivity` picks RAM before IDLE: a goat being
/// fed or bred only follows.
#[test]
fn a_fed_or_bred_goat_starts_no_ram() {
let kind = fake_goat();
let mut partner = Mob::new(9, kind.clone(), Vec3::new(2.5, 64.0, 0.5), 0.0);
partner.extra.in_love = 600;
let others = [partner.seen(None)];
let ran = with_test_world(&others, |w, _| {
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
let mut goat = Mob::new(7, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
let mut due = |goat: &mut Mob, w: &MobWorld| {
Ram.can_use(
goat,
&mut Cx {
w,
lr: &mut lr,
events: &mut events,
},
)
};
assert!(due(&mut goat, w), "a ram is due");
// `BREED_TARGET`: in love, with an adult goat in love nearby.
goat.extra.in_love = 600;
assert!(!due(&mut goat, w), "a goat being bred only follows");
goat.extra.in_love = 0;
// `TEMPTING_PLAYER`: a player within 10 blocks holding goat food.
let mut fed = w.clone();
let mut p = test_player(Vec3::new(0.5, 64.0, 3.5));
p.main_hand = Some("minecraft:wheat".into());
fed.player = Some(p);
assert!(!due(&mut goat, &fed), "a goat being fed only follows");
// Any other item is no temptation.
let mut stone = w.clone();
let mut p = test_player(Vec3::new(0.5, 64.0, 3.5));
p.main_hand = Some("minecraft:stone".into());
stone.player = Some(p);
assert!(due(&mut goat, &stone));
});
let _ = ran;
}
/// `RamTarget.tick` posts the hit (`noAggroMobAttack`) and then its push,
/// which names the damage the victim's `applyItemBlocking` runs again on.
#[test]
fn a_ram_posts_the_hits_damage_with_its_push() {
let kind = fake_goat();
let ran = with_test_world(&[], |w, _| {
let mut w = w.clone();
w.player = Some(test_player(Vec3::new(0.5, 64.0, 0.5)));
let mut goat = Mob::new(7, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
goat.attrs.attack_damage = 2.0;
goat.ai.speed = 3.0;
goat.extra.goat.ram_direction = Vec3::new(0.0, 0.0, 1.0);
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
ram_tick(
&mut goat,
&mut Cx {
w: &w,
lr: &mut lr,
events: &mut events,
},
);
assert!(matches!(
events[0],
MobEvent::DamagePlayer {
amount: 2.0,
kind: "minecraft:mob_attack_no_aggro",
..
}
));
let push = events
.iter()
.find_map(|e| match e {
MobEvent::Knockback {
target: Who::Player,
power,
x,
z,
apply_blocking,
..
} => Some((*power, *x, *z, *apply_blocking)),
_ => None,
})
.unwrap_or_else(|| panic!("the ram posts no push: {events:?}"));
// clamp(3.0 * 1.65, 0.2, 3.0) * 2.5, and the hit's damage for the
// victim's second `applyItemBlocking`.
assert_eq!(push, (7.5, 0.0, 1.0, Some(2.0)));
});
let _ = ran;
}
}
+6 -1
View File
@@ -557,7 +557,12 @@ fn snowball_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let yo = (xd * xd + zd * zd).sqrt() * f64::from(0.2_f32);
let mut p = Projectile::new(ProjectileKind::Snowball, start, Some(Who::Mob(m.id)));
// SnowGolem.rangedAttackUncertainty: 12 on every difficulty.
p.shoot_own([xd, yd + yo - start.y, zd], 1.6, 12.0);
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + yo - start.y, zd],
1.6,
12.0,
);
cx.events.push(MobEvent::Projectile(p));
let pitch = 0.4 / (m.random.next_float() * 0.4 + 0.8);
cx.events.push(MobEvent::Sound(
+2 -1
View File
@@ -668,7 +668,8 @@ fn shoot_crossbow(m: &mut Mob, who: Who, cx: &mut Cx) {
);
p.set_bonus(super::gear::arrow_bonus(m));
// rangedAttackUncertainty: 14 - 4 * difficulty.
p.shoot_own(
p.shoot(
&mut crate::random_source::entity(),
[dx, dy, dz],
1.6,
(14 - i32::from(cx.w.difficulty.id) * 4) as f32,
-25
View File
@@ -21,31 +21,6 @@ use horizon_world::{
registry::BlockStateId,
};
/// `DeathProtection.TOTEM_EFFECTS`: clear, then regeneration II, absorption II
/// and fire resistance.
pub(super) fn totem_death_effects() -> Vec<crate::items::ConsumeEffect> {
use crate::items::{ConsumeEffect, ConsumeEffectSpec};
let spec = |id: &str, duration, amplifier| ConsumeEffectSpec {
id: id.to_owned(),
duration,
amplifier,
ambient: false,
show_particles: true,
show_icon: true,
};
vec![
ConsumeEffect::ClearAllEffects,
ConsumeEffect::ApplyEffects {
effects: vec![
spec("minecraft:regeneration", 900, 1),
spec("minecraft:absorption", 100, 1),
spec("minecraft:fire_resistance", 800, 0),
],
probability: 1.0,
},
]
}
/// How many block particles a landing throws up
/// (`(int)(min(0.2F + power / 15, 2.5) * 150)`), none when the fall is within
/// `safe_fall_distance` (`power = max(floor(fall + 1.0E-6 - safe), 0)`).
+6 -1
View File
@@ -226,7 +226,12 @@ fn spit(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
let zd = t.position.z - m.position.z;
let yo = (xd * xd + zd * zd).sqrt() * f64::from(0.2_f32);
let mut p = Projectile::new(ProjectileKind::LlamaSpit, start, Some(Who::Mob(m.id)));
p.shoot_own([xd, yd + yo, zd], 1.5, 10.0);
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + yo, zd],
1.5,
10.0,
);
cx.events.push(MobEvent::Projectile(p));
let pitch = 1.0 + (m.random.next_float() - m.random.next_float()) * 0.2;
cx.events.push(MobEvent::Sound(
@@ -1255,7 +1255,17 @@ fn shoot_tick(m: &mut Mob, cx: &mut Bx) {
);
// The new entity's own random spreads the shot (5 - difficulty id * 4):
// not the breeze's.
p.shoot_own([xd, yd, zd], 0.7, 5.0 - f32::from(cx.w.difficulty.id) * 4.0);
let mut own = LegacyRandom::new(
cx.now
.wrapping_mul(0x5DEE_CE66)
.wrapping_add(i64::from(m.id)),
);
p.shoot(
&mut own,
[xd, yd, zd],
0.7,
5.0 - f32::from(cx.w.difficulty.id) * 4.0,
);
cx.events.push(MobEvent::Projectile(p));
breeze_sound(m, cx, "entity.breeze.shoot", 1.5);
}
@@ -577,7 +577,6 @@ impl Goal for Charge {
x: f64::from(horizon_core::geometry::trig::sin(yaw)),
z: -f64::from(horizon_core::geometry::trig::cos(yaw)),
source: super::Damage::mob(m.id, from),
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -1,23 +0,0 @@
//! `RandomPos` helpers of the random-position goals.
use crate::mobs::MobWorld;
/// RandomPos.moveUpToAboveSolid: out of the solid blocks, then up to
/// `above` more blocks of air.
pub(super) fn move_up_to_above_solid(w: &MobWorld, mut p: [i32; 3], above: i32) -> [i32; 3] {
if !w.solid(p[0], p[1], p[2]) {
return p;
}
p[1] += 1;
while p[1] < w.world.top_y() && w.solid(p[0], p[1], p[2]) {
p[1] += 1;
}
let first = p[1];
while p[1] < w.world.top_y() && p[1] - first < above {
p[1] += 1;
if w.solid(p[0], p[1], p[2]) {
p[1] -= 1;
break;
}
}
p
}
@@ -300,7 +300,6 @@ fn cause_extra_knockback(
x: f64::from((m.yaw * to_rad).msin()),
z: f64::from(-(m.yaw * to_rad).mcos()),
source,
apply_blocking: None,
});
m.velocity = Vec3::new(m.velocity.x * 0.6, m.velocity.y, m.velocity.z * 0.6);
}
@@ -89,24 +89,6 @@ fn name_at(w: &MobWorld, p: [i32; 3]) -> String {
.name
.to_string()
}
/// `ChestBlock.getOpenChestSound` / `getCloseChestSound` of a chest block:
/// a copper chest's comes from `CopperChestBlock.getHingeSound`, by its
/// weather state (a waxed chest's is its state's).
pub fn chest_hinge_sound(block: &str, open: bool) -> &'static str {
let base = block
.trim_start_matches("minecraft:")
.trim_start_matches("waxed_");
match (base.ends_with("copper_chest"), base.split('_').next(), open) {
(false, _, true) => "minecraft:block.chest.open",
(false, _, false) => "minecraft:block.chest.close",
(true, Some("weathered"), true) => "minecraft:block.copper_chest_weathered.open",
(true, Some("weathered"), false) => "minecraft:block.copper_chest_weathered.close",
(true, Some("oxidized"), true) => "minecraft:block.copper_chest_oxidized.open",
(true, Some("oxidized"), false) => "minecraft:block.copper_chest_oxidized.close",
(true, _, true) => "minecraft:block.copper_chest.open",
(true, _, false) => "minecraft:block.copper_chest.close",
}
}
fn is_chest_block(name: &str) -> bool {
matches!(name, "minecraft:chest" | "minecraft:trapped_chest") || name.ends_with("copper_chest")
}
@@ -348,7 +330,20 @@ fn open_or_close(cx: &mut Bx, p: [i32; 3], open: bool, mob: u32) {
at[0] += f64::from(o[0] - p[0]) * 0.5;
at[2] += f64::from(o[2] - p[2]) * 0.5;
}
let sound = chest_hinge_sound(&name_at(w, p), open).trim_start_matches("minecraft:");
let name = name_at(w, p);
let base = name
.trim_start_matches("minecraft:")
.trim_start_matches("waxed_");
let sound = match (base.ends_with("copper_chest"), base.split('_').next(), open) {
(false, _, true) => "block.chest.open",
(false, _, false) => "block.chest.close",
(true, Some("weathered"), true) => "block.copper_chest_weathered.open",
(true, Some("weathered"), false) => "block.copper_chest_weathered.close",
(true, Some("oxidized"), true) => "block.copper_chest_oxidized.open",
(true, Some("oxidized"), false) => "block.copper_chest_oxidized.close",
(true, _, true) => "block.copper_chest.open",
(true, _, false) => "block.copper_chest.close",
};
let pitch = cx.lr.next_float() * 0.1 + 0.9;
cx.events.push(MobEvent::Sound(sound, at, 0.5, pitch));
}
+7 -7
View File
@@ -294,7 +294,8 @@ fn drowned_throw(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
if let Some(a) = p.arrow.as_mut() {
a.set_item(&trident);
}
p.shoot_own(
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + dist * f64::from(0.2_f32), zd],
1.6,
ranged_attack_uncertainty(cx.w.difficulty.id),
@@ -894,6 +895,8 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
let dist = (xd * xd + zd * zd).sqrt();
// AbstractArrow.setBaseDamageFromMob: power * 2 + triangle(difficulty * 0.11, 0.57425).
let id = i32::from(cx.w.difficulty.id);
let mut arrow_random = crate::random_source::entity();
let base = f64::from(power) * 2.0 + arrow_random.triangle(f64::from(id) * 0.11, 0.574_25);
let effect = match m.species() {
Species::Stray => Some(("minecraft:slowness", 600)),
Species::Bogged => Some(("minecraft:poison", 100)),
@@ -902,20 +905,17 @@ pub fn perform_ranged_attack(m: &mut Mob, who: Who, cx: &mut Cx, power: f32) {
};
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 0.0,
base_damage: base,
critical: false,
effect,
},
start,
Some(Who::Mob(m.id)),
);
// From the arrow's own random, after its UUID.
if let (ProjectileKind::Arrow { base_damage, .. }, Some(a)) = (&mut p.kind, p.arrow.as_mut()) {
*base_damage = f64::from(power) * 2.0 + a.random.triangle(f64::from(id) * 0.11, 0.574_25);
}
p.set_bonus(super::gear::arrow_bonus(m));
ignite_for_wither_skeleton(m, &mut p);
p.shoot_own(
p.shoot(
&mut arrow_random,
[xd, yd + dist * f64::from(0.2_f32), zd],
1.6,
(14 - id * 4) as f32,
+1 -56
View File
@@ -173,7 +173,7 @@ impl Goal for CopyOwnerTarget {
.find(|s| s.id == owner)
.and_then(|s| s.target)
});
seen.is_some_and(|t| t != Who::Mob(m.id) && copyable(cx.w, t))
seen.is_some_and(|t| t != Who::Mob(m.id))
}
fn can_continue(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
m.target_info(cx.w).is_some_and(|t| t.alive)
@@ -187,14 +187,6 @@ impl Goal for CopyOwnerTarget {
});
}
}
/// `TargetingConditions.test` for the owner's target: `canBeSeenByAnyone`
/// leaves out the dead and spectators.
fn copyable(w: &crate::mobs::MobWorld, t: Who) -> bool {
match t {
Who::Player => w.player.as_ref().is_some_and(|p| p.alive && !p.spectator),
Who::Mob(id) => w.seen(id).is_some_and(|s| s.alive),
}
}
/// `VexMoveControl`: accelerate straight at the wanted point.
pub fn move_control(m: &mut Mob) {
if m.ai.mov.operation != Operation::MoveTo {
@@ -441,51 +433,4 @@ mod owner_save_tests {
let saved = mob_to_nbt(&loose, [0; 4], Some(&stale), None, |_| None);
assert_eq!(saved.get("owner"), None, "no owner, no key");
}
/// `VexCopyOwnerTargetGoal.canUse` runs the owner's target through
/// `TargetingConditions.forNonCombat()`, whose `canBeSeenByAnyone` drops a
/// dead target. A dead target is not copied.
#[test]
fn a_vex_does_not_copy_a_dead_owners_target() {
use crate::mobs::world::with_test_world;
let mut outcomes = Vec::new();
if !with_test_world(&[], |w, _| {
for alive in [true, false] {
let mut owner_kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
owner_kind.species = Species::Evoker;
let mut owner = Mob::new(2, Arc::new(owner_kind), Vec3::new(1.5, 64.0, 0.5), 0.0);
owner.ai.target = Some(Who::Mob(3));
let mut target_kind = (*crate::mobs::sensor_scan_tests::kind()).clone();
target_kind.species = Species::Cow;
let mut target = Mob::new(3, Arc::new(target_kind), Vec3::new(2.5, 64.0, 0.5), 0.0);
target.health = if alive { target.health } else { 0.0 };
let seen = [owner.seen(None), target.seen(None)];
let world = crate::mobs::MobWorld {
others: &seen,
..w.clone()
};
let mut vex = vex();
vex.position = Vec3::new(0.5, 64.0, 0.5);
vex.extra.counter2 = 2;
vex.ai.targets.add(2, CopyOwnerTarget);
let mut lr = horizon_core::random::LegacyRandom::new(0);
let mut events = Vec::new();
let mut cx = Cx {
w: &world,
lr: &mut lr,
events: &mut events,
};
let mut goals = std::mem::take(&mut vex.ai.targets);
goals.tick(&mut vex, &mut cx);
outcomes.push(goals.is_running("CopyOwnerTarget"));
vex.ai.targets = goals;
}
}) {
return;
}
assert_eq!(
outcomes,
vec![true, false],
"an alive target is copied; a dead one is not"
);
}
}
+2 -1
View File
@@ -231,7 +231,8 @@ fn witch_attack(m: &mut Mob, who: Who, _power: f32, cx: &mut Cx) {
Some(Who::Mob(m.id)),
);
// Splash potions throw with gravity 0.05.
p.shoot_own(
p.shoot(
&mut crate::random_source::entity(),
[xd, yd + dist * 0.2, zd],
if dist <= 2.0 { 0.45 } else { 0.75 },
8.0,
+1 -48
View File
@@ -2900,7 +2900,7 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> {
== "minecraft:scaffolding")
}
fn suppressing_ladder_slide(&mut self) -> Result<bool> {
Ok(self.player.shift_key_down && !(self.player.client_side && self.player.flying))
Ok(self.player.shift_key_down)
}
fn is_player_class(&self) -> bool {
true
@@ -6267,51 +6267,4 @@ mod tests {
assert_eq!(player.velocity.y, want, "client_side={client_side}");
}
}
/// `LocalPlayer.isSuppressingSlidingDownLadder` is `!abilities.flying && super...()`, where
/// `LivingEntity`'s is `isShiftKeyDown()`: a flying player on the client keeps sliding down a
/// ladder whether it sneaks or not. The server's player (`ServerPlayer` has no override) only
/// looks at the sneak.
#[test]
fn a_flying_client_does_not_stop_sliding_down_a_ladder_by_sneaking() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
// (client_side, flying, sneaking, suppressing)
for (client_side, flying, sneaking, want) in [
(true, false, true, true),
(true, false, false, false),
(true, true, true, false),
(true, true, false, false),
(false, false, true, true),
(false, true, true, true),
(false, true, false, false),
] {
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
player.client_side = client_side;
player.flying = flying;
player.shift_key_down = sneaking;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
health: &resources.health,
};
assert_eq!(
w.suppressing_ladder_slide().unwrap(),
want,
"client_side={client_side} flying={flying} sneaking={sneaking}"
);
}
}
}
+9 -43
View File
@@ -275,18 +275,6 @@ pub struct Projectile {
/// charge until [`Projectile::set_item`] gives it another. None for the
/// kinds that are no `Fireball` (a wither skull, a wind charge).
pub item: Option<crate::items::ItemStack>,
/// The UUID its own random made (`Mth.createInsecureUUID`), for the server
/// to keep unless a save gives one: an arrow's at construction, another
/// kind's when it is shot (`shoot_own`), which is the same stream since
/// nothing else draws from it between.
pub uuid: Option<[i32; 4]>,
}
/// A new projectile's own random (`Entity.random`) and the UUID its first two
/// draws make (`Mth.createInsecureUUID`).
pub fn new_own_random() -> (horizon_core::random::LegacyRandom, [i32; 4]) {
let mut random = crate::random_source::entity();
let uuid = crate::save::insecure_uuid(&mut random);
(random, uuid)
}
/// `ThrownEnderpearl.isAllowedToTeleportOwner(owner, level)` for an owner in
/// the pearl's own dimension: vanilla reaches
@@ -475,9 +463,6 @@ impl Projectile {
} else {
0.1
};
// An arrow keeps its own random (`Entity.random`) for its later draws.
let own = kind_is_arrow.then(new_own_random);
let uuid = own.as_ref().map(|o| o.1);
Self {
join: Default::default(),
id: 0,
@@ -511,8 +496,12 @@ impl Projectile {
fall_distance: 0.0,
synced_fire: false,
item,
arrow: own.map(|(random, _)| Box::new(crate::arrows::ArrowState::new(random))),
uuid,
arrow: kind_is_arrow.then(|| {
// The arrow's own random (`Entity.random`).
Box::new(crate::arrows::ArrowState::new(
crate::random_source::entity(),
))
}),
}
}
/// Entity box (`EntityType.sized` cube of `kind.size()`), as `bounds`.
@@ -803,33 +792,10 @@ impl Projectile {
self.velocity = Vec3::new(v[0], v[1], v[2]);
self.update_rotation();
}
/// The UUID a loaded or summoned projectile keeps (`Entity.load`): the
/// tag's, else the one its own random made. The constructor makes that
/// random either way, so a kind that makes it at the shot makes it here.
pub fn own_uuid(&mut self, tag_uuid: Option<[i32; 4]>) -> [i32; 4] {
let own = self.uuid.take().unwrap_or_else(|| new_own_random().1);
tag_uuid.unwrap_or(own)
}
/// `Projectile.shoot` from the projectile's own random, after its UUID's
/// draws: an arrow's, or for another kind one made now.
pub fn shoot_own(&mut self, dir: [f64; 3], speed: f32, uncertainty: f32) {
let mut random = match self.arrow.as_mut() {
Some(a) => a.random.clone(),
None => {
let (random, uuid) = new_own_random();
self.uuid = Some(uuid);
random
}
};
self.shoot(&mut random, dir, speed, uncertainty);
if let Some(a) = self.arrow.as_mut() {
a.random = random;
}
}
/// Projectile.shootFromRotation for player throws, from the projectile's
/// own random (`shoot_own`).
/// Projectile.shootFromRotation for player throws.
pub fn shoot_from_rotation(
&mut self,
random: &mut impl RandomSource,
pitch: f32,
yaw: f32,
offset: f32,
@@ -848,7 +814,7 @@ impl Projectile {
f64::from(-o.sin()),
f64::from(y.cos() * p.cos()),
];
self.shoot_own(dir, speed, uncertainty);
self.shoot(random, dir, speed, uncertainty);
self.velocity = Vec3::new(
self.velocity.x + source_velocity.x,
self.velocity.y
+35 -66
View File
@@ -5073,7 +5073,6 @@ impl Screen {
slot,
kind,
creation,
reset: None,
..
} = &self.subscreen
{
@@ -6563,7 +6562,15 @@ impl Screen {
}
}
// `RealmsResetWorldScreen`'s tiles are drawn, not widgets: the click is a rectangle.
if let SubScreen::ResetWorld { reset, .. } = &self.subscreen {
if let SubScreen::ResetWorld {
server,
slot,
kind,
creation,
reset,
..
} = &self.subscreen
{
if reset.is_some() {
return Outcome::None;
}
@@ -6575,7 +6582,15 @@ impl Screen {
x >= tx && x < tx + 60.0 && y >= ty && y < ty + 69.0
});
if let Some(tile) = tile {
return self.click(Some(70 + tile as u8), x, y, (w, h));
let (server, slot, kind) = (server.clone(), *slot, *kind);
let creation = creation.clone();
if tile == 1 {
self.open_upload_select(server, slot, kind, creation);
} else if tile >= 2
&& let Some(group) = TemplateGroup::ALL.get(tile - 2)
{
self.open_template_list(server, slot, kind, creation, Some(*group));
}
}
return Outcome::None;
}
@@ -12666,70 +12681,24 @@ mod tests {
/// the Realm, slot and reset kind to upload into.
#[test]
fn the_generate_tile_asks_for_the_create_world_screen() {
for (size, (tx, ty)) in [
((854.0, 480.0), (305.0, 63.0)),
((400.0, 243.0), (78.0, 56.0)),
] {
for kind in [ResetKind::Reset, ResetKind::Empty, ResetKind::NewRealm] {
let creation = (kind == ResetKind::NewRealm)
.then(|| ("Realm name".to_owned(), "Description".to_owned()));
let mut screen = listed(vec![server(7, None)]);
screen.subscreen = SubScreen::ResetWorld {
server: server(7, None),
slot: 2,
kind,
creation: creation.clone(),
templates: None,
job: None,
reset: None,
error: None,
};
for (hit, (x, y)) in [
(Some(70), (0.0, 0.0)),
(None, (tx, ty)),
(None, (tx + 30.0, ty + 34.0)),
(None, (tx + 59.5, ty + 68.5)),
] {
match screen.click(hit, x, y, size) {
Outcome::CreateWorld(request) => {
assert_eq!((request.server.id, request.slot), (7, 2));
assert_eq!(request.kind, kind);
assert_eq!(request.creation, creation);
}
_ => panic!("the Generate New World tile asks for Create World"),
}
}
for (dx, dy) in [(-1.0, 0.0), (60.0, 0.0), (0.0, -1.0), (0.0, 69.0)] {
assert!(matches!(
screen.click(None, tx + dx, ty + dy, size),
Outcome::None
));
}
let abort = Arc::new(AtomicBool::new(false));
if let SubScreen::ResetWorld { reset, .. } = &mut screen.subscreen {
*reset = Some(RunningTask {
job: Arc::new(Mutex::new(None)),
title: Arc::new(Mutex::new(String::new())),
abort: Arc::clone(&abort),
});
}
for hit in [
None,
Some(70),
Some(71),
Some(72),
Some(73),
Some(74),
Some(75),
] {
assert!(matches!(screen.click(hit, tx, ty, size), Outcome::None));
assert!(matches!(
screen.subscreen,
SubScreen::ResetWorld { reset: Some(_), .. }
));
assert!(!abort.load(Ordering::Relaxed));
}
let mut screen = listed(vec![server(7, None)]);
screen.subscreen = SubScreen::ResetWorld {
server: server(7, None),
slot: 2,
kind: ResetKind::Empty,
creation: None,
templates: None,
job: None,
reset: None,
error: None,
};
match screen.click(Some(70), 0.0, 0.0, (854.0, 480.0)) {
Outcome::CreateWorld(request) => {
assert_eq!((request.server.id, request.slot), (7, 2));
assert_eq!(request.kind, ResetKind::Empty);
assert!(request.creation.is_none());
}
_ => panic!("the Generate New World tile asks for Create World"),
}
}
+4 -8
View File
@@ -635,9 +635,7 @@ impl Book {
(ns.to_lowercase(), p.to_lowercase())
};
let Some((namespace, path)) = query.split_once(':') else {
// `FullTextSearchTree.searchPlainText`: the plain-text tree only,
// not the ids (#15321).
return in_lines(&query);
return in_lines(&query) || results.iter().any(|r| id(r).1.contains(&query));
};
let (namespace, path) = (namespace.trim(), path.trim());
results.iter().any(|r| id(r).0.contains(namespace))
@@ -2309,12 +2307,10 @@ mod tests {
&*book.entries[custom_index].result.item,
"minecraft:blaze_rod"
);
// `FullTextSearchTree`: plain text searches the tooltip lines only
// (#15321); `namespace:path` also searches the result's id.
// Searching without namespace prefix matches the item path in Vanilla parity.
let lines = |_: &ItemStack| vec!["Custom Recipe".to_owned()];
assert!(book.collection_matches(&[custom_index], "custom", &lines));
assert!(!book.collection_matches(&[custom_index], "blaze", &lines));
assert!(!book.collection_matches(&[custom_index], "rod", &lines));
assert!(book.collection_matches(&[custom_index], "blaze", &lines));
assert!(book.collection_matches(&[custom_index], "rod", &lines));
assert!(book.collection_matches(&[custom_index], "minecraft:blaze", &lines));
assert!(!book.collection_matches(&[custom_index], "iron", &lines));
}
+8 -29
View File
@@ -296,14 +296,7 @@ impl Host {
for t in self.threads.drain(..) {
let _ = t.join();
}
// A panic on a sharing thread must not crash the game at stop.
if let Some(r) = self
.shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
if let Some(r) = self.shared.reach.lock().unwrap().as_mut() {
r.release();
}
}
@@ -364,27 +357,13 @@ fn share(shared: Arc<Shared>, port: u16) {
// Renew the router mapping at half-lease until stopped.
while !shared.stopped() {
std::thread::sleep(Duration::from_millis(100));
// Renewed without the lock: the router may be slow, and stop() waits for this thread
// anyway before it releases the mapping, so it is always back by then.
let due = shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
.and_then(|r| r.mapping.take_if(|m| m.due()));
if let Some(mut m) = due {
if m.renew().is_err() {
// Try again in a minute rather than hammering the router.
m.renewed = Instant::now() - m.lifetime / 2 + Duration::from_secs(60);
}
if let Some(r) = shared
.reach
.lock()
.unwrap_or_else(|e| e.into_inner())
.as_mut()
{
r.mapping = Some(m);
}
let mut reach = shared.reach.lock().unwrap();
if let Some(m) = reach.as_mut().and_then(|r| r.mapping.as_mut())
&& m.due()
&& m.renew().is_err()
{
// Try again in a minute rather than hammering the router.
m.renewed = Instant::now() - m.lifetime / 2 + Duration::from_secs(60);
}
}
if let Some(t) = relay {
+4 -86
View File
@@ -434,12 +434,6 @@ fn split_url(url: &str) -> Option<(String, u16, String)> {
))
}
/// How long a router has for its whole HTTP answer.
const HTTP_DEADLINE: Duration = Duration::from_secs(10);
/// Gateways tried from one discovery: anyone on the LAN can answer SSDP, each with its own.
const MAX_GATEWAYS: usize = 8;
/// A tiny HTTP/1.1 client (Connection: close; Content-Length, chunked or
/// to the end).
fn http(
@@ -447,16 +441,6 @@ fn http(
url: &str,
headers: &[(&str, &str)],
body: &str,
) -> io::Result<(u16, String)> {
http_within(method, url, headers, body, HTTP_DEADLINE)
}
fn http_within(
method: &str,
url: &str,
headers: &[(&str, &str)],
body: &str,
within: Duration,
) -> io::Result<(u16, String)> {
let (host, port, path) = split_url(url).ok_or_else(|| io::Error::other("bad URL"))?;
let addr = (host.as_str(), port)
@@ -475,39 +459,8 @@ fn http_within(
req.push_str("\r\n");
req.push_str(body);
s.write_all(req.as_bytes())?;
// One deadline for the whole answer: a per-read timeout alone let a device sending a byte every
// few seconds hold the request (and the lock or the stop waiting on it) for weeks.
let deadline = Instant::now() + within;
let mut raw = Vec::new();
let mut chunk = [0u8; 8192];
loop {
let left = deadline
.checked_duration_since(Instant::now())
.filter(|d| !d.is_zero())
.ok_or_else(|| io::Error::new(io::ErrorKind::TimedOut, "router too slow"))?;
s.set_read_timeout(Some(left.min(Duration::from_secs(5))))?;
// A read that times out at the deadline is the router being too slow, whatever kind the
// platform reports it as (Linux: WouldBlock).
let n = match s.read(&mut chunk) {
Ok(n) => n,
Err(e)
if matches!(
e.kind(),
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
) =>
{
return Err(io::Error::new(io::ErrorKind::TimedOut, "router too slow"));
}
Err(e) => return Err(e),
};
if n == 0 {
break;
}
raw.extend_from_slice(&chunk[..n]);
if raw.len() > 1 << 20 {
return Err(io::Error::other("router answer too big"));
}
}
s.take(1 << 20).read_to_end(&mut raw)?;
let text = String::from_utf8_lossy(&raw).into_owned();
let (head, rest) = text
.split_once("\r\n\r\n")
@@ -534,16 +487,11 @@ fn dechunk(mut s: &str) -> String {
let Ok(n) = usize::from_str_radix(size.split(';').next().unwrap_or("").trim(), 16) else {
break;
};
// `n` is the device's byte count: a chunk ending inside a character (the text went through
// `from_utf8_lossy`) or past the end stops here rather than panicking.
let (Some(chunk), Some(next)) = (rest.get(..n), rest.get(n..)) else {
break;
};
if n == 0 {
if n == 0 || rest.len() < n {
break;
}
out.push_str(chunk);
s = next.trim_start_matches("\r\n");
out.push_str(&rest[..n]);
s = rest[n..].trim_start_matches("\r\n");
}
out
}
@@ -591,7 +539,6 @@ pub fn ssdp_discover(timeout: Duration) -> Vec<String> {
if let Ok((n, _)) = s.recv_from(&mut buf)
&& let Some(l) = ssdp_location(&String::from_utf8_lossy(&buf[..n]))
&& !found.contains(&l)
&& found.len() < MAX_GATEWAYS
{
found.push(l);
}
@@ -803,35 +750,6 @@ pub fn windows_gateway(text: &str) -> Option<Ipv4Addr> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_chunk_ending_inside_a_character_stops_the_body() {
// "é" is two bytes: a one-byte chunk ends inside it.
assert_eq!(dechunk("2\r\nab\r\n1\r\né\r\n0\r\n\r\n"), "ab");
assert_eq!(dechunk("2\r\nab\r\n9\r\nshort"), "ab");
assert_eq!(dechunk("3\r\nabc\r\n2\r\né\r\n0\r\n\r\n"), "abcé");
}
#[test]
fn a_router_trickling_its_answer_runs_out_of_time() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}/desc.xml", listener.local_addr().unwrap());
std::thread::spawn(move || {
let (mut s, _) = listener.accept().unwrap();
let _ = s.read(&mut [0; 1024]);
// Each byte well within the per-read timeout.
for b in b"HTTP/1.1 200 OK\r\n\r\n".iter().cycle().take(200) {
if s.write_all(&[*b]).is_err() {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
});
let started = Instant::now();
let err = http_within("GET", &url, &[], "", Duration::from_millis(300)).unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
assert!(started.elapsed() < Duration::from_secs(2));
}
use std::{
collections::HashMap,
net::TcpListener,
-75
View File
@@ -1278,28 +1278,6 @@ impl Items {
}
self.get(&stack.item)?.tool_damage_per_block
}
/// `Weapon.disableBlockingForSeconds`: the stack's `weapon` component,
/// else the item's prototype; zero without one.
pub fn weapon_disable_blocking(&self, stack: &ItemStack) -> f32 {
if stack.count == 0 || &*stack.item == "minecraft:air" {
return 0.0;
}
if stack
.data()
.is_some_and(|d| d.other("!minecraft:weapon").is_some())
{
return 0.0;
}
if let Some(weapon) = stack.data().and_then(|d| d.other("minecraft:weapon")) {
return horizon_world::datafix::dynamic::as_float(
weapon.get("disable_blocking_for_seconds"),
0.0,
);
}
self.get(&stack.item)
.map_or(0.0, |i| i.disable_blocking_seconds)
}
/// `Weapon.itemDamagePerAttack`: the stack's `weapon` component, else the
/// item's prototype. A removed component leaves no weapon.
pub fn weapon_damage(&self, stack: &ItemStack) -> Option<u32> {
@@ -2978,59 +2956,6 @@ mod tests {
assert_eq!(items.weapon_damage(&sword), None);
}
#[test]
fn weapon_disable_blocking_comes_from_the_stack() {
let root = std::env::temp_dir().join(format!(
"horizon-weapon-disable-blocking-{}",
std::process::id()
));
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("items.json"),
r#"{"minecraft:stick":{},"minecraft:diamond_axe":{"minecraft:weapon":{"disable_blocking_for_seconds":5.0}}}"#,
)
.unwrap();
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
let mut axe = ItemStack::new("minecraft:diamond_axe", 1);
assert_eq!(items.weapon_disable_blocking(&axe), 5.0);
for item in ["minecraft:stick", "test:unknown", "minecraft:air"] {
assert_eq!(items.weapon_disable_blocking(&ItemStack::new(item, 1)), 0.0);
}
// A replacement component uses the codec's zero default, not the axe's.
let empty = crate::item_data::command_components("[weapon={}]").unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&empty));
assert_eq!(items.weapon_disable_blocking(&axe), 0.0);
let custom =
crate::item_data::command_components("[weapon={disable_blocking_for_seconds:1.5f}]")
.unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&custom));
assert_eq!(items.weapon_disable_blocking(&axe), 1.5);
let mut stick = ItemStack::new("minecraft:stick", 1);
(stick.damage, stick.extra) = crate::item_data::decode_components(Some(&custom));
assert_eq!(items.weapon_disable_blocking(&stick), 1.5);
stick.count = 0;
assert_eq!(items.weapon_disable_blocking(&stick), 0.0);
stick.count = 1;
stick.item = "minecraft:air".into();
assert_eq!(items.weapon_disable_blocking(&stick), 0.0);
axe.data_mut().set_other(
"!minecraft:weapon",
Some(horizon_world::nbt::Tag::compound(Vec::<(
&str,
horizon_world::nbt::Tag,
)>::new())),
);
assert_eq!(items.weapon_disable_blocking(&axe), 0.0);
let removal = crate::item_data::command_components("[!weapon]").unwrap();
(axe.damage, axe.extra) = crate::item_data::decode_components(Some(&removal));
let restored = crate::item_nbt::item_from_nbt(&crate::item_nbt::item_to_nbt(&axe)).unwrap();
assert_eq!(items.weapon_disable_blocking(&restored), 0.0);
fs::remove_dir_all(root).unwrap();
}
#[test]
fn loot_table_ids_list_the_roots_and_the_packs_tables() {
let root = std::env::temp_dir().join(format!("horizon-loot-ids-{}", std::process::id()));
+1
View File
@@ -7,6 +7,7 @@ pub mod boot_paint;
pub mod heap_census;
pub mod jobs;
pub mod process_memory;
pub mod stack_dump;
use std::sync::{
OnceLock,
+1 -25
View File
@@ -62,32 +62,8 @@ pub fn resident_bytes() -> Option<u64> {
Some(pages * u64::try_from(page).ok()?)
}
/// The memory this process holds in RAM, in bytes. iOS has no `proc_pidinfo` in libc: the Mach
/// task's basic info instead.
#[cfg(target_os = "ios")]
#[allow(deprecated)]
pub fn resident_bytes() -> Option<u64> {
unsafe extern "C" {
// libc declares it for macOS only.
static mach_task_self_: libc::mach_port_t;
}
let mut count = libc::MACH_TASK_BASIC_INFO_COUNT;
// SAFETY: all-zero is a valid value of this plain C struct; task_info writes at most `count`
// natural_t words into it.
unsafe {
let mut info: libc::mach_task_basic_info = std::mem::zeroed();
let ok = libc::task_info(
mach_task_self_,
libc::MACH_TASK_BASIC_INFO,
(&raw mut info).cast(),
&raw mut count,
);
(ok == libc::KERN_SUCCESS).then_some(info.resident_size)
}
}
/// The memory this process holds in RAM, in bytes.
#[cfg(all(target_vendor = "apple", not(target_os = "ios")))]
#[cfg(target_vendor = "apple")]
pub fn resident_bytes() -> Option<u64> {
let size = std::mem::size_of::<libc::proc_taskinfo>() as libc::c_int;
// SAFETY: all-zero is a valid value of this plain C struct, and the call writes at most `size`
+240
View File
@@ -0,0 +1,240 @@
//! Another thread's stack, read from outside it: the server watchdog's crash report says what
//! the hung tick was doing, as vanilla's `ServerWatchdog` dumps the server thread's stack (#15349).
//!
//! On Linux, Android and Apple systems a signal interrupts the watched thread and its handler
//! captures that thread's own backtrace. The handler allocates, which a signal handler should not
//! do: ask only right before the process ends, as the watchdog does. A thread that never runs the
//! handler (stuck in the kernel, or deadlocked inside the handler) leaves the asker without an
//! answer once its wait is over.
use std::time::Duration;
/// A thread whose stack another thread can read ([`Watched::stack`]).
#[derive(Clone, Copy, Debug)]
pub struct Watched {
#[cfg_attr(
not(any(target_vendor = "apple", target_os = "linux", target_os = "android")),
allow(dead_code)
)]
thread: usize,
}
/// Why [`Watched::stack`] has no stack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NoStack {
/// This system can't interrupt another thread to read its stack.
Unsupported,
/// The thread did not answer within the wait (blocked in the kernel, for one).
NoAnswer,
}
impl std::fmt::Display for NoStack {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
NoStack::Unsupported => "not available on this system",
NoStack::NoAnswer => "the thread did not answer",
})
}
}
impl Watched {
/// The calling thread. It must still run when [`Watched::stack`] is asked.
pub fn current() -> Self {
Self {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
thread: unsafe { libc::pthread_self() } as usize,
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
thread: 0,
}
}
/// The thread's stack as `std::backtrace::Backtrace` prints it, starting at the frame the
/// thread was in (on Apple's arm64, at its caller), waiting at most `wait` for it.
pub fn stack(&self, wait: Duration) -> Result<String, NoStack> {
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
{
imp::stack(self.thread, wait).map(|b| after_handler(&b.to_string()))
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
{
let _ = wait;
Err(NoStack::Unsupported)
}
}
}
/// A backtrace's text without the frames the signal added: the capture's, the handler's and the
/// signal trampoline's, the one frame the system puts between the handler and the interrupted
/// code (named `__restore_rt`, `_sigtramp`, or after whatever symbol precedes it). Unchanged when
/// the handler's frame isn't found.
fn after_handler(text: &str) -> String {
fn frame_name(line: &str) -> Option<&str> {
let (n, name) = line.trim().split_once(": ")?;
n.chars().all(|c| c.is_ascii_digit()).then_some(name)
}
let lines: Vec<&str> = text.lines().collect();
let frames: Vec<usize> = (0..lines.len())
.filter(|&i| frame_name(lines[i]).is_some())
.collect();
let Some(handler) = frames.iter().position(|&i| {
frame_name(lines[i]).is_some_and(|n| n == "on_signal" || n.ends_with("::on_signal"))
}) else {
return text.to_owned();
};
let start = frames.get(handler + 2).copied().unwrap_or(lines.len());
lines[start..].iter().map(|l| format!("{l}\n")).collect()
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
mod imp {
use super::NoStack;
use std::{
backtrace::Backtrace,
sync::{
Mutex, OnceLock,
atomic::{AtomicPtr, Ordering},
},
time::{Duration, Instant},
};
/// Ignored when no handler is installed, and used by nothing else here (Go's runtime takes it
/// for the same reason): a stray one does nothing.
const SIGNAL: libc::c_int = libc::SIGURG;
/// The handler's answer, taken by the asker.
static ANSWER: AtomicPtr<Backtrace> = AtomicPtr::new(std::ptr::null_mut());
/// One question at a time.
static ASKING: Mutex<()> = Mutex::new(());
extern "C" fn on_signal(_: libc::c_int) {
let stack = Box::into_raw(Box::new(Backtrace::force_capture()));
// An answer nobody took (its asker gave up) is left as it is: freeing here could be
// the allocation that goes wrong.
ANSWER.store(stack, Ordering::Release);
}
fn installed() -> bool {
static INSTALLED: OnceLock<bool> = OnceLock::new();
*INSTALLED.get_or_init(|| unsafe {
let mut action: libc::sigaction = std::mem::zeroed();
action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
action.sa_flags = libc::SA_RESTART;
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(SIGNAL, &action, std::ptr::null_mut()) == 0
})
}
fn take() -> Option<Backtrace> {
let answer = ANSWER.swap(std::ptr::null_mut(), Ordering::Acquire);
// SAFETY: only `on_signal` stores, a pointer from `Box::into_raw`, and the swap takes it once.
(!answer.is_null()).then(|| *unsafe { Box::from_raw(answer) })
}
pub(super) fn stack(thread: usize, wait: Duration) -> Result<Backtrace, NoStack> {
let _one = ASKING.lock().unwrap_or_else(|e| e.into_inner());
if !installed() {
return Err(NoStack::Unsupported);
}
// A late answer to an earlier question.
drop(take());
// SAFETY: `Watched::current`'s contract keeps the thread alive.
if unsafe { libc::pthread_kill(thread as libc::pthread_t, SIGNAL) } != 0 {
return Err(NoStack::NoAnswer);
}
let until = Instant::now() + wait;
loop {
if let Some(stack) = take() {
return Ok(stack);
}
if Instant::now() >= until {
return Err(NoStack::NoAnswer);
}
std::thread::sleep(Duration::from_millis(2));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
};
#[inline(never)]
fn spin(mut n: u64) -> u64 {
for _ in 0..1_000_000 {
n = std::hint::black_box(n.wrapping_mul(31).wrapping_add(7));
}
n
}
/// Its time goes to a call: Apple's arm64 unwinder loses the frame the signal interrupted
/// (it resumes from the return address), while every caller's frame stays.
#[inline(never)]
fn busy_in_a_named_frame(stop: &AtomicBool) {
let mut n = 0u64;
while !stop.load(Ordering::Relaxed) {
n = std::hint::black_box(spin)(n);
}
}
#[cfg(any(target_vendor = "apple", target_os = "linux", target_os = "android"))]
#[test]
fn reads_the_stack_of_a_busy_thread_from_another() {
let stop = Arc::new(AtomicBool::new(false));
let (tx, rx) = mpsc::channel();
let worker = {
let stop = stop.clone();
std::thread::spawn(move || {
tx.send(Watched::current()).unwrap();
busy_in_a_named_frame(&stop);
})
};
let watched = rx.recv().unwrap();
let stack = watched.stack(Duration::from_secs(10));
stop.store(true, Ordering::Relaxed);
worker.join().unwrap();
let stack = stack.unwrap();
let busy = stack.find("busy_in_a_named_frame");
assert!(
busy.is_some(),
"the busy thread's frame is in its stack:\n{stack}"
);
assert!(
!stack[..busy.unwrap()].contains("on_signal") && !stack.contains("Backtrace"),
"the stack starts where the thread was, not in the signal handler:\n{stack}"
);
}
#[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))]
#[test]
fn says_when_the_system_cannot_read_a_stack() {
assert_eq!(
Watched::current().stack(Duration::from_millis(10)),
Err(NoStack::Unsupported)
);
}
#[test]
fn drops_the_capture_handler_and_trampoline_frames() {
let text = " 0: std::backtrace::Backtrace::force_capture
at /rustc/library/std/src/backtrace.rs:312:9
1: horizon_threads::stack_dump::imp::on_signal
at ./src/stack_dump.rs:120:40
2: __os_lock_spin_lock
3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
";
assert_eq!(
after_handler(text),
" 3: horizon_client::game::Game::tick_server
at ./src/game/levels.rs:10:5
4: main
"
);
}
}
+5 -4
View File
@@ -240,10 +240,11 @@ From preview on, stability.md's deprecation windows apply.
## `shaders/`-format packs
They keep their own buffers and tone map to SDR in their last program, and their alpha is theirs. Their `final`
program writes all four channels onto the frame (`gfx/pack_post.rs` `target`), and `keep_world` copies that alpha
into the kept world, but the HDR pass doesn't read it as marks: their picture is shown at paper white with nothing
lifted, as #3125 suggests. HDR values they could read (`hdr`, headroom,
They keep their own buffers and tone map to SDR in their last program, and their alpha is theirs. Today their
`final` program writes all four channels onto the frame (`gfx/pack_post.rs` `target`), and `keep_world` copies that
alpha into the kept world. With HDR and its HDR Lighting setting on, the HDR pass reads it as marks: below 0.75 it
lifts as a light-source tier, from 0.75 up nothing (the lighting gain is off under a pack). From this note on their
picture is shown at paper white with nothing lifted, as #3125 suggests. HDR values they could read (`hdr`, headroom,
paper white) are a later, optional addition for pack authors.
## Upscaling, anti-aliasing, frame generation
+1 -1
View File
@@ -45,7 +45,7 @@ Every setting applies at once, except worker threads, the graphics API and trans
**Translucent batching** (Advanced, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. Under a `shaders/` pack nothing is: the alpha its last program leaves is its own, and its picture is shown at paper white. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider; its peak Line truncated
**HDR** has its own page and three choices: **Off** (the default) keeps SDR; **On** uses HDR wherever the display path offers it; **Auto** uses HDR only while the system reports HDR active for the window's display and an HDR output is offered, otherwise SDR. On is unavailable where no HDR output is offered; Auto stays selectable before the system is queried, including on an SDR display. The controls below work only while HDR is active. The existing `hdr` key in `horizon-graphics.txt` saves On as `true`, Off as `false`, and Auto as `auto`; old boolean files keep their meaning, and older builds ignore `auto` and keep their Off default. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one, and offers it to an `--offscreen` run, which has no display to ask: there HDR is drawn and screenshotted (the `-hdr.png` below) but not presented, and automatic brightness takes 203 and 1000 nits, so HDR can be checked without an HDR display. The frame is drawn exactly as in SDR, in vanilla's gamma space (into a 16-bit target (`Rgba16Unorm`, which clips at white after every pass as the 8-bit one does) when HDR is on as the renderer is made, at the start or a resource reload, with **Smooth Gradients** on (the default, `hdrSmoothGradients`; off, the 8-bit frame) and where the device can draw into it, else an 8-bit one; HDR switched off after that keeps the 16-bit frame and copies it to the 8-bit swapchain, without frame generation, until the next start; with `HORIZON_HDR_FRAME=float` the frame follows HDR, Smooth Gradients and the picked pack unless it was refused, is missing or its owner mod is off (the renderer is made again on the same device before the next frame, and the world meshed again), and with no shader pack or a 0.2 one, where the device can keep the HUD on a layer of its own, a float one (`Rgba16Float`) takes the 16-bit one's place whatever Smooth Gradients is: the world's shaders write light past white into it themselves, and it holds no marks (below)), and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL and mastering luminance at the peak, MaxFALL as 0, unknown, since a frame's average isn't known ahead of it), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. Set by hand, on the slider and in calibration alike, Paper White runs from 10 to 480 nits by 10, and Peak Brightness from 200 to 2000 nits by 50, where most displays' peaks are, then by 500 up to PQ's 10 000, for displays that are brighter or tone-map the signal themselves; `hdrPeak` keeps any value from 200 to 10 000. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), watched while HDR is On or Auto (Off does not ask the system); the preferred target peak above reference white reports HDR headroom, so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; the same reading's current output colour-space state drives Auto (an offered Direct3D 12 HDR10 swapchain alone does not). macOS reports potential EDR headroom, fixed for a display rather than following desktop brightness: Auto uses it above 1, or stays SDR if it is missing or invalid. macOS reports no absolute nits or separate HDR-mode flag. X11, OpenGL, other paths without a report and offscreen runs keep Auto in SDR. Windows' SDR white is the player's own slider; its peak is the display's own figure, or the active calibration profile's where the player ran Windows HDR Calibration, and the readLine truncated
With `HORIZON_HDR_FRAME=float`, HDR on and no shader pack or a 0.2 one, devices with independent blending draw the frame in layers ([hdr-linear-light.md](design/hdr-linear-light.md), The HUD and menus). The world's shaders write their own light into the frame instead of a mark (`hdr_written`, `hdr_light.wgsl`), a whole frame at a time, so glass, water, clouds and particles each lay their own light over the light behind them. Improved Transparency's layers write their own light too, before they are weighted and composited. No float frame holds marks: under a 0.2 pack Horizon's own draws keep their colour, alpha 1 where opaque and coverage where blended, HDR output lifts nothing, and the pack's `[tonemap]` takes the tone curve's place below (one or the other runs, never both); without the HUD layer (HDR off, or a device without independent blending) its float frame finishes on the swapchain's 8 bits before the camera overlays, which then draw with the HUD as in SDR, and HDR output stays off. The camera overlays (vignette, pumpkin, powder snow, portal, nausea), post effects and entity outlines draw on light. After the post effects and before the camera overlays, the tone curve eases that light into the peak (`Hdr::tonemap`) in place of HDR lighting's own easing and the light sources' cap at the peak: up to 60% of the peak light stays as it is, and above it ITU-R BT.2390's EETF in PQ rolls it off, on its brightest channel so it keeps its hue. The HUD and menus then draw on an 8-bit target of their own, the world clipped to white, as they found the frame before (each draw clips at white as there), and write how much of the world they hide into an R8 coverage target. The last pass lays the HUD over the world's light by that coverage, in gamma as the HUD blends: light behind see-through HUD shows as vanilla weights the HUD over it, and none shows through what covers it. The menu blur blurs that picture, light above white included, and the menu draws over the result. UI Brightness scales the HUD's own part, not the world under it. A screenshot maps the world's light into SDR, unchanged up to 0.6 of paper white in linear light and smoothly rolled towards white above it, with one scale for all RGB channels to keep their hue (where Horizon lifts nothing, with HDR Lighting off or under a 0.2 pack, the world is clipped at white as SDR draws it); the HUD's own part stays as drawn, whatever UI Brightness is, over the mapped world by its coverage. The world's icon uses the same mapping without the HUD; the `-hdr.png` beside a screenshot keeps the whole HDR picture. `HORIZON_HDR_DEBUG=coverage` tints the coverage magenta for inspection.
-161
View File
@@ -1,161 +0,0 @@
"""anvil_dump prints NBT as JSON; given no file it asks for one instead of crashing."""
import gzip
import json
import struct
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
TOOL = Path(__file__).resolve().parents[1] / "tools" / "anvil_dump.py"
def tag_string(text: str) -> bytes:
data = text.encode("utf-8")
return struct.pack(">H", len(data)) + data
def entry(tag_id: int, name: str, payload: bytes) -> bytes:
return struct.pack(">B", tag_id) + tag_string(name) + payload
def compound(entries: list[bytes]) -> bytes:
"""A TAG_Compound payload: its entries, then the TAG_End that closes it."""
return b"".join(entries) + b"\x00"
def named_root(entries: list[bytes]) -> bytes:
"""A named root compound, the shape a level.dat has."""
return entry(10, "", compound(entries))
LEVEL = named_root([
entry(8, "LevelName", tag_string("Horizon")),
entry(1, "Difficulty", struct.pack(">b", 2)),
entry(3, "Version", struct.pack(">i", 42)),
entry(4, "Seed", struct.pack(">q", 1234567890123)),
entry(9, "Spawn", struct.pack(">bi", 6, 2) + struct.pack(">dd", 1.5, -2.25)),
entry(7, "Bytes", struct.pack(">i", 10) + bytes(range(10))),
])
CHUNK = named_root([
entry(3, "xPos", struct.pack(">i", 0)),
entry(3, "zPos", struct.pack(">i", 0)),
entry(8, "Status", tag_string("minecraft:full")),
])
def region(chunks: dict[tuple[int, int], bytes]) -> bytes:
"""An Anvil region file with the given chunks, one sector each."""
header = bytearray(4096)
blocks = b""
for index, (position, chunk) in enumerate(sorted(chunks.items())):
cx, cz = position
sector = 2 + index
header[(cx + cz * 32) * 4:(cx + cz * 32) * 4 + 3] = sector.to_bytes(3, "big")
payload = gzip.compress(chunk)
blocks += struct.pack(">i", len(payload) + 1) + bytes([1]) + payload
blocks += b"\x00" * (4096 - len(blocks) % 4096)
return bytes(header) + b"\x00" * 4096 + blocks
def run(folder: Path, *arguments: str) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(TOOL), *arguments],
cwd=folder, text=True, capture_output=True)
class UsageTests(unittest.TestCase):
def test_help_prints_the_usage_and_exits_zero(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder), "--help")
left = sorted(path.name for path in Path(folder).iterdir())
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout)
for option in ("--full", "--typed", "--help"):
self.assertIn(option, result.stdout, "the usage does not document " + option)
self.assertEqual(left, [], "the tool wrote files while being asked for help")
def test_short_help_prints_the_usage_and_exits_zero(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder), "-h")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout)
def test_without_a_file_it_asks_for_one(self):
with tempfile.TemporaryDirectory() as folder:
result = run(Path(folder))
left = sorted(path.name for path in Path(folder).iterdir())
self.assertNotEqual(result.returncode, 0, "it reported success without a file")
self.assertNotIn("Traceback", result.stderr)
self.assertIn("Usage", result.stdout + result.stderr)
self.assertEqual(left, [], "the tool wrote files without being told where")
class DumpTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory()
self.addCleanup(self.folder.cleanup)
self.path = Path(self.folder.name)
(self.path / "level.dat").write_bytes(LEVEL)
def test_it_dumps_a_dat_file_as_json(self):
result = run(self.path, "level.dat")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), {
"LevelName": "Horizon",
"Difficulty": 2,
"Version": 42,
"Seed": 1234567890123,
"Spawn": [1.5, -2.25],
"Bytes": "B[10: [0, 1, 2, 3]...]",
})
def test_a_path_selects_one_value(self):
result = run(self.path, "level.dat", "LevelName")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "Horizon")
def test_a_path_walks_into_a_list(self):
result = run(self.path, "level.dat", "Spawn", "1")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), -2.25)
def test_typed_numbers_carry_their_nbt_type(self):
result = run(self.path, "level.dat", "Difficulty", "--typed")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "2b")
def test_full_prints_long_arrays_whole(self):
result = run(self.path, "level.dat", "Bytes", "--full")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), list(range(10)))
def test_a_gzipped_dat_reads_the_same(self):
(self.path / "zipped.dat").write_bytes(gzip.compress(LEVEL))
result = run(self.path, "zipped.dat", "LevelName")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), "Horizon")
def test_a_region_file_lists_its_chunks(self):
(self.path / "r.0.0.mca").write_bytes(region({(0, 0): CHUNK}))
result = run(self.path, "r.0.0.mca")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), [[0, 0]])
def test_a_region_file_dumps_a_chunk(self):
(self.path / "r.0.0.mca").write_bytes(region({(0, 0): CHUNK}))
result = run(self.path, "r.0.0.mca", "0", "0")
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(json.loads(result.stdout), {
"xPos": 0,
"zPos": 0,
"Status": "minecraft:full",
})
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -5,7 +5,7 @@ import subprocess
import tempfile
import unittest
from test_world_input_capture import method
from tests.test_world_input_capture import method
ROOT = Path(__file__).resolve().parents[1]
-26
View File
@@ -150,32 +150,6 @@ class CommandLineTests(unittest.TestCase):
self.assertIn("GAME es_es ", out)
self.assertIn("LAUNCHER es ", out)
def test_show_keys_is_not_dropped_when_a_mode_also_prints_lines(self):
with tempfile.TemporaryDirectory() as tmp:
folder = Path(tmp)
write(folder, "en_us.json", '{\n "a": "Apple",\n "b": "Banana"\n}\n')
write(folder, "xx.json", '{\n "a": "Manzana",\n "c": "Datil"\n}\n')
common = ("--game-dir", tmp, "--game-only")
code, summary, _ = run(*common, "--summary")
code, with_keys, _ = run(*common, "--summary", "--show-keys")
code, unreviewed, _ = run(*common, "--unreviewed")
code, unreviewed_keys, _ = run(*common, "--unreviewed", "--show-keys")
code, payload, _ = run(*common, "--json", "--show-keys")
self.assertEqual(code, 0)
# the summary keeps its line per file, and the keys behind the counts follow it
self.assertIn("GAME xx 2/2 keys, missing 1, extra 1", summary)
self.assertIn("GAME xx 2/2 keys, missing 1, extra 1", with_keys)
self.assertNotIn("xx: missing (1)", summary)
self.assertIn("xx: missing (1)", with_keys)
self.assertIn(" b", with_keys)
self.assertIn("xx: not in the reference (1)", with_keys)
self.assertIn(" c", with_keys)
self.assertNotEqual(summary, with_keys)
# the unreviewed listing says the same, and the json stays a json
self.assertNotEqual(unreviewed, unreviewed_keys)
self.assertIn("xx: missing (1)", unreviewed_keys)
self.assertEqual(by_code(json.loads(payload)["game"]["files"], "xx")["missing"], ["b"])
class ReviewTests(unittest.TestCase):
"""The record of what a person checked: anything else, or anything changed since, is unreviewed."""
-68
View File
@@ -1,68 +0,0 @@
"""tools/ci/legacy_releases.py: the pinned SHA-256s of the releases published before signing began."""
import unittest
from tools.ci import legacy_releases as legacy
def release(tag, assets, draft=False):
return {"tag_name": tag, "draft": draft, "assets": assets}
def asset(name, digest="sha256:" + "ab" * 32):
return {"name": name, "digest": digest}
class TagKeyTests(unittest.TestCase):
def test_the_numbers_order_by_value_not_by_spelling(self):
self.assertLess(legacy.tag_key("v0.2.9"), legacy.tag_key("v0.2.10"))
self.assertLess(legacy.tag_key("v0.9.0"), legacy.tag_key("v0.10.0"))
def test_a_game_version_comes_before_a_launcher_version(self):
# A game tag starts with "v"; a launcher tag is "[email protected]".
self.assertLess(legacy.tag_key("v1.0.0"), legacy.tag_key("[email protected]"))
def test_the_launcher_versions_keep_their_own_order(self):
self.assertLess(legacy.tag_key("[email protected]"), legacy.tag_key("[email protected]"))
class EntriesTests(unittest.TestCase):
def test_a_draft_is_left_out(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz")], draft=True)])
self.assertEqual(lines, [])
def test_a_signed_release_is_left_out(self):
signed = release("v1.0.0", [asset("horizon.tar.gz"), asset("SHA256SUMS.sig")])
self.assertEqual(legacy.entries([signed]), [])
def test_an_unsigned_release_keeps_its_digest(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz")])])
self.assertEqual(lines, [f"v1.0.0 horizon.tar.gz {'ab' * 32}"])
def test_the_digest_is_written_without_its_prefix(self):
lines = legacy.entries([release("v1.0.0", [asset("horizon.tar.gz", "sha256:" + "CD" * 32)])])
self.assertEqual(lines, [f"v1.0.0 horizon.tar.gz {'cd' * 32}"])
def test_an_asset_without_a_digest_is_refused(self):
with self.assertRaises(SystemExit):
legacy.entries([release("v1.0.0", [asset("horizon.tar.gz", "")])])
def test_the_releases_come_out_in_order(self):
releases = [release("v0.2.10", [asset("a")]), release("v0.2.9", [asset("b")])]
self.assertEqual([line.split()[0] for line in legacy.entries(releases)], ["v0.2.9", "v0.2.10"])
class RenderTests(unittest.TestCase):
def test_the_header_names_the_file_and_its_writer(self):
text = legacy.render([])
self.assertTrue(text.startswith("# Releases published before signing began"))
self.assertIn("tools/ci/legacy_releases.py", text)
def test_the_header_and_the_lines_are_both_written(self):
text = legacy.render([release("v1.0.0", [asset("horizon.tar.gz")])])
self.assertTrue(text.startswith(legacy.HEADER))
self.assertIn("v1.0.0 horizon.tar.gz", text)
self.assertTrue(text.endswith("\n"))
if __name__ == "__main__":
unittest.main()
+1 -1
View File
@@ -7,7 +7,7 @@ from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from unittest import mock
from test_class_index import Pool
from tests.test_class_index import Pool
from tools import port_ledger
-97
View File
@@ -1,97 +0,0 @@
"""tools/ci/resolve_reviewer_ids.py: the numeric GitHub IDs the comment commands decide by."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from tools.ci import resolve_reviewer_ids as resolve
class ReviewerListTests(unittest.TestCase):
"""The check reads the list and the owner's ID; it never writes them."""
def _in(self, list_text, nightly_text, accounts=None):
accounts = accounts or {}
with tempfile.TemporaryDirectory() as folder, \
mock.patch.multiple(resolve, LIST=Path(folder, "reviewers.txt"),
NIGHTLY_SET=Path(folder, "nightly_set.py")), \
mock.patch.object(resolve, "user", side_effect=lambda ref: accounts.get(ref)):
Path(folder, "reviewers.txt").write_text(list_text, encoding="utf-8")
Path(folder, "nightly_set.py").write_text(nightly_text, encoding="utf-8")
errors = []
with mock.patch.object(resolve, "print", create=True):
code = resolve.check()
return code
OWNER = 'OWNER = "ludovic111"\nOWNER_ID: int | None = 136255825\n'
def test_a_list_of_ids_and_logins_is_accepted(self):
code = self._in("136255825 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 0)
def test_a_line_without_an_id_is_reported(self):
code = self._in("ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
def test_an_id_that_belongs_to_another_login_is_reported(self):
code = self._in("136255825 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "someone-else", "type": "User"}})
self.assertEqual(code, 1)
def test_an_id_github_does_not_know_is_reported(self):
code = self._in("999999999 ludovic111\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
def test_the_paused_line_is_not_a_problem(self):
code = self._in("paused\n", self.OWNER,
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 0)
def test_an_owner_id_that_is_not_set_is_reported(self):
code = self._in("136255825 ludovic111\n", 'OWNER = "ludovic111"\n',
{"user/136255825": {"id": 136255825, "login": "ludovic111", "type": "User"}})
self.assertEqual(code, 1)
class OwnerLoginTests(unittest.TestCase):
def test_the_owner_is_read_from_the_nightly_set(self):
with tempfile.TemporaryDirectory() as folder, \
mock.patch.object(resolve, "NIGHTLY_SET", Path(folder, "nightly_set.py")):
Path(folder, "nightly_set.py").write_text('OWNER = "ludovic111"\n', encoding="utf-8")
self.assertEqual(resolve.owner_login(), "ludovic111")
def test_a_nightly_set_without_an_owner_is_refused(self):
with tempfile.TemporaryDirectory() as folder, \
mock.patch.object(resolve, "NIGHTLY_SET", Path(folder, "nightly_set.py")):
Path(folder, "nightly_set.py").write_text("# nothing here\n", encoding="utf-8")
with self.assertRaises(SystemExit):
resolve.owner_login()
class UserTests(unittest.TestCase):
"""The lookup asks GitHub for a login or for a numeric ID."""
def test_a_login_is_asked_for_by_name(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stdout='{"id": 1, "login": "ludovic111", "type": "User"}')
found = resolve.user("ludovic111")
self.assertEqual(found["login"], "ludovic111")
self.assertIn("users/ludovic111", run.call_args.args[0])
def test_an_id_is_asked_for_by_number(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=0, stdout='{"id": 1, "login": "ludovic111", "type": "User"}')
resolve.user("user/1")
self.assertIn("user/1", run.call_args.args[0])
def test_an_account_github_does_not_know_is_none(self):
with mock.patch.object(resolve.subprocess, "run") as run:
run.return_value = mock.Mock(returncode=1, stdout="")
self.assertIsNone(resolve.user("nobody"))
if __name__ == "__main__":
unittest.main()
+5 -15
View File
@@ -2,18 +2,13 @@
"""Dump Minecraft NBT files (level.dat, data/*.dat, players/*.dat) and Anvil
region chunks (region/, entities/, poi/) as readable SNBT-like text.
Usage:
anvil_dump.py FILE.dat [PATH...]
anvil_dump.py r.0.0.mca [CX CZ] [PATH...]
PATH selects nested keys (for example `sections 3 block_states`).
Options:
--full print long arrays whole instead of summarising them
--typed suffix numbers with their NBT type (b, s, l, f, d; plain ints are TAG_Int)
-h, --help print this help and exit
Used to compare Horizon saves with worlds written by the vanilla server.
PATH selects nested keys (for example `sections 3 block_states`). Long
arrays are summarised unless --full is given; --typed suffixes numbers
with their NBT type (b, s, l, f, d; plain ints are TAG_Int). Used to compare Horizon saves
with worlds written by the vanilla server.
"""
import gzip
import io
@@ -22,8 +17,6 @@ import struct
import sys
import zlib
USAGE = __doc__
def read_tag(f, tag_id):
if tag_id == 1:
@@ -126,14 +119,11 @@ def select(v, path):
def main(argv):
if '--help' in argv or '-h' in argv:
print(USAGE)
return 0
full = '--full' in argv
typed = '--typed' in argv
argv = [a for a in argv if a not in ('--full', '--typed')]
if not argv:
print(USAGE)
print(__doc__)
return 1
path = argv[0]
rest = argv[1:]
-42
View File
@@ -1910,29 +1910,6 @@ def aquatic_from_bucket():
return ops
def noai_tadpole_age():
"""`Tadpole.aiStep` counts the age up and grows the tadpole into a frog at
24000; `NoAI` skips only `serverAiStep`, so a NoAI tadpole ages too. An
age-locked one doesn't (#14615)."""
box = 'x=8,y=218,z=8,dx=10,dy=6,dz=4'
# (tag, x, saved Age, AgeLocked)
tadpoles = [('h14615_young', 10, 0, False), ('h14615_due', 13, 23998, False),
('h14615_locked', 16, 0, True)]
ops = []
for tag, x, age, locked in tadpoles:
lock = ',AgeLocked:1b' if locked else ''
ops += setup(f'/summon minecraft:tadpole {x}.5 220 10.5 {{Tags:["{tag}"],NoAI:1b,'
f'NoGravity:1b,Silent:1b,PersistenceRequired:1b,Age:{age}{lock}}}')
for ticks in (1, 2):
ops.append({'op': 'tick', 'n': ticks})
for tag, *_ in tadpoles:
target = f'@e[type=minecraft:tadpole,tag={tag},limit=1]'
ops += [cmd(f'/execute if entity {target}'), cmd(f'/data get entity {target} Age')]
ops.append(cmd(f'/execute if entity @e[type=minecraft:frog,{box}]'))
ops += setup(f'/kill @e[type=minecraft:tadpole,{box}]', f'/kill @e[type=minecraft:frog,{box}]')
return ops
def dispensed_spawn_egg_persistence():
"""`SpawnEggItemBehavior.execute` spawns with `EntitySpawnReason.DISPENSER`
and never calls `Mob.setPersistenceRequired`: the mob saves
@@ -7677,23 +7654,6 @@ def dispensed_item_escape():
f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0} {z0 + 15} air', f'/forceload remove {x0} {z0}')
def comparator_setblock_output():
"""#13870: `BlockInput.place` loads a comparator's tag (`ComparatorBlockEntity.loadAdditional`:
`getIntOr("OutputSignal", 0)`), as `/data merge block` does."""
x0, y0, z0 = 4096 - 320, 260, 3584
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd('/tick freeze', False),
cmd(f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0 - 1} {z0 + 15} stone', False)]
for n, data in enumerate(('{OutputSignal:20}', '{OutputSignal:-1}', '{}', '{OutputSignal:7}'), 1):
x = x0 + 2 * n
ops += [cmd(f'/setblock {x} {y0} {z0 + 8} comparator{data}', expect_result=1),
cmd(f'/data get block {x} {y0} {z0 + 8} OutputSignal')]
# A second /setblock over the same comparator loads the tag again.
ops += [cmd(f'/setblock {x0 + 8} {y0} {z0 + 8} comparator{{OutputSignal:3}}'),
cmd(f'/data get block {x0 + 8} {y0} {z0 + 8} OutputSignal')]
return ops + setup('/tick unfreeze', f'/fill {x0} {y0 - 1} {z0} {x0 + 15} {y0} {z0 + 15} air',
f'/forceload remove {x0} {z0}')
def groups():
try:
from .spectral_duration_corpus import cases as spectral_saved_duration
@@ -7865,7 +7825,6 @@ def groups():
('axolotl integer memory TTL', axolotl_integer_memory_ttl()),
('parrot saved Age', parrot_saved_age()),
('animal NoAI love', animal_noai_love()),
('NoAI tadpole age', noai_tadpole_age()),
('turtle summon home', turtle_summon_home()),
('command minecart loaded success', command_minecart_loaded_success()),
('ravager NoAI counters', ravager_noai_counters()),
@@ -7891,7 +7850,6 @@ def groups():
('primed tnt fall', primed_tnt_fall()),
('dispensed item escape', dispensed_item_escape()),
('command block last output', command_block_last_output()),
('comparator setblock output', comparator_setblock_output()),
('glide fall accumulation', glide_fall_accumulation()),
('living short timers', living_short_timers()),
('projectile fall distance', projectile_fall_distance()),
+3 -9
View File
@@ -5,12 +5,10 @@ CommandSelection.ALL), which the tree sent to players keeps. Brigadier keeps
children in registration order; the data generator's commands report, which
the sent tree is built from, sorts them.
python3 tools/generate_lan_command_order.py [--out PATH]
python3 tools/generate_lan_command_order.py
"""
import argparse
import json
import os
from pathlib import Path
import subprocess
import sys
@@ -24,9 +22,6 @@ OUT = lab.ROOT / 'crates/horizon-client/src/lan/command_order.rs'
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('--out', type=Path, default=OUT)
args = parser.parse_args()
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'lan-command-order'
classes = build / 'classes'
@@ -61,9 +56,8 @@ def main():
]
lines += [f' ("{p}", "{n}"),' for p, n in rows]
lines += ['];', '']
args.out.write_text('\n'.join(lines))
print(f'wrote {len(rows)} nodes to {args.out}')
return 0
OUT.write_text('\n'.join(lines))
print(f'wrote {len(rows)} nodes to {OUT}')
if __name__ == '__main__':
+8 -13
View File
@@ -28,7 +28,6 @@ since, counts as unreviewed. Players see no difference; the record is for review
python3 tools/lang_coverage.py --game-only
python3 tools/lang_coverage.py --summary # one line per file, for a pull request
python3 tools/lang_coverage.py --show-keys # the keys behind every count
python3 tools/lang_coverage.py --summary --show-keys # the summary, then the keys behind it
python3 tools/lang_coverage.py --json # the same, for a script
python3 tools/lang_coverage.py --fail-under 95 # exit 1 under that cover (for CI, later)
python3 tools/lang_coverage.py --unreviewed --lang fr_fr # what a French reviewer reads
@@ -356,8 +355,7 @@ def main(argv: list[str] | None = None) -> int:
parser.add_argument("--lang", nargs="+", metavar="CODE",
help="only these file stems (es_es and fr_fr for the game, es and fr for the launcher)")
parser.add_argument("--summary", action="store_true", help="one line per file, for a pull request")
parser.add_argument("--show-keys", action="store_true",
help="list the keys behind every count (--json already carries them)")
parser.add_argument("--show-keys", action="store_true", help="list the keys behind every count")
parser.add_argument("--json", action="store_true", help="machine output")
parser.add_argument("--fail-under", type=float, metavar="PERCENT",
help="exit 1 when a game language covers less than this")
@@ -446,16 +444,13 @@ def main(argv: list[str] | None = None) -> int:
if reference:
print()
print_launcher(args.launcher_dir, launcher)
if args.show_keys and not args.json:
# the keys behind the counts, after the lines that carry the counts; --json holds
# them already, and printing more would break what a script reads.
if reference:
print_game_keys(game)
for *_, rows in crates:
print_game_keys(rows)
if launcher:
print_launcher_keys(launcher)
if args.show_keys:
if reference:
print_game_keys(game)
for *_, rows in crates:
print_game_keys(rows)
if launcher:
print_launcher_keys(launcher)
if args.fail_under is not None:
every = game + [row for *_, rows in crates for row in rows]
-3
View File
@@ -47,9 +47,6 @@ def mob_absorption_max(raw=False):
('data_soak_effect', None, [f'/effect give {T} minecraft:absorption 100 0',
f'/data merge entity {T} {{AbsorptionAmount:10.0f}}',
f'/damage {T} 1 minecraft:generic']),
# #11384: Absorption II for a second over Absorption I. When it runs out the hidden I is promoted
# (onEffectUpdated -> refreshDirtyAttributes), and the amount of 8 is clamped to I's maximum of 4.
('promote', None, [f'/effect give {T} minecraft:absorption 100 0', f'/effect give {T} minecraft:absorption 1 1']),
]
tags = []
for i, (name, base, setup) in enumerate(cases):
+1 -1
View File
@@ -127,7 +127,7 @@ AbstractSliderButton.getSprite port crates/horizon-client-render/src/ui.rs::slid
AbstractSliderButton.getHandleSprite port crates/horizon-client-render/src/ui.rs::slider_sprites_ext lit while hovered or while its value can change (Tab, Enter, a mouse drag)
AbstractSliderButton.updateWidgetNarration skip:other narration only
AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider_ext the whole message, value included, scrolls inside the slider (margin 2)
AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::slider_cursor a hovered slider points, a dragged one resizes horizontally, an inactive one is not allowed
AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::widget_cursor gap: minor: no resize cursor while dragging
AbstractSliderButton.setFocused port crates/horizon-client/src/game/key_focus.rs::slider_edit_on_focus the arrows change the value only when the mouse or Tab focused the slider, not when an arrow did
AbstractSliderButton.keyPressed port crates/horizon-client/src/game/key_focus.rs::act left and right nudge a focused slider once Tab or Enter let them (Act::Nudge), Enter toggles it (Act::ToggleSlider)
AbstractSliderButton.setValue port crates/horizon-client-render/src/options_ui.rs::set_slider gap: minor: the 0..1 clamp is done by the caller (ui.rs slider_value)
1 ActiveTextCollector.accept inline crates/horizon-client-render/src/screen_draw.rs::text default overload: left-aligned text at x,y with default parameters
127 AbstractSliderButton.getHandleSprite port crates/horizon-client-render/src/ui.rs::slider_sprites_ext lit while hovered or while its value can change (Tab, Enter, a mouse drag)
128 AbstractSliderButton.updateWidgetNarration skip:other narration only
129 AbstractSliderButton.extractWidgetRenderState port crates/horizon-client-render/src/ui.rs::slider_ext the whole message, value included, scrolls inside the slider (margin 2)
130 AbstractSliderButton.handleCursor port crates/horizon-client-render/src/widgets.rs::slider_cursor crates/horizon-client-render/src/widgets.rs::widget_cursor a hovered slider points, a dragged one resizes horizontally, an inactive one is not allowed gap: minor: no resize cursor while dragging
131 AbstractSliderButton.setFocused port crates/horizon-client/src/game/key_focus.rs::slider_edit_on_focus the arrows change the value only when the mouse or Tab focused the slider, not when an arrow did
132 AbstractSliderButton.keyPressed port crates/horizon-client/src/game/key_focus.rs::act left and right nudge a focused slider once Tab or Enter let them (Act::Nudge), Enter toggles it (Act::ToggleSlider)
133 AbstractSliderButton.setValue port crates/horizon-client-render/src/options_ui.rs::set_slider gap: minor: the 0..1 clamp is done by the caller (ui.rs slider_value)
-2
View File
@@ -41,8 +41,6 @@ DeathScreen.tick inline crates/horizon-client/src/game/screens.rs::Game::draw_de
DeathScreen.setButtonsActive inline crates/horizon-client/src/game/screens.rs::Game::draw_death_screen buttons are active once the 1000 ms delay has passed
DirectJoinServerScreen.keyPressed port crates/horizon-client/src/multiplayer.rs::Screen::confirm Enter joins the typed address when it parses (direct_address, friend invite codes included); on the server list it joins the selected server or LAN game
DirectJoinServerScreen.init port crates/horizon-client/src/multiplayer.rs::Screen::buttons Direct Connection field with Join and Cancel (Screen::Direct arm)
DirectJoinServerScreen.updateSelectButtonStatus port crates/horizon-client/src/multiplayer.rs::Screen::buttons the Join Server button is active only while servers::parse_address accepts the typed address (Screen::Direct)
DirectJoinServerScreen.setInitialFocus skip:ui the address field is drawn focused (focused_at); it is the Direct screen's only text field
DirectJoinServerScreen.resize skip:ui layout only; Screen::Direct keeps the typed address
DirectJoinServerScreen.onSelect port crates/horizon-client/src/multiplayer.rs::Screen::click Join returns the typed address as Outcome::Connect
DirectJoinServerScreen.removed port crates/horizon-client/src/title.rs::Title::remember_direct_address saves the address to last_server; gap: minor: the options file write is not in this fn
Can't render this file because it contains an unexpected character in line 28 and column 160.
+1 -2
View File
@@ -65,8 +65,7 @@ InWorldGameRulesScreen.onGamemasterPermissionChanged port crates/horizon-client/
LanguageSelectScreen.addTitle inline crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language search box at the top of the Language page
LanguageSelectScreen.setInitialFocus skip:ui focus only: typed text goes to the language search whatever has focus (type_text)
LanguageSelectScreen.addContents port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language rows of the Language page
LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout the options.languageAccuracyWarning line above the Font... and Done buttons
LanguageSelectScreen.LanguageSelectionList.Entry.getNarration skip:other narration text for screen readers
LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout gap: minor: unsure whether the translation warning line is drawn; Font... and Done buttons
LanguageSelectScreen.repositionElements skip:ui layout only: rows are laid out from the window size each frame
LanguageSelectScreen.onDone port crates/horizon-client-render/src/options_ui.rs::OptionsUi::back Done applies the chosen language (language_choice)
LanguageSelectScreen.LanguageSelectionList.filterEntries port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout filter by the search text against name and region (Page::Language)
1 ClientBundleTooltip.extractEmptyBundleTooltip port crates/horizon-client/src/game/stations.rs::Game::draw_bundle_tooltip empty branch: description, bar and label drawn from the layout
65 LanguageSelectScreen.addTitle inline crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language search box at the top of the Language page
66 LanguageSelectScreen.setInitialFocus skip:ui focus only: typed text goes to the language search whatever has focus (type_text)
67 LanguageSelectScreen.addContents port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout language rows of the Language page
68 LanguageSelectScreen.addFooter port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout the options.languageAccuracyWarning line above the Font... and Done buttons gap: minor: unsure whether the translation warning line is drawn; Font... and Done buttons
LanguageSelectScreen.LanguageSelectionList.Entry.getNarration skip:other narration text for screen readers
69 LanguageSelectScreen.repositionElements skip:ui layout only: rows are laid out from the window size each frame
70 LanguageSelectScreen.onDone port crates/horizon-client-render/src/options_ui.rs::OptionsUi::back Done applies the chosen language (language_choice)
71 LanguageSelectScreen.LanguageSelectionList.filterEntries port crates/horizon-client-render/src/options_ui.rs::OptionsUi::layout filter by the search text against name and region (Page::Language)
-1
View File
@@ -148,5 +148,4 @@ TropicalFish.applyImplicitComponents port crates/horizon-client/src/entity_nbt.r
TropicalFish.finalizeSpawn port crates/horizon-client/src/mobs/finalize.rs::Ctx::finalize one in ten is a lone random fish, else a shared common variant
TropicalFish.checkTropicalFishSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules water below and above, and any height biomes or the surface rule
TropicalFish.Pattern.addToTooltip port crates/horizon-client/src/item_tooltip.rs::PlayerHeadName::tropical_fish_lines ticked in #3512; `TropicalFish.Pattern.addToTooltip` is cited at crates/horizon-client/src/item_tooltip.rs:362
TraderLlama.TraderLlamaDefendWanderingTraderGoal.canUse port crates/horizon-client/src/mobs/caravan.rs::DefendTrader::can_use spectator attackers are refused by TargetingConditions.DEFAULT
OwnerHurtByTargetGoal.canUse port crates/horizon-client/src/mobs/tame.rs::wants_to_attack dead attackers are refused by canBeSeenByAnyone
Can't render this file because it contains an unexpected character in line 131 and column 104.
-1
View File
@@ -156,5 +156,4 @@ Evoker.EvokerAttackSpellGoal.createSpellEntity port crates/horizon-client/src/mo
SulfurCube.doPush inline crates/horizon-client/src/mobs/sulfur_cube.rs::mob_contacts mob contact stage only; ordinary AI push impulse remains in ai::push_entities
Slime.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: an AbstractCubeMob that is ageable but never a baby
MagmaCube.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: same as the slime
Vex.VexCopyOwnerTargetGoal.canUse port crates/horizon-client/src/mobs/vex.rs::CopyOwnerTarget::can_use owner target must be alive and not a spectator
SulfurCube.SulfurCubeTemptGoal.canUse port crates/horizon-client/src/mobs/sulfur_cube.rs::SulfurTempt::can_use range scaled by visibility, floor 2
1 Strider.checkStriderSpawnRules port crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules strider arm: climbs lava upward, then needs air above
156 SulfurCube.doPush inline crates/horizon-client/src/mobs/sulfur_cube.rs::mob_contacts mob contact stage only; ordinary AI push impulse remains in ai::push_entities
157 Slime.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: an AbstractCubeMob that is ageable but never a baby
158 MagmaCube.canBeABaby port crates/horizon-client/src/mobs/species.rs::can_be_a_baby the false arm: same as the slime
Vex.VexCopyOwnerTargetGoal.canUse port crates/horizon-client/src/mobs/vex.rs::CopyOwnerTarget::can_use owner target must be alive and not a spectator
159 SulfurCube.SulfurCubeTemptGoal.canUse port crates/horizon-client/src/mobs/sulfur_cube.rs::SulfurTempt::can_use range scaled by visibility, floor 2
+3 -3
View File
@@ -38,7 +38,7 @@ Piglin.customServerAiStep port crates/horizon-client/src/mobs/piglin.rs::ai_step
Piglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step the conversion cancels the admire and drops the admired item; the rest of a piglin's carried inventory is still not scattered
Piglin.hurtServer inline crates/horizon-client/src/mobs/piglin.rs::celebrate gap: player: a hit by a player does not stop an admiring piglin or start its admiring-disabled wait; babies do not flee the attacker
Piglin.canUseNonMeleeWeapon port crates/horizon-client/src/game/piglins.rs::Game::piglin_wanted gap: minor: checks the preferred-weapons tag only, not the usable-crossbow tag or the kinetic weapon component
Piglin.holdInOffHand port crates/horizon-client/src/mobs/piglin.rs::hold_in_offhand equips the item (drop chance 2.0 as setItemSlotAndDropWhenKilled) and makes the piglin persistent unless it is the barter item
Piglin.holdInOffHand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring gap: minor: does not drop an item already in the off hand and does not set persistence
Piglin.wantsToPickUp port crates/horizon-client/src/game/piglins.rs::Game::piglin_wants gap: minor: does not check the admiring-disabled wait after a hit by a player
Piglin.startRiding port crates/horizon-client/src/mobs/riders.rs::start_riding the boarding tests (not the vehicle it is on, not one it carries, the boarding cooldown, a free vehicle) plus the `Piglin` case: a baby piglin on a hoglin goes on top of the up-to-three piglins already there
Piglin.getTopPassenger port crates/horizon-client/src/mobs/riders.rs::top_passenger the top of the passenger chain, capped at four passengers on a hoglin
@@ -56,7 +56,7 @@ PiglinAi.createIdleMovementBehaviors inline crates/horizon-client/src/mobs/pigli
PiglinAi.updateActivity inline crates/horizon-client/src/mobs/piglin.rs::register_piglin gap: minor: the activity choice is the goal priority order; the aggressive and dancing flags are set elsewhere
PiglinAi.isBabyRidingBaby port crates/horizon-client/src/mobs/piglin.rs::is_baby_riding_baby a baby piglin on a baby piglin or a baby hoglin
PiglinAi.pickUpItem port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups loved item admired, food eaten, else worn or stored
PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring drops what the off hand held, whole, then holds the item through hold_in_offhand (Piglin.holdInOffHand), which sets persistence
PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring gap: minor: does not drop an item already in the off hand and does not set persistence
PiglinAi.removeOneItemFromItemEntity port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups takes one item from the stack (nuggets whole)
PiglinAi.cancelAdmiring port crates/horizon-client/src/mobs/piglin.rs::stop_admiring the off hand is emptied and admiring is disabled for a while
PiglinAi.throwItems port crates/horizon-client/src/game/piglins.rs::Game::finish_admiring gap: minor: only the barter path throws; the target choice is nearest player within 16, else a spot nearby
@@ -137,7 +137,7 @@ Drowned.checkDrownedSpawnRules port crates/horizon-client/src/mobs/spawn_rules.r
Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw the field defaults to -1.0f, so the throw's uncertainty is 14 minus 4 times the difficulty id (ranged_attack_uncertainty)
Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw gap: minor: fixed uncertainty 6 (normal difficulty), not 14 minus 4 times the difficulty
Drowned.rideTick port crates/horizon-client/src/mobs/riders.rs::takes_vehicle_body the body yaw of a PathfinderMob it steers (getControlledVehicle)
Drowned.wantsToPickUp port crates/horizon-client/src/mobs/equip.rs::wants_to_pick_up spears are refused, then the zombie checks
Drowned.DrownedGoToBeachGoal.isValidTarget port crates/horizon-client/src/mobs/undead.rs::beach_target two empty blocks above and a standable block below
1 Evoker.EvokerWololoSpellGoal.canContinueToUse port crates/horizon-client/src/mobs/illager.rs::UseSpell::can_continue wololo branch: sheep target still set and warm-up left
38 Piglin.finishConversion port crates/horizon-client/src/mobs/piglin.rs::ai_step the conversion cancels the admire and drops the admired item; the rest of a piglin's carried inventory is still not scattered
39 Piglin.hurtServer inline crates/horizon-client/src/mobs/piglin.rs::celebrate gap: player: a hit by a player does not stop an admiring piglin or start its admiring-disabled wait; babies do not flee the attacker
40 Piglin.canUseNonMeleeWeapon port crates/horizon-client/src/game/piglins.rs::Game::piglin_wanted gap: minor: checks the preferred-weapons tag only, not the usable-crossbow tag or the kinetic weapon component
41 Piglin.holdInOffHand port crates/horizon-client/src/mobs/piglin.rs::hold_in_offhand crates/horizon-client/src/game/piglins.rs::Game::start_admiring equips the item (drop chance 2.0 as setItemSlotAndDropWhenKilled) and makes the piglin persistent unless it is the barter item gap: minor: does not drop an item already in the off hand and does not set persistence
42 Piglin.wantsToPickUp port crates/horizon-client/src/game/piglins.rs::Game::piglin_wants gap: minor: does not check the admiring-disabled wait after a hit by a player
43 Piglin.startRiding port crates/horizon-client/src/mobs/riders.rs::start_riding the boarding tests (not the vehicle it is on, not one it carries, the boarding cooldown, a free vehicle) plus the `Piglin` case: a baby piglin on a hoglin goes on top of the up-to-three piglins already there
44 Piglin.getTopPassenger port crates/horizon-client/src/mobs/riders.rs::top_passenger the top of the passenger chain, capped at four passengers on a hoglin
56 PiglinAi.updateActivity inline crates/horizon-client/src/mobs/piglin.rs::register_piglin gap: minor: the activity choice is the goal priority order; the aggressive and dancing flags are set elsewhere
57 PiglinAi.isBabyRidingBaby port crates/horizon-client/src/mobs/piglin.rs::is_baby_riding_baby a baby piglin on a baby piglin or a baby hoglin
58 PiglinAi.pickUpItem port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups loved item admired, food eaten, else worn or stored
59 PiglinAi.holdInOffhand port crates/horizon-client/src/game/piglins.rs::Game::start_admiring drops what the off hand held, whole, then holds the item through hold_in_offhand (Piglin.holdInOffHand), which sets persistence gap: minor: does not drop an item already in the off hand and does not set persistence
60 PiglinAi.removeOneItemFromItemEntity port crates/horizon-client/src/game/piglins.rs::Game::piglin_pickups takes one item from the stack (nuggets whole)
61 PiglinAi.cancelAdmiring port crates/horizon-client/src/mobs/piglin.rs::stop_admiring the off hand is emptied and admiring is disabled for a while
62 PiglinAi.throwItems port crates/horizon-client/src/game/piglins.rs::Game::finish_admiring gap: minor: only the barter path throws; the target choice is nearest player within 16, else a spot nearby
137 Drowned.isDeepEnoughToSpawn inline crates/horizon-client/src/mobs/spawn_rules.rs::check_spawn_rules below sea level minus 5
138 Drowned.populateDefaultEquipmentSlots port crates/horizon-client/src/mobs/finalize.rs::Ctx::populate 10 percent chance of a trident (10 in 16) or fishing rod
139 Drowned.canReplaceCurrentItem port crates/horizon-client/src/game/mob_looting.rs::Game::can_replace the mob pickup compare (armour, toughness, attack damage) with the drowned's nautilus shell guard; called before a mob swaps what it holds
140 Drowned.rangedAttackUncertainty port crates/horizon-client/src/mobs/undead.rs::drowned_throw the field defaults to -1.0f, so the throw's uncertainty is 14 minus 4 times the difficulty id (ranged_attack_uncertainty) gap: minor: fixed uncertainty 6 (normal difficulty), not 14 minus 4 times the difficulty
141 Drowned.rideTick port crates/horizon-client/src/mobs/riders.rs::takes_vehicle_body the body yaw of a PathfinderMob it steers (getControlledVehicle)
142 Drowned.wantsToPickUp port crates/horizon-client/src/mobs/equip.rs::wants_to_pick_up spears are refused, then the zombie checks
143 Drowned.DrownedGoToBeachGoal.isValidTarget port crates/horizon-client/src/mobs/undead.rs::beach_target two empty blocks above and a standable block below
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more