Merge origin/main into refactor/entity-walk-lightning

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
This commit is contained in:
HrafnaValkyrjaandClaude Sonnet 5.5 committed 2026-11-02 04:11:28 -05:00
commit 0c8f97e4a3
44 files changed
+1679 -420

No files matched your search

+24 -4
View File
@@ -135,6 +135,9 @@ pub struct Extras {
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
/// The wheel over a cycle button in the settings changes it (`CycleButton.mouseScrolled`,
/// on by default as vanilla); off, the wheel only scrolls the list.
pub wheel_cycles_settings: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -177,6 +180,7 @@ impl Default for Extras {
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
wheel_cycles_settings: true,
}
}
}
@@ -261,6 +265,7 @@ impl Extras {
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
"wheelCyclesSettings" => e.wheel_cycles_settings = flag(e.wheel_cycles_settings),
_ => {}
}
}
@@ -268,7 +273,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -312,6 +317,7 @@ impl Extras {
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
bool_text(self.wheel_cycles_settings),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -456,6 +462,7 @@ pub enum Item {
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -480,6 +487,8 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(AutoReconnect),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
]
}
impl Item {
@@ -503,6 +512,7 @@ impl Item {
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -525,6 +535,7 @@ impl Item {
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -556,6 +567,7 @@ impl Item {
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -568,7 +580,7 @@ impl Item {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation => {
| ChatAnimation | WheelCyclesSettings => {
return None;
}
})
@@ -623,6 +635,9 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -638,7 +653,8 @@ impl Item {
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
| Item::Splitscreen
| Item::WheelCyclesSettings => Some(Cost::None),
_ => None,
}
}
@@ -661,6 +677,7 @@ impl Item {
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
_ => return None,
})
}
@@ -694,6 +711,7 @@ impl Item {
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
Item::SortKey => {}
}
}
@@ -717,7 +735,7 @@ mod tests {
assert!(!d.zoom && !d.fps && !d.keystrokes && !d.minimap && !d.auto_reconnect);
assert!(!d.mouse_tweaks && !d.sort && !d.own_name_tag && !d.ui_sound_reserve);
assert!(!d.hide_block_particles);
assert!(d.discord_presence);
assert!(d.discord_presence && d.wheel_cycles_settings);
assert_eq!(Extras::parse(""), d);
assert_eq!(Extras::parse(&d.serialize()), d);
let mut e = d.clone();
@@ -758,6 +776,8 @@ mod tests {
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
Item::WheelCyclesSettings.cycle(&mut e, false);
assert_eq!(Item::WheelCyclesSettings.switch(&e), Some(false));
assert_eq!(Extras::parse(&e.serialize()), e);
}
@@ -1363,6 +1363,11 @@ impl GraphicsUi {
framebuffer: [i32; 2],
amount: f32,
) -> Vec<Action> {
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
if !s.extras.wheel_cycles_settings {
self.scroll(level, s, h, amount);
return Vec::new();
}
// Horizon improvement: focused slider/cycle intercepts wheel first.
if amount != 0.0
&& self.can_change_value
@@ -2253,6 +2258,15 @@ mod tests {
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, 1.0);
assert_eq!(actions, vec![Action::ButtonClick, Action::Changed]);
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
// With the "Scroll wheel changes settings" extra off, it scrolls instead.
s.extras.wheel_cycles_settings = false;
let scroll_before = ui.scroll.amount;
let actions = ui.wheel(Level::Advanced, &mut s, cursor, size, fb, -1.0);
assert!(actions.is_empty());
assert_eq!(s.horizon.far_detail, FarDetail::Reduced);
assert!(ui.scroll.amount > scroll_before);
s.extras.wheel_cycles_settings = true;
ui.scroll.amount = scroll_before;
// 2. Wheel over a slider: Chunk Updates scrolls the page, doesn't change slider value
s.horizon.chunk_updates = 16;
+1
View File
@@ -82,6 +82,7 @@ pub mod screen_draw;
pub mod screen_effects;
pub mod sculk;
pub mod section_mask;
pub mod server_terrain;
pub mod settings;
pub mod shader_options;
pub mod shader_pack_list;
+3 -4
View File
@@ -1220,10 +1220,9 @@ impl Lod {
else {
continue;
};
Done::Observed(
key,
ReducedColumns::new(summarize(&chunk, kinds)),
)
let columns = summarize(&chunk, kinds);
sampler.observed(key, &columns);
Done::Observed(key, ReducedColumns::new(columns))
}
Job::RequestDetail { chunks, center } => {
sampler.request_detail(&chunks, center);
+51 -1
View File
@@ -4233,7 +4233,8 @@ impl OptionsUi {
}
let plain = self.resource_error.is_none()
&& !(self.pack_screen.is_some() && self.stack.last() == Some(&Page::Packs));
if !plain {
// The "Scroll wheel changes settings" extra off: the wheel only scrolls.
if !plain || !s.extras.wheel_cycles_settings {
self.scroll_at(s, size, cursor, amount);
return Vec::new();
}
@@ -7321,6 +7322,55 @@ mod tests {
assert!(events.contains(&Event::Changed));
}
#[test]
fn the_wheel_only_scrolls_with_the_extra_off() {
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let framebuffer = [854, 480];
let size = (427.0, 240.0);
let (widgets, top, bottom, content) = ui.layout(&s, size.0, size.1);
assert!(
content > bottom - top,
"the video page scrolls at this size"
);
let (opt, over) = widgets
.iter()
.find_map(|wd| match wd.kind {
WKind::Button {
act: Act::Cycle(opt),
..
} if !wd.list || (wd.y >= top && wd.y + wd.h <= bottom) => {
Some((opt, (wd.x + 5.0, wd.y + 5.0)))
}
_ => None,
})
.expect("a visible cycle button");
s.extras.wheel_cycles_settings = false;
let before = format!("{s:?}");
let start = ui.scroll;
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_eq!(format!("{s:?}"), before, "the hovered option stays put");
assert!(!events.contains(&Event::Changed));
assert!(
ui.scroll > start,
"the list scrolls under the cursor instead"
);
// Keyboard focus doesn't take the wheel either.
ui.focus = Some(FocusId::Cycle(opt));
ui.focus_from_keyboard = true;
ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_eq!(format!("{s:?}"), before);
// On (the default), vanilla's CycleButton.mouseScrolled again.
s.extras.wheel_cycles_settings = true;
let before = format!("{s:?}");
let events = ui.wheel(&mut s, size, over, framebuffer, -1.0);
assert_ne!(format!("{s:?}"), before, "{opt:?} cycles");
assert!(events.contains(&Event::Changed));
}
#[test]
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
let stepped_opts = [
@@ -0,0 +1,531 @@
//! Far terrain on servers: every chunk a server sends is kept as its
//! far-terrain summary (`lod::Column`s), on disk per server, world and
//! dimension, and drawn beyond the server's view distance on this visit and
//! the next ones. A server never sends its seed, so its
//! distant terrain can't be generated as a local world's is.
//!
//! The summaries live in Anvil region files (`r.X.Z.mca`, one NBT record a
//! chunk), under `server-terrain/<server>/<hashed seed>/<namespace>/<level>/`.
use crate::{
fast_hash::FxHashMap,
lod::{Column, Span, Spans, Surface},
streaming::{LodDetail, LodSampler, LodSource},
};
use horizon_core::position::ChunkPos;
use horizon_world::{
DATA_VERSION,
compression::Compression,
nbt::{Limits, NamedTag, Tag},
region::{ChunkRecord, RegionFile},
registry::BlockStateId,
};
use std::{
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
/// Bumped when the summary layout changes; older records are ignored.
const FORMAT: i32 = 1;
/// Decoded chunks kept in memory, and open region files.
const MAX_CHUNKS: usize = 8192;
const MAX_REGIONS: usize = 64;
/// The widest cell, in chunks, drawn from the chunks kept under it
/// (`Sampler::reduced_column`): its regions all stay open.
const MAX_KEPT_SIDE: i32 = 128;
type Columns = Arc<[Column; 256]>;
/// The folder of a server's terrain: its address as typed, lowercased, with
/// the default port left out, and every character a file name can't hold
/// replaced (`play.example.net:25566` → `play.example.net_25566`).
pub fn server_folder(address: &str) -> String {
let address = address.trim().to_ascii_lowercase();
let address = address.strip_suffix(":25565").unwrap_or(&address);
let name: String = address
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
let name = name.trim_matches('.');
if name.is_empty() {
"_".into()
} else {
name.into()
}
}
/// `root/<server>/<seed>/<namespace>/<path>` for a level key
/// (`minecraft:overworld`) and the login's hashed seed, which keeps
/// the worlds behind one address (a proxy's servers, a reset) apart.
pub fn level_dir(root: &Path, address: &str, hashed_seed: i64, level: &str) -> PathBuf {
root.join(server_folder(address))
.join(hashed_seed.to_string())
.join(level_path(level))
}
/// `<namespace>/<path>` of a level key.
fn level_path(level: &str) -> PathBuf {
let (namespace, path) = level.split_once(':').unwrap_or(("minecraft", level));
let clean = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.') {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('.')
.to_owned()
};
let mut dir = PathBuf::from(clean(namespace));
for part in path.split('/').map(clean).filter(|p| !p.is_empty()) {
dir.push(part);
}
dir
}
struct Region {
file: RegionFile,
present: [bool; 1024],
}
/// Open region files by region; `None`: the file doesn't exist.
type Regions = FxHashMap<(i32, i32), Option<Region>>;
#[derive(Default)]
struct State {
regions: Regions,
/// `None`: the chunk isn't stored.
chunks: FxHashMap<(i32, i32), Option<Columns>>,
}
/// One level's stored terrain, shared by the far-terrain threads.
pub struct ServerTerrain {
dir: PathBuf,
state: Mutex<State>,
warned: std::sync::atomic::AtomicBool,
}
impl ServerTerrain {
pub fn new(dir: PathBuf) -> Arc<Self> {
Arc::new(Self {
dir,
state: Mutex::default(),
warned: Default::default(),
})
}
/// The stored chunk nearest the middle of the `side` × `side` chunks
/// from `(x, z)`, found in the region headers without reading any.
fn nearest(&self, x: i32, z: i32, side: i32) -> Option<(i32, i32)> {
let mut state = self.state.lock().unwrap();
let middle = (x * 2 + side, z * 2 + side);
let mut best: Option<(i64, (i32, i32))> = None;
for rz in z >> 5..=(z + side - 1) >> 5 {
for rx in x >> 5..=(x + side - 1) >> 5 {
let present = region(&mut state.regions, &self.dir, (rx, rz))
.map_or([false; 1024], |r| r.present);
for (i, _) in present.iter().enumerate().filter(|(_, p)| **p) {
let (cx, cz) = (rx * 32 + (i % 32) as i32, rz * 32 + (i / 32) as i32);
if cx < x || cz < z || cx >= x + side || cz >= z + side {
continue;
}
let (dx, dz) = (
i64::from(cx * 2 + 1 - middle.0),
i64::from(cz * 2 + 1 - middle.1),
);
let distance = dx * dx + dz * dz;
if best.is_none_or(|(d, _)| distance < d) {
best = Some((distance, (cx, cz)));
}
}
}
}
best.map(|(_, chunk)| chunk)
}
/// A stored chunk's columns.
pub fn chunk(&self, x: i32, z: i32) -> Option<Columns> {
let key = (x >> 5, z >> 5);
let here = {
let mut state = self.state.lock().unwrap();
if let Some(known) = state.chunks.get(&(x, z)) {
return known.clone();
}
read(region(&mut state.regions, &self.dir, key), x, z)
};
// A record of another game version or layout is none.
let columns = here.and_then(|record| {
record
.nbt(Limits::default())
.ok()
.and_then(|root| decode(&root.tag))
});
let mut state = self.state.lock().unwrap();
// Stored by another thread meanwhile (`write`): that, newer than
// what was read here.
if let Some(Some(stored)) = state.chunks.get(&(x, z)) {
return Some(stored.clone());
}
remember(&mut state, (x, z), columns.clone());
columns
}
/// A chunk couldn't be kept (disk full, a folder it can't write): said
/// once a level, not for every chunk after.
fn warn_once(&self, error: &std::io::Error) {
if !self.warned.swap(true, std::sync::atomic::Ordering::Relaxed) {
log::warn!("Horizon: far terrain can't keep the server's chunks: {error}");
}
}
/// Keep a chunk's columns, unless they are what is stored already.
pub fn store(&self, key: (i32, i32), columns: &Columns) -> std::io::Result<()> {
if self
.chunk(key.0, key.1)
.is_some_and(|old| *old == **columns)
{
return Ok(());
}
self.write(key, columns)
}
fn write(&self, (x, z): (i32, i32), columns: &Columns) -> std::io::Result<()> {
let root = NamedTag {
name: "".into(),
tag: encode(x, z, columns),
};
let record = ChunkRecord::from_nbt(&root, Compression::Zlib, now(), Limits::default())?;
let mut state = self.state.lock().unwrap();
let key = (x >> 5, z >> 5);
if region(&mut state.regions, &self.dir, key).is_none() {
std::fs::create_dir_all(&self.dir)?;
let file = RegionFile::open_writable(region_path(&self.dir, key), true)?;
state.regions.insert(key, Some(Region::new(file)));
}
let region = region(&mut state.regions, &self.dir, key).expect("region just opened");
region.file.write(ChunkPos::new(x, z), &record)?;
region.present[slot(x, z)] = true;
remember(&mut state, (x, z), Some(columns.clone()));
Ok(())
}
}
fn region_path(dir: &Path, (rx, rz): (i32, i32)) -> PathBuf {
dir.join(format!("r.{rx}.{rz}.mca"))
}
/// A region of `dir`, opened on first use.
fn region<'a>(regions: &'a mut Regions, dir: &Path, key: (i32, i32)) -> Option<&'a mut Region> {
if !regions.contains_key(&key) {
if regions.len() >= MAX_REGIONS {
regions.clear();
}
let file = RegionFile::open_writable(region_path(dir, key), false);
regions.insert(key, file.ok().map(Region::new));
}
regions.get_mut(&key)?.as_mut()
}
fn read(region: Option<&mut Region>, x: i32, z: i32) -> Option<ChunkRecord> {
let region = region.filter(|r| r.present[slot(x, z)])?;
region.file.read(ChunkPos::new(x, z)).ok().flatten()
}
impl Region {
fn new(file: RegionFile) -> Self {
let mut present = [false; 1024];
for (pos, _) in file.chunks() {
present[slot(pos.x, pos.z)] = true;
}
Self { file, present }
}
}
fn slot(x: i32, z: i32) -> usize {
(x & 31) as usize + (z & 31) as usize * 32
}
fn remember(state: &mut State, key: (i32, i32), columns: Option<Columns>) {
if state.chunks.len() >= MAX_CHUNKS {
state.chunks.clear();
}
state.chunks.insert(key, columns);
}
fn now() -> u32 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as u32)
}
/// The columns as one int array: per column a surface (`0`, or `1`, height,
/// state, water flag, water height, water state, biome), the span count, and
/// three ints a span (min and max, state, biome and light).
fn encode(x: i32, z: i32, columns: &[Column; 256]) -> Tag {
let mut ints = Vec::with_capacity(256 * 8);
for column in columns {
match column.surface {
None => ints.push(0),
Some(s) => {
let (water, water_height, water_state) = s
.water
.map_or((0, 0, 0), |(h, state)| (1, h, state.0 as i32));
ints.extend([
1,
s.height,
s.state.0 as i32,
water,
water_height,
water_state,
i32::from(s.biome),
]);
}
}
ints.push(column.spans.len() as i32);
for span in &column.spans {
ints.extend([
i32::from(span.min as u16) << 16 | i32::from(span.max as u16),
span.state.0 as i32,
i32::from(span.biome) << 16 | i32::from(span.light),
]);
}
}
Tag::compound([
("DataVersion", Tag::Int(DATA_VERSION)),
("HorizonFarTerrain", Tag::Int(FORMAT)),
("xPos", Tag::Int(x)),
("zPos", Tag::Int(z)),
("columns", Tag::IntArray(ints)),
])
}
/// `encode`'s columns, or `None` for a record of another game version or
/// layout (their block states and biomes are numbered differently).
fn decode(tag: &Tag) -> Option<Columns> {
if tag.get("DataVersion") != Some(&Tag::Int(DATA_VERSION))
|| tag.get("HorizonFarTerrain") != Some(&Tag::Int(FORMAT))
{
return None;
}
let Some(Tag::IntArray(ints)) = tag.get("columns") else {
return None;
};
let mut ints = ints.iter().copied();
let mut next = || ints.next();
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
for column in &mut columns {
column.surface = match next()? {
0 => None,
1 => {
let height = next()?;
let state = BlockStateId(next()? as u32);
let water = next()?;
let water_height = next()?;
let water_state = BlockStateId(next()? as u32);
let biome = next()? as u16;
Some(Surface {
height,
state,
water: (water != 0).then_some((water_height, water_state)),
biome,
})
}
_ => return None,
};
let count = usize::try_from(next()?).ok().filter(|&n| n <= 4096)?;
column.spans = Spans::with_capacity(count);
for _ in 0..count {
let range = next()?;
let state = BlockStateId(next()? as u32);
let material = next()?;
column.spans.push(Span {
min: (range >> 16) as i16,
max: range as i16,
state,
biome: (material >> 16) as u16,
light: material as u16,
});
}
}
next().is_none().then(|| Arc::new(columns))
}
impl LodSource for ServerTerrain {
fn sampler(self: Arc<Self>) -> Box<dyn LodSampler> {
Box::new(Sampler {
terrain: self,
details: Vec::new(),
})
}
}
struct Sampler {
terrain: Arc<ServerTerrain>,
details: Vec<LodDetail>,
}
impl LodSampler for Sampler {
fn fork(&self) -> Option<Box<dyn LodSampler>> {
Some(Box::new(Self {
terrain: self.terrain.clone(),
details: Vec::new(),
}))
}
/// Stored chunks come back whole; the others are known empty.
fn request_detail(&mut self, chunks: &[(i32, i32)], _center: [i32; 2]) {
for &(x, z) in chunks {
let columns = self
.terrain
.chunk(x, z)
.unwrap_or_else(|| Arc::new(std::array::from_fn(|_| Column::default())));
self.details.push(((x, z), columns));
}
}
fn drain_details(&mut self) -> Vec<LodDetail> {
std::mem::take(&mut self.details)
}
fn observed(&mut self, key: (i32, i32), columns: &Columns) {
if let Err(e) = self.terrain.store(key, columns) {
self.terrain.warn_once(&e);
}
}
fn sample(&mut self, x: i32, z: i32) -> Option<Surface> {
self.terrain.chunk(x >> 4, z >> 4)?[((z & 15) * 16 + (x & 15)) as usize].surface
}
fn column(&mut self, x: i32, z: i32) -> Column {
self.terrain
.chunk(x >> 4, z >> 4)
.map(|c| c[((z & 15) * 16 + (x & 15)) as usize].clone())
.unwrap_or_default()
}
/// A cell of `width` blocks from one stored chunk: its own area up to a
/// chunk, else the stored chunk nearest the cell's middle (the server
/// sent only the chunks a player came near).
fn reduced_column(&mut self, x: i32, z: i32, width: i32) -> Column {
if width <= 16 {
let Some(chunk) = self.terrain.chunk(x >> 4, z >> 4) else {
return Column::default();
};
let width = width.max(1) as usize;
let lx = (x & 15) as usize & !(width - 1);
let lz = (z & 15) as usize & !(width - 1);
return Column::reduced(&chunk, lx, lz, width);
}
// Searching wider cells would open thousands of region files.
if width >> 4 > MAX_KEPT_SIDE {
return Column::default();
}
self.terrain
.nearest(x >> 4, z >> 4, width >> 4)
.and_then(|(cx, cz)| self.terrain.chunk(cx, cz))
.map_or_else(Column::default, |chunk| Column::reduced(&chunk, 0, 0, 16))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn columns() -> Columns {
Arc::new(std::array::from_fn(|i| {
if i % 3 == 0 {
return Column::default();
}
Column {
surface: Some(Surface {
height: 63 + i as i32 % 7,
state: BlockStateId(9 + i as u32),
water: (i % 2 == 0).then_some((62, BlockStateId(86))),
biome: (i % 5) as u16,
}),
spans: vec![
Span {
min: -64,
max: 40,
state: BlockStateId(1),
biome: 3,
light: 0,
},
Span {
min: 40,
max: 63 + i as i16 % 7,
state: BlockStateId(9),
biome: 7,
light: 0xF0,
},
]
.into(),
}
}))
}
#[test]
fn columns_survive_their_encoding() {
let columns = columns();
assert_eq!(decode(&encode(3, -4, &columns)), Some(columns));
}
#[test]
fn records_of_another_version_are_ignored() {
let mut tag = encode(0, 0, &columns());
if let Tag::Compound(map) = &mut tag {
map.insert("DataVersion".into(), Tag::Int(DATA_VERSION - 1));
}
assert_eq!(decode(&tag), None);
}
#[test]
fn server_folders_are_plain_names() {
assert_eq!(server_folder("Play.Example.net"), "play.example.net");
assert_eq!(server_folder("play.example.net:25565"), "play.example.net");
assert_eq!(
server_folder("play.example.net:25566"),
"play.example.net_25566"
);
assert_eq!(server_folder("[::1]:25570"), "___1__25570");
assert_eq!(server_folder("../.."), "_");
assert_eq!(
level_dir(Path::new("/c"), "a", -7, "minecraft:the_nether"),
Path::new("/c/a/-7/minecraft/the_nether")
);
assert_eq!(
level_dir(Path::new("/c"), "a", 0, "mod:../../x"),
Path::new("/c/a/0/mod/x")
);
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("horizon-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
#[test]
fn stored_chunks_come_back_after_a_restart() {
let dir = scratch("server-terrain");
let columns = columns();
{
let terrain = ServerTerrain::new(dir.clone());
assert_eq!(terrain.chunk(-33, 5), None);
terrain.store((-33, 5), &columns).unwrap();
}
let terrain = ServerTerrain::new(dir.clone());
assert_eq!(terrain.chunk(-33, 5), Some(columns.clone()));
assert_eq!(terrain.nearest(-64, 0, 64), Some((-33, 5)));
assert_eq!(terrain.nearest(-32, 0, 64), None);
let mut sampler = terrain.clone().sampler();
assert_eq!(sampler.sample(-33 * 16 + 1, 5 * 16), columns[1].surface);
assert_eq!(sampler.column(0, 0), Column::default());
// A far cell finds the stored chunk anywhere in it.
assert_ne!(sampler.reduced_column(-64 * 16, 0, 1024), Column::default());
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -173,6 +173,8 @@ pub trait LodSampler: Send {
fn drain_details(&mut self) -> Vec<LodDetail> {
Vec::new()
}
/// A loaded chunk's summary, made on the far-terrain worker.
fn observed(&mut self, _key: (i32, i32), _columns: &Arc<[crate::lod::Column; 256]>) {}
/// Reduced far detail (`graphics::FarDetail::Reduced`): `grid` may then
/// approximate wide cells.
fn set_approximate(&mut self, _approximate: bool) {}
@@ -225,6 +225,7 @@
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multiplayer",
"horizon.extras.header.sound": "Sound",
"horizon.extras.header.menus": "Menus",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Dynamic lights",
"horizon.extras.own_name_tag": "Own name tag",
@@ -241,6 +242,7 @@
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
"horizon.extras.wheel_cycles_settings": "Scroll wheel changes settings",
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
@@ -309,6 +311,7 @@
"horizon.osk.close": "Close",
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.wheel_cycles_settings.tooltip": "The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
@@ -331,6 +331,9 @@
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.particles": "Частинки",
"horizon.extras.header.sound": "Звук",
"horizon.extras.header.menus": "Меню",
"horizon.extras.wheel_cycles_settings": "Коліщатко змінює налаштування",
"horizon.extras.wheel_cycles_settings.tooltip": "Коліщатко миші над кнопкою в налаштуваннях змінює її значення, як у ванільній грі. Вимкнено — коліщатко лише гортає список.",
"horizon.extras.hide_block_particles": "Без частинок блоків",
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
"horizon.extras.splitscreen": "Розділений екран",
+5 -4
View File
@@ -17,10 +17,11 @@ pub use horizon_client_render::{
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+102 -25
View File
@@ -21,18 +21,44 @@ use horizon_world::{
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
const ITEM_GRAVITY: f64 = 0.04;
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
/// Non-living slime `bounceRestitution` factor (`0.8f`) from
/// `Entity.restituteMovementAfterCollisions`.
const ITEM_SLIME_BOUNCE: f64 = 0.8;
const ITEM_SLIME_BOUNCE: f32 = 0.8;
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
-wanted_y * ITEM_SLIME_BOUNCE
} else {
0.0
/// `Entity.restituteMovementAfterCollisions` for the vertical axis of a
/// non-living entity: `current_y` is its velocity before the move, `moved_y`
/// how far the move got. Landing on slime (`below`) bounces by the block's
/// restitution `1.0f * 0.8f`, with the gravity and air drag the part of the tick
/// after the hit would have added back; any other collision zeroes Y.
pub fn vertical_collision_velocity(
current_y: f64,
moved_y: f64,
gravity: f64,
below: bool,
slime: bool,
) -> f64 {
if !(below && slime && -current_y > gravity) {
return 0.0;
}
let restitution = f64::from(1.0_f32 * ITEM_SLIME_BOUNCE);
let portion = moved_y / current_y;
let drag = 1.0 + portion * (f64::from(0.98_f32) - 1.0);
(portion * gravity - current_y) * drag * restitution
}
/// `Entity.move`: the position only changes when the collided step is longer
/// than `sqrt(1e-7)` or nothing was cut off; a tiny step against a wall or floor
/// is dropped (its collision effects still run).
pub fn move_applies(wanted: Vec3, moved: Vec3) -> bool {
let moved_sq = moved.length_squared();
moved_sq > 1.0e-7 || wanted.length_squared() - moved_sq < 1.0e-7
}
/// `SlimeBlock.stepOn`: a slow walk or slide on slime drags the horizontal
/// speed (`0.4 + |dy| * 0.2`); a fast vertical speed leaves it alone.
pub fn slime_step_on_scale(velocity_y: f64) -> Option<f64> {
let abs_delta_y = velocity_y.abs();
(abs_delta_y < 0.1).then_some(0.4 + abs_delta_y * 0.2)
}
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
@@ -278,11 +304,13 @@ impl ItemEntity {
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
if move_applies(wanted, moved) {
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
}
if !no_physics {
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
}
@@ -304,7 +332,13 @@ impl ItemEntity {
// (or a moving piston carrying one) bounces a dropped item;
// MovingPistonBlock itself has no bounceRestitution.
let slime = item_support_is_slime(world, facts, self.position);
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
self.velocity.y = vertical_collision_velocity(
wanted.y,
moved.y,
ITEM_GRAVITY,
wanted.y < 0.0,
slime,
);
}
// Entity.move -> applyEffectsFromBlocks: honey then bubble columns
// (before air drag), matching ItemEntity.tick.
@@ -318,6 +352,11 @@ impl ItemEntity {
self.bounds(),
&mut self.velocity,
);
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn, with the
// vertical speed the bounce just left (before the air drag).
if !no_physics {
self.slime_step_on(world, facts);
}
let mut friction = 0.98;
if self.on_ground {
let below = world.block(
@@ -337,6 +376,9 @@ impl ItemEntity {
}
} else {
// applyEffectsFromBlocksForLastMovements: still lift/drag while idle.
if !no_physics {
self.slime_step_on(world, facts);
}
crate::motion::bubble_column_push(
world,
registry,
@@ -411,6 +453,26 @@ impl ItemEntity {
}
stuck
}
/// `SlimeBlock.stepOn` for an item on the ground: slow vertical speed
/// drags the horizontal speed, on idle ticks too.
fn slime_step_on(&mut self, world: &World, facts: &BlockFacts) {
if !self.on_ground {
return;
}
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
0.0,
-f64::from(0.2_f32),
0.0,
)));
if &*facts.get(world.block(p.x, p.y, p.z)).name != "minecraft:slime_block" {
return;
}
if let Some(scale) = slime_step_on_scale(self.velocity.y) {
self.velocity.x *= scale;
self.velocity.z *= scale;
}
}
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
@@ -2116,22 +2178,38 @@ mod slime_bounce_tests {
use super::*;
#[test]
fn landing_on_slime_bounces_a_dropped_item() {
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
fn landing_on_slime_bounces_a_dropped_item_like_vanilla() {
// Measured on a vanilla 26.3 server: an item falling at -0.6097 that
// is stopped 0.4865 down leaves with 0.4950 / 0.98 after the bounce.
let y = vertical_collision_velocity(-0.6097, -0.4865, ITEM_GRAVITY, true, true);
assert!((y - 0.4950 / 0.98).abs() < 1e-3, "{y}");
// A tiny landing: -0.0792 stopped after 0.06 gives 0.0862 (vanilla).
let y = vertical_collision_velocity(-0.0792, -0.06, ITEM_GRAVITY, true, true);
assert!((y - 0.0862).abs() < 1e-3, "{y}");
assert_eq!(
item_vertical_collision_velocity(-0.04, true),
vertical_collision_velocity(-0.04, -0.01, ITEM_GRAVITY, true, true),
0.0,
"gravity alone is not a bounce"
);
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
}
#[test]
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
assert_eq!(
vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, false),
0.0
);
assert_eq!(
vertical_collision_velocity(1.0, 0.5, ITEM_GRAVITY, false, true),
0.0
);
}
#[test]
fn slime_step_on_drags_slow_vertical_speed_only() {
let scale = slime_step_on_scale(0.0862).unwrap();
assert!((scale - 0.41724).abs() < 1e-5);
assert_eq!(slime_step_on_scale(0.1), None);
}
#[test]
@@ -2204,9 +2282,8 @@ mod slime_bounce_tests {
fn a_slime_landing_stays_upward_after_item_friction() {
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
// negative on the ground. A slime bounce must remain positive.
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
let bounced = vertical_collision_velocity(-1.0, -0.5, ITEM_GRAVITY, true, true) * 0.98;
assert!(bounced > 0.0);
assert_eq!(bounced, 0.8 * 0.98);
}
#[test]
+40 -4
View File
@@ -2298,6 +2298,8 @@ struct Game<'a> {
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
lod_observe: horizon_client::fast_hash::FxHashSet<(i32, i32)>,
/// The server level far terrain keeps the chunks of.
lod_level: Option<String>,
/// Beacon beam sections kept after their chunk unloads, so far terrain
/// LOD still draws their beams (`beacon_geometry` refreshes them).
far_beacons: HashMap<(i32, i32, i32), Vec<(u32, i32)>>,
@@ -16607,20 +16609,28 @@ impl Game<'_> {
let enabled = self.far_terrain_choice().0 > 0
&& self.renderer.is_some()
&& self.player.is_some()
&& self.title.is_none()
&& (self.remote.is_none()
|| self.integrated.is_some()
|| self.server_far_terrain().is_some());
&& self.title.is_none();
if !enabled {
if self.lod.is_some() || self.lod_source_attempted {
self.clear_lod();
}
return;
}
// Another level: its own kept chunks. (Another world at the same
// address, a proxy's next server, comes through a new login; a seed
// changed at a respawn of the same level is a plugin hiding it.)
if self
.lod_level
.as_ref()
.is_some_and(|level| self.remote.as_ref().is_some_and(|r| r.level != *level))
{
self.clear_lod();
}
if self.lod_source_attempted {
return;
}
self.lod_source_attempted = true;
self.lod_level = None;
let dim = self.dim;
let source = match (&self.integrated, self.server_far_terrain(), &self.remote) {
(Some(server), ..) => server.call(move |game| game.lod_source_in(dim)).flatten(),
@@ -16631,6 +16641,31 @@ impl Game<'_> {
remote.session.far_terrain(offer, biomes, states);
Some(source)
}
// A friend's world (a new code at every share) or a splitscreen
// guest's (the player's own world, its other guests writing the
// same folder): nothing to keep.
(None, None, Some(remote))
if self.options.split_guest
|| remote
.session
.address
.starts_with(horizon_client::net::FRIEND) =>
{
None
}
// No offer from a server: draw what it sent, on this visit and before.
(None, None, Some(remote)) => std::env::current_dir().ok().map(|home| {
use horizon_client::server_terrain as terrain;
let address = &remote.session.address;
let dir = terrain::level_dir(
&home.join("server-terrain"),
address,
remote.hashed_seed,
&remote.level,
);
self.lod_level = Some(remote.level.clone());
terrain::ServerTerrain::new(dir) as _
}),
_ => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
@@ -18612,6 +18647,7 @@ impl<'a> Game<'a> {
lod_source_attempted: false,
far_beacons: HashMap::new(),
lod_observe: Default::default(),
lod_level: None,
lod_covered_at: None,
view_arrival: Default::default(),
options,
+10 -1
View File
@@ -45,6 +45,8 @@ pub(super) struct Boat {
pub delta_rotation: f32,
status: Status,
old_status: Status,
/// `AbstractBoat.oldStatus` is null until the first tick has set a status.
status_known: bool,
water_level: f64,
land_friction: f32,
out_of_control: f32,
@@ -126,6 +128,7 @@ impl Boat {
delta_rotation: 0.0,
status: Status::InAir,
old_status: Status::InAir,
status_known: false,
water_level: 0.0,
land_friction: 0.0,
out_of_control: 0.0,
@@ -571,6 +574,7 @@ impl Game<'_> {
if !controlled && self.remote.is_none() {
self.tick_boat_leash(&mut b);
}
let had_status = std::mem::replace(&mut b.status_known, true);
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.tick_submersion();
@@ -601,7 +605,10 @@ impl Game<'_> {
binding: self.collision,
subject: None,
};
if b.old_status == Status::InAir && b.status != Status::InAir && b.status != Status::OnLand
if had_status
&& b.old_status == Status::InAir
&& b.status != Status::InAir
&& b.status != Status::OnLand
{
b.water_level = b.position.y;
let target = self.water_level_above(&b) - HEIGHT + 0.101;
@@ -841,6 +848,8 @@ impl Game<'_> {
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
// `oldStatus = status` runs on every boat, so the next tick has one.
self.boats[i].status_known = true;
self.boats[i].tick_submersion();
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
+27
View File
@@ -311,6 +311,33 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
fn position(&mut self) -> Result<Vec3, Infallible> {
Ok(self.at())
}
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Infallible> {
// EntityTeleportEvent.ChorusFruit: only a server's player is heard.
let (Some(uuid), Some(name)) = (self.0.lan_acting_uuid, self.0.lan_acting_name.clone())
else {
return Ok(true);
};
let from = self.at();
let mut to = [target.x, target.y, target.z];
let mut cancel = false;
self.0.native_chorus_fruit_teleport(
uuid,
&name,
[from.x, from.y, from.z],
&mut to,
&mut cancel,
);
// Keep the mod's spot inside the level as vanilla's own draw is (a
// huge y would walk down a long way); one that is not a number keeps
// the draw.
if to.iter().all(|v| v.is_finite()) {
let shape = self.0.level_shapes.get(self.0.dim.id());
let high = shape.min_y + shape.logical_height - 1;
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
*target = Vec3::new(to[0], y, to[2]);
}
Ok(!cancel)
}
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
Ok(self
.0
+1 -1
View File
@@ -1236,7 +1236,7 @@ impl Game<'_> {
"distance": self.settings.horizon.lod_distance,
"effective_distance": self.lod_distance(),
"terrain_distance": self.terrain_distance(),
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else { "local" },
"source": if self.lod.is_none() { "none" } else if self.integrated.is_some() { "integrated" } else if self.server_far_terrain().is_some() { "server" } else if self.remote.is_some() { "server-terrain" } else { "local" },
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
+29 -4
View File
@@ -4519,7 +4519,32 @@ impl Game<'_> {
{
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
}
if p.owner == Some(Who::Player) && self.player.is_some() {
// To where the pearl started its last tick (oldPosition).
let mut to = p.previous;
let mut damage = 5.0;
let mut cancel = false;
// EntityTeleportEvent.EnderPearl: a server player's pearl.
if p.owner == Some(Who::Player)
&& let Some(from) = self.player.as_ref().map(|pl| pl.position)
&& let (Some(uuid), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
let mut target = [to.x, to.y, to.z];
self.native_ender_pearl_teleport(
uuid,
&name,
p.id,
[from.x, from.y, from.z],
&mut target,
&mut damage,
&mut cancel,
);
// A target that is not a number keeps the pearl's.
if target.iter().all(|v| v.is_finite()) {
to = Vec3::new(target[0], target[1], target[2]);
}
}
if p.owner == Some(Who::Player) && self.player.is_some() && !cancel {
// ThrownEnderpearl: 5% endermite where the player was
// (when monsters spawn), teleport, 5 damage.
if self.world.random.next_float() < 0.05
@@ -4530,8 +4555,6 @@ impl Game<'_> {
let from = pl.position;
self.spawn_mob("minecraft:endermite", [from.x, from.y, from.z], false);
}
// To where the pearl started its last tick (oldPosition).
let to = p.previous;
if let Some(pl) = self.player.as_mut() {
pl.position = to;
pl.velocity = Vec3::ZERO;
@@ -4539,7 +4562,9 @@ impl Game<'_> {
pl.teleported = true;
}
self.previous_position = to;
self.hurt_player_by("minecraft:ender_pearl", 5.0);
if damage > 0.0 {
self.hurt_player_by("minecraft:ender_pearl", damage);
}
self.audio.play(
"minecraft:entity.player.teleport",
Some([to.x, to.y, to.z]),
+118
View File
@@ -35,6 +35,17 @@ pub(super) enum ThunderHit {
Item,
}
/// What a blast knows about itself, for the entities in its reach to answer.
#[derive(Clone, Copy)]
pub(super) struct Blast {
/// `VehicleEntity.ignoreExplosion`: a mob's blast, with mob griefing off.
pub(super) vehicles_ignore: bool,
/// `ItemEntity.ignoreExplosion` (`shouldAffectBlocklikeEntities`).
pub(super) items_ignore: bool,
/// The exploding mob, which `getEntities(source, box)` leaves out.
pub(super) spared: Option<u32>,
}
pub(super) trait Entity {
fn position(&self) -> Vec3;
fn position_mut(&mut self) -> &mut Vec3;
@@ -82,6 +93,32 @@ pub(super) trait Entity {
fn thunder_bounds(&self) -> Aabb {
self.bounds()
}
/// `Entity.ignoreExplosion`: the blast neither hurts nor pushes it. Also
/// answers for what `getEntities(source, box)` never lists: the exploding
/// mob, and the dead.
fn ignores_explosion(&self, _blast: &Blast) -> bool {
false
}
/// Whether `Entity.push` moves it. An arrow in the ground, a wind charge and
/// an armour stand (`explosion_breaks_decorations` takes those) stay put.
fn pushed_by_explosion(&self) -> bool {
true
}
/// The box the blast casts its rays at: `getSeenPercent`.
fn explosion_box(&self) -> Aabb {
self.bounds()
}
/// How far above its feet `pushFromExplosion` aims: `getEyeHeight`, which
/// defaults to 0.85 of the box.
fn explosion_eye_height(&self) -> f64 {
let b = self.explosion_box();
f64::from((b.max_position().y - b.min_position().y) as f32 * 0.85f32)
}
/// `Entity.push` with the explosion's knockback.
fn push_from_explosion(&mut self, push: Vec3) {
let v = self.velocity_mut();
*v = Vec3::new(v.x + push.x, v.y + push.y, v.z + push.z);
}
/// `Entity.isSupportedBy`.
fn is_supported_by(&self, _pos: BlockPos) -> bool {
false
@@ -276,6 +313,33 @@ impl Entity for Mob {
fn is_ignoring_block_triggers(&self) -> bool {
self.species() == Species::Bat
}
/// `Warden.ignoreExplosion`: while it digs or emerges.
fn ignores_explosion(&self, blast: &Blast) -> bool {
!self.alive()
|| blast.spared == Some(self.id)
|| (self.species() == Species::Warden
&& horizon_client::mobs::warden::digging_or_emerging(self))
}
/// The kind's own box, not the baby's or the slime's.
fn explosion_box(&self) -> Aabb {
let (feet, half) = (self.position, self.kind.width / 2.0);
Aabb::new(
Vec3::new(feet.x - half, feet.y, feet.z - half),
Vec3::new(feet.x + half, feet.y + self.kind.height, feet.z + half),
)
}
fn explosion_eye_height(&self) -> f64 {
self.kind.eye_height
}
/// `Entity.push` sets `needsSync`.
fn push_from_explosion(&mut self, push: Vec3) {
self.velocity = Vec3::new(
self.velocity.x + push.x,
self.velocity.y + push.y,
self.velocity.z + push.z,
);
self.needs_sync = true;
}
fn is_supported_by(&self, pos: BlockPos) -> bool {
self.supporting == Some([pos.x, pos.y, pos.z])
}
@@ -301,6 +365,12 @@ impl Entity for ItemEntity {
fn thunder_hit(&self) -> ThunderHit {
ThunderHit::Item
}
fn ignores_explosion(&self, blast: &Blast) -> bool {
blast.items_ignore
}
fn explosion_eye_height(&self) -> f64 {
0.2125
}
fn bounds(&self) -> Aabb {
ItemEntity::bounds(self)
}
@@ -324,6 +394,9 @@ impl Entity for ExperienceOrb {
fn thunder_hit(&self) -> ThunderHit {
ThunderHit::Orb
}
fn explosion_eye_height(&self) -> f64 {
f64::from(0.5f32 * 0.85f32)
}
fn bounds(&self) -> Aabb {
ExperienceOrb::bounds(self)
}
@@ -347,6 +420,13 @@ impl Entity for Boat {
fn thunder_hit(&self) -> ThunderHit {
ThunderHit::Vehicle
}
fn ignores_explosion(&self, blast: &Blast) -> bool {
blast.vehicles_ignore
}
/// `EntityType.OAK_BOAT & co`: the full 0.5625.
fn explosion_eye_height(&self) -> f64 {
0.5625
}
fn bounds(&self) -> Aabb {
Boat::bounds(self)
}
@@ -360,6 +440,9 @@ impl Entity for Minecart {
fn thunder_hit(&self) -> ThunderHit {
ThunderHit::Vehicle
}
fn ignores_explosion(&self, blast: &Blast) -> bool {
blast.vehicles_ignore
}
fn bounds(&self) -> Aabb {
Minecart::bounds(self)
}
@@ -387,6 +470,9 @@ impl Entity for ArmorStand {
fn is_ignoring_block_triggers(&self) -> bool {
self.marker
}
fn pushed_by_explosion(&self) -> bool {
false
}
/// `ArmorStand.getPistonPushReaction`: a marker is ignored.
fn get_piston_push_reaction(&self) -> PushReaction {
if self.marker {
@@ -399,6 +485,10 @@ impl Entity for ArmorStand {
impl Entity for PrimedTnt {
stored_motion!();
/// Pushed from its position, not its eyes.
fn explosion_eye_height(&self) -> f64 {
0.0
}
fn bounds(&self) -> Aabb {
PrimedTnt::bounds(self)
}
@@ -422,6 +512,22 @@ impl Entity for FallingBlock {
impl Entity for Projectile {
stored_motion!();
/// `AbstractArrow.push` does nothing in the ground; a wind charge's motion
/// stays 0.
fn pushed_by_explosion(&self) -> bool {
!self.stuck && !self.kind.ignores_blast()
}
fn explosion_eye_height(&self) -> f64 {
self.kind.eye_height()
}
fn push_from_explosion(&mut self, push: Vec3) {
self.velocity = Vec3::new(
self.velocity.x + push.x,
self.velocity.y + push.y,
self.velocity.z + push.z,
);
self.needs_sync = true;
}
fn bounds(&self) -> Aabb {
Projectile::bounds(self)
}
@@ -437,6 +543,10 @@ impl Entity for Projectile {
impl Entity for Rocket {
stored_motion!();
/// 0.25 wide, so 0.2125 to the eyes.
fn explosion_eye_height(&self) -> f64 {
0.2125
}
fn bounds(&self) -> Aabb {
Rocket::bounds(self)
}
@@ -448,6 +558,14 @@ impl Entity for Rocket {
impl Entity for ThrownTrident {
stored_motion!();
/// Like any arrow (`AbstractArrow.push`) not pushed while in the ground.
fn pushed_by_explosion(&self) -> bool {
!self.in_ground
}
/// `EntityType.TRIDENT`: eyes at 0.13.
fn explosion_eye_height(&self) -> f64 {
f64::from(0.13_f32)
}
fn bounds(&self) -> Aabb {
let p = self.position;
Aabb::new(
+177 -315
View File
@@ -2,7 +2,10 @@
//! HashSet, so iteration and the following shuffle match vanilla's random
//! draws), entity damage with exposure and knockback, block interaction with
//! merged drops and TNT chain priming, and optional fire.
use super::Game;
use super::{
Game,
entity::{Blast, Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, mobs::Who, text::Text,
};
@@ -125,6 +128,34 @@ pub(super) fn blast_spares_decorations(
source_in_water || (!mob_griefing && (interaction == BlockInteraction::Keep || by_mob))
}
/// How far the blast's `center` is from `p`.
fn blast_distance(center: [f64; 3], p: [f64; 3]) -> f64 {
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2)).sqrt()
}
/// The unit vector from `center` to `origin`, 0 when they coincide.
fn blast_direction(center: [f64; 3], origin: [f64; 3]) -> [f64; 3] {
let d = [
origin[0] - center[0],
origin[1] - center[1],
origin[2] - center[2],
];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if l < 1.0e-4 {
[0.0; 3]
} else {
[d[0] / l, d[1] / l, d[2] / l]
}
}
/// An entity in the blast's reach: how far (as a share of the blast's
/// diameter), how much of it the rays see, and the knockback.
struct Hit {
dist: f64,
exposure: f32,
push: Vec3,
}
/// A mob in a blast's reach: `hurtServer` when its team allows the damage
/// (`hurtable`), then `pushFromExplosion` whatever that returned, so a mob
/// that refuses the hit (invulnerable, in its hurt cooldown, a wither and an
@@ -472,6 +503,38 @@ impl Game<'_> {
pushed
}
/// Where a blast at `center` reaches `entity`, unless it ignores the blast
/// or is out of range (`getEntities` and `ServerExplosion.hurtEntities`).
/// Generic over the kind, so every walked list gets its own copy.
fn blast_hit<E: Entity>(
&self,
entity: &E,
center: [f64; 3],
diameter: f64,
blast: &Blast,
) -> Option<Hit> {
if entity.ignores_explosion(blast) || !entity.pushed_by_explosion() {
return None;
}
let feet = entity.position();
let dist = blast_distance(center, [feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
return None;
}
let b = entity.explosion_box();
let (lo, hi) = (b.min_position(), b.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let dir = blast_direction(
center,
[feet.x, feet.y + entity.explosion_eye_height(), feet.z],
);
let power = (1.0 - dist) * f64::from(exposure);
Some(Hit {
dist,
exposure,
push: Vec3::new(dir[0] * power, dir[1] * power, dir[2] * power),
})
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`. Returns the players whose clients add a
/// push from the explosion packet, and how much (`explosion_hurt_player`).
@@ -487,27 +550,10 @@ impl Game<'_> {
}
self.explosion_hits_special(center, radius);
let diameter = f64::from(radius * 2.0);
let distance = |p: [f64; 3]| {
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
.sqrt()
};
let damage = |dist: f64, exposure: f32| {
let pow = (1.0 - dist) * f64::from(exposure);
((pow * pow + pow) / 2.0 * 7.0 * diameter + 1.0) as f32
};
let direction = |origin: [f64; 3]| {
let d = [
origin[0] - center[0],
origin[1] - center[1],
origin[2] - center[2],
];
let l = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if l < 1.0e-4 {
[0.0; 3]
} else {
[d[0] / l, d[1] / l, d[2] / l]
}
};
// `source.doTeamsAllowDamage(entity)`: the exploding mob spares its
// team mates when its team has no friendly fire (they are still pushed).
let exploding = self.combat.explosion_source;
@@ -563,306 +609,122 @@ impl Game<'_> {
.or(exploding)
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|m| m.species());
// Mobs, but not the one exploding (`getEntities(source, box)`): a
// wither's spawn blast neither hurts nor pushes it. `Warden.ignoreExplosion`:
// nor does any blast hurt or push a digging or emerging warden.
let spared = exploding.filter(|_| self.combat.explosion_source_named);
let mut mobs = std::mem::take(&mut self.mobs);
for m in mobs.iter_mut().filter(|m| {
m.alive()
&& spared != Some(m.id)
&& !(m.species() == horizon_client::mobs::Species::Warden
&& horizon_client::mobs::warden::digging_or_emerging(m))
}) {
let hurtable = teams_allow(self, self.mob_team(m.id));
let feet = [m.position.x, m.position.y, m.position.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
continue;
}
let (w, h) = (m.kind.width / 2.0, m.kind.height);
let eye = [feet[0], feet[1] + m.kind.eye_height, feet[2]];
let exposure = self.seen_percent(
center,
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
// The exploding mob is the damage source's entity, so victims
// it kills drop its charged-creeper loot (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
// A projectile's owner (`getIndirectSourceEntity`), else
// the exploding mob itself.
attacker: attacker
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
// `ServerExplosion.hurtEntities`: every entity in reach, on the walk. Each
// answers `ignoreExplosion` itself (`Entity::ignores_explosion`); the rest
// is the same for all, and only what a blast does to a kind differs. The
// walk covers the entities there are now: what the blast drops or spawns
// is not hit by it. Destroyed items and orbs are squeezed out afterwards
// (as `retain` does), and a destroyed boat or cart shifts its list, so
// `gone` counts how far.
let blast = Blast {
vehicles_ignore: self.combat.blast_owner.is_none()
&& self.blast_attacker().is_some()
&& !self.ui.rules.bool("mob_griefing"),
items_ignore: items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction),
spared: exploding.filter(|_| self.combat.explosion_source_named),
};
let (items_before, orbs_before) = (self.dropped_ids(), self.orb_ids());
let (items_len, orbs_len) = (self.dropped.len(), self.orbs.len());
let (mut items_kept, mut orbs_kept) = (0, 0);
let (mut boats_gone, mut carts_gone) = (0, 0);
for handle in self.entity_handles() {
let handle = match handle {
EntityHandle::Boat(i) => EntityHandle::Boat(i - boats_gone),
EntityHandle::Cart(i) => EntityHandle::Cart(i - carts_gone),
other => other,
};
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`: its
// base is 0 for every mob type, so iron golems and ravagers are pushed.
let dir = direction(eye);
let power = (1.0 - dist) * f64::from(exposure);
hurt_and_push_mob(
m,
hurtable,
cause,
(damage(dist, exposure), source),
[dir[0] * power, dir[1] * power, dir[2] * power],
);
let hit = with_entity!(self, handle, e => self.blast_hit(e, center, diameter, &blast));
let mut destroyed = false;
if let Some(Hit {
dist,
exposure,
push,
}) = hit
{
let amount = damage(dist, exposure);
match handle {
EntityHandle::Mob(i) => {
let hurtable = teams_allow(self, self.mob_team(self.mobs[i].id));
let m = &mut self.mobs[i];
// The exploding mob is the damage source's entity, so
// victims it kills drop its charged-creeper loot
// (`Creeper.killedEntity`).
let source = horizon_client::mobs::Damage {
kind: horizon_client::mobs::DamageKind::Explosion,
// A projectile's owner (`getIndirectSourceEntity`),
// else the exploding mob itself.
attacker: attacker
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
};
// `EXPLOSION_KNOCKBACK_RESISTANCE`, not `KNOCKBACK_RESISTANCE`:
// its base is 0 for every mob type, so iron golems and
// ravagers are pushed.
hurt_and_push_mob(
m,
hurtable,
cause,
(amount, source),
[push.x, push.y, push.z],
);
}
// 5 health, destroyed by the explosion damage (nether stars resist).
EntityHandle::Item(i) => {
destroyed = &*self.dropped[i].stack.item != "minecraft:nether_star"
&& amount >= 5.0;
if !destroyed {
self.dropped[i].push_from_explosion(push);
}
}
// `ExperienceOrb.hurtServer` takes the damage off their 5 health.
EntityHandle::Orb(i) => {
destroyed = self.orbs[i].hurt(amount);
if !destroyed {
self.orbs[i].push_from_explosion(push);
}
}
// `VehicleEntity.hurtServer` (explosions ignite TNT carts);
// a destroyed one is gone before the push.
EntityHandle::Cart(i) => {
let (id, len) = (self.carts[i].id, self.carts.len());
self.hurt_cart(i, amount, false, true);
carts_gone += len - self.carts.len();
if let Some(c) = self.carts.iter_mut().find(|c| c.id == id) {
c.push_from_explosion(push);
}
}
// `VehicleEntity.hurtServer`: damage * 10, broken past 40.
EntityHandle::Boat(i) => {
let (id, len) = (self.boats[i].id, self.boats.len());
self.hurt_boat(i, amount, false);
boats_gone += len - self.boats.len();
if let Some(b) = self.boats.iter_mut().find(|b| b.id == id) {
b.push_from_explosion(push);
}
}
// `hurtServer` does nothing to the rest; `pushFromExplosion` moves them.
other => with_entity_mut!(self, other, e => e.push_from_explosion(push)),
}
}
match handle {
EntityHandle::Item(i) if !destroyed => {
self.dropped.swap(items_kept, i);
items_kept += 1;
}
EntityHandle::Orb(i) if !destroyed => {
self.orbs.swap(orbs_kept, i);
orbs_kept += 1;
}
_ => {}
}
}
self.mobs = mobs;
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
// first, so a blast that keeps blocks leaves every item untouched.
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
let before = self.dropped_ids();
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
if items_ignore {
return true;
}
let feet = [item.position.x, item.position.y, item.position.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
return true;
}
let half = 0.125;
let exposure = self.seen_percent(
center,
[feet[0] - half, feet[1], feet[2] - half],
[feet[0] + half, feet[1] + 0.25, feet[2] + half],
);
if &*item.stack.item != "minecraft:nether_star" && damage(dist, exposure) >= 5.0 {
return false;
}
let dir = direction([feet[0], feet[1] + 0.2125, feet[2]]);
let power = (1.0 - dist) * f64::from(exposure);
item.velocity = Vec3::new(
item.velocity.x + dir[0] * power,
item.velocity.y + dir[1] * power,
item.velocity.z + dir[2] * power,
);
true
});
self.dropped = items;
self.native_items_left_since(&before);
// Experience orbs: `ExperienceOrb.hurtServer` takes the damage off
// their 5 health, and `pushFromExplosion` moves the ones that stay
// (a 0.5 x 0.5 box, the eyes at 0.85 of its height).
let orbs_before = self.orb_ids();
let mut orbs = std::mem::take(&mut self.orbs);
orbs.retain_mut(|o| {
let feet = [o.position.x, o.position.y, o.position.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
return true;
}
let half = 0.25;
let exposure = self.seen_percent(
center,
[feet[0] - half, feet[1], feet[2] - half],
[feet[0] + half, feet[1] + 0.5, feet[2] + half],
);
if o.hurt(damage(dist, exposure)) {
return false;
}
let dir = direction([feet[0], feet[1] + f64::from(0.5f32 * 0.85f32), feet[2]]);
let power = (1.0 - dist) * f64::from(exposure);
o.velocity = Vec3::new(
o.velocity.x + dir[0] * power,
o.velocity.y + dir[1] * power,
o.velocity.z + dir[2] * power,
);
true
});
self.orbs = orbs;
self.dropped.drain(items_kept..items_len);
self.orbs.drain(orbs_kept..orbs_len);
self.native_items_left_since(&items_before);
self.native_orbs_left_since(&orbs_before);
// Primed TNT: knockback only, from its position (not its eyes).
let mut tnts = std::mem::take(&mut self.tnts);
for t in &mut tnts {
let feet = [t.position.x, t.position.y, t.position.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
continue;
}
let half = 0.49;
let exposure = self.seen_percent(
center,
[feet[0] - half, feet[1], feet[2] - half],
[feet[0] + half, feet[1] + 0.98, feet[2] + half],
);
let dir = direction(feet);
let power = (1.0 - dist) * f64::from(exposure);
t.velocity = Vec3::new(
t.velocity.x + dir[0] * power,
t.velocity.y + dir[1] * power,
t.velocity.z + dir[2] * power,
);
}
self.tnts = tnts;
// `VehicleEntity.ignoreExplosion`: a mob's blast (not a primed TNT's)
// spares boats and minecarts while mob griefing is off.
let vehicles_ignore = self.combat.blast_owner.is_none()
&& self.blast_attacker().is_some()
&& !self.ui.rules.bool("mob_griefing");
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
// then the push from their eyes (0.85 of their height).
let ids: Vec<u32> = if vehicles_ignore {
Vec::new()
} else {
self.carts.iter().map(|c| c.id).collect()
};
for id in ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
let bounds = self.carts[i].bounds();
let feet = self.carts[i].position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
self.hurt_cart(i, damage(dist, exposure), false, true);
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
continue;
};
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
c.velocity = Vec3::new(
c.velocity.x + dir[0] * power,
c.velocity.y + dir[1] * power,
c.velocity.z + dir[2] * power,
);
}
// Boats: `VehicleEntity.hurtServer` (damage * 10, broken past 40), then the
// push from their eyes (`EntityType.OAK_BOAT & co`: the full 0.5625).
let ids: Vec<u32> = if vehicles_ignore {
Vec::new()
} else {
self.boats.iter().map(|b| b.id).collect()
};
for id in ids {
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
continue;
};
let bounds = self.boats[i].bounds();
let feet = self.boats[i].position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
self.hurt_boat(i, damage(dist, exposure), false);
let Some(b) = self.boats.iter_mut().find(|b| b.id == id) else {
continue;
};
let dir = direction([feet.x, feet.y + 0.5625, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
b.velocity = Vec3::new(
b.velocity.x + dir[0] * power,
b.velocity.y + dir[1] * power,
b.velocity.z + dir[2] * power,
);
}
// Falling blocks: `Entity.hurtServer` does nothing, `pushFromExplosion`
// moves them from their eyes (0.85 of the 0.98 box).
let mut falling = std::mem::take(&mut self.falling);
for f in &mut falling {
let feet = f.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let b = horizon_client::falling_block::bounds(feet);
let (lo, hi) = (b.min_position(), b.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
let dir = direction([feet.x, feet.y + eye, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
f.velocity = Vec3::new(
f.velocity.x + dir[0] * power,
f.velocity.y + dir[1] * power,
f.velocity.z + dir[2] * power,
);
}
self.falling = falling;
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
// does nothing while it is in the ground; `hurtServer` never hurts them.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
for p in projectiles
.iter_mut()
.filter(|p| !p.stuck && !p.kind.ignores_blast())
{
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye_height = p.kind.eye_height();
let dir = direction([p.position.x, p.position.y + eye_height, p.position.z]);
let power = (1.0 - dist) * f64::from(exposure);
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
p.needs_sync = true;
}
self.creatures.projectiles = projectiles;
// Firework rockets (0.25 wide, so 0.2125 to the eyes).
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
let feet = r.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.125, feet.y, feet.z - 0.125],
[feet.x + 0.125, feet.y + 0.25, feet.z + 0.125],
);
let dir = direction([feet.x, feet.y + 0.2125, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
r.velocity = Vec3::new(
r.velocity.x + dir[0] * power,
r.velocity.y + dir[1] * power,
r.velocity.z + dir[2] * power,
);
}
self.rockets = rockets;
// Thrown tridents live apart from the other projectiles (`game/trident.rs`): 0.5
// wide and high, eyes at 0.13 (`EntityType.TRIDENT`), and like any arrow
// (`AbstractArrow.push`) not pushed while in the ground.
let mut tridents = std::mem::take(&mut self.tridents);
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
let feet = t.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let exposure = self.seen_percent(
center,
[feet.x - 0.25, feet.y, feet.z - 0.25],
[feet.x + 0.25, feet.y + 0.5, feet.z + 0.25],
);
let dir = direction([feet.x, feet.y + f64::from(0.13_f32), feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
t.velocity = Vec3::new(
t.velocity.x + dir[0] * power,
t.velocity.y + dir[1] * power,
t.velocity.z + dir[2] * power,
);
}
self.tridents = tridents;
// Frames, paintings, cushions and armour stands last: vanilla lists
// the entities in reach before hurting any, so the items they drop
// here are neither destroyed nor pushed by the same blast.
+4 -1
View File
@@ -1821,7 +1821,10 @@ impl Game<'_> {
e.position = [d.position.x, d.position.y, d.position.z];
e.on_ground = d.on_ground;
e.velocity = [d.velocity.x, d.velocity.y, d.velocity.z];
e.precise = d.needs_sync;
// `ServerEntity.getRequiresPrecisePosition` is false for an item:
// `needs_sync` asks for its data again, not for an exact position.
// The collision case is `full_precision_required` in the tracker.
e.precise = false;
e.needs_sync = d.needs_sync;
e.vertical_collision = d.vertical_collision;
e.horizontal_collision = d.horizontal_collision;
+49 -9
View File
@@ -391,11 +391,6 @@ impl Game<'_> {
.get(self.world.block(x, y, z))
.full_collision
}
/// Whether the cart's box touches water where it is now
/// (`EntityFluidInteraction.update`).
fn touches_water(&self, c: &Minecart) -> bool {
self.bounds_in_water(c.bounds())
}
/// `OldMinecartBehavior.getPos`: the point on the rail under (x, y, z).
fn rail_pos(&self, x: f64, y: f64, z: f64) -> Option<[f64; 3]> {
let [xt, yt, zt] = self.rail_block(Vec3::new(x, y, z));
@@ -544,6 +539,9 @@ impl Game<'_> {
c.fall_distance = 0.0;
}
}
// Entity.move ends with applyEffectsFromBlocks: a bubble column lifts
// or drags the cart (tick_cart runs it a second time, like vanilla).
self.cart_block_effects(c);
}
fn max_speed(&self, c: &Minecart) -> f64 {
let base = if c.in_water { 0.2 } else { 0.4 };
@@ -962,6 +960,33 @@ impl Game<'_> {
);
}
}
/// `applyEffectsFromBlocks` for a cart: a bubble column lifts or drags it,
/// and `onInsideBubbleColumn` resets the fall distance.
fn cart_block_effects(&self, c: &mut Minecart) {
if horizon_client::motion::bubble_column_push(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
c.bounds(),
&mut c.velocity,
) {
c.fall_distance = 0.0;
}
}
/// Runs `push_from_cart` for the cart being ticked (`c` is a copy of the
/// stored one): the stored cart is replaced by `c` for the push and `c`
/// takes back what the push changed.
fn push_from_ticked_cart(&mut self, c: &mut Minecart) {
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
return;
};
self.carts[i] = c.clone();
self.push_from_cart(i);
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
*c = self.carts[i].clone();
}
}
/// `AbstractMinecart.tick` for one cart; returns false once it is gone.
fn tick_cart(&mut self, c: &mut Minecart, intent: [f64; 2]) -> bool {
c.first_tick = false;
@@ -1001,8 +1026,25 @@ impl Game<'_> {
c.flipped = !c.flipped;
}
c.yaw %= 360.0;
// updateFluidInteraction, after the move.
c.in_water = self.touches_water(c);
// applyEffectsFromBlocks: a bubble column lifts or drags the cart.
self.cart_block_effects(c);
// OldMinecartBehavior.tick ends with pushAndPickupEntities, before
// AbstractMinecart.tick's updateFluidInteraction.
self.push_from_ticked_cart(c);
// AbstractMinecart.tick: updateFluidInteraction, a stream pushes the cart.
c.in_water = horizon_client::entities::update_entity_fluids(
c.bounds(),
c.position,
c.bounds().max_position().y,
&mut c.velocity,
&self.world,
&self.res.motion().fluids,
)
.0 > 0.0;
// updateFluidInteraction: being in water resets the fall distance.
if c.in_water {
c.fall_distance = 0.0;
}
// Variants.
match c.kind {
CartKind::Furnace => {
@@ -1428,7 +1470,6 @@ impl Game<'_> {
if alive {
let due = std::mem::take(&mut c.command_due);
self.carts[i] = c;
self.push_from_cart(i);
if due {
self.perform_cart_command(id);
}
@@ -1542,7 +1583,6 @@ impl Game<'_> {
};
if alive {
self.carts[i] = c;
self.push_from_cart(i);
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(c.id);
+64 -3
View File
@@ -3215,6 +3215,67 @@ impl Game<'_> {
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
mod_api::dispatch_perform_console_command(self, mod_api::mods(), command, ran);
}
/// A server player's ender pearl landed and is about to teleport them
/// (NeoForge's `EntityTeleportEvent.EnderPearl`, vanilla's
/// `ThrownEnderpearl.onHit` for a player owner): `pearl` is the pearl's
/// entity id, `from` their feet now, `target` where the pearl started
/// its last tick and `damage` the fall damage they then take (5). A mod
/// may move `target` (a value that is not a number is ignored) or change
/// `damage` (0 or less: none). Canceling
/// keeps the player where they are: no endermite roll, no teleport, no
/// damage and no sound; the pearl is used up either way. The player's
/// state is swapped in (`native_acting_player`). The hit itself is
/// heard by `native_projectile_impact`. @API:ender_pearl_teleport@.
pub(super) fn native_ender_pearl_teleport(
&mut self,
player: u128,
name: &str,
pearl: u32,
from: [f64; 3],
target: &mut [f64; 3],
damage: &mut f32,
cancel: &mut bool,
) {
mod_api::dispatch_ender_pearl_teleport(
self,
mod_api::mods(),
player,
name,
pearl,
from,
target,
damage,
cancel,
);
}
/// A server player who ate chorus fruit (or another item with the
/// `teleport_randomly` consume effect) is about to try a random spot
/// (NeoForge's `EntityTeleportEvent.ChorusFruit`, vanilla's
/// `TeleportRandomlyConsumeEffect.apply`): once per attempt (up to 16),
/// after they stopped riding. `from` is their feet, `target` the spot
/// drawn; a mod may move it (its y kept inside the level's height, a
/// value that is not a number ignored), and the game still looks down
/// from it for ground it may land on. Canceling ends the effect: no teleport and no
/// more attempts. The player's state is swapped in
/// (`native_acting_player`). Foxes are not heard. @API:chorus_fruit_teleport@.
pub(super) fn native_chorus_fruit_teleport(
&mut self,
player: u128,
name: &str,
from: [f64; 3],
target: &mut [f64; 3],
cancel: &mut bool,
) {
mod_api::dispatch_chorus_fruit_teleport(
self,
mod_api::mods(),
player,
name,
from,
target,
cancel,
);
}
/// A server player a command teleported (NeoForge's
/// `EntityTeleportEvent`, for a player): `/tp` and `/teleport`, inside
/// their level or into another one. `from` and `to` are their feet before
@@ -3223,9 +3284,9 @@ impl Game<'_> {
/// teleport: when the new level is entered, before it has loaded around
/// them), so it cannot stop it. The player's state is swapped in, so the
/// helpers that read the acting player and `native_tell` work. Once for
/// each teleported player, on a server. Other teleports are not heard
/// yet: ender pearls, chorus fruit, a spectator's teleport menu; portals
/// and respawns are not teleports here. @API:native_player_teleported@.
/// each teleported player, on a server. Ender pearls and chorus fruit
/// have their own hooks; a spectator's teleport menu is not heard yet;
/// portals and respawns are not teleports here. @API:native_player_teleported@.
pub(super) fn native_player_teleported(
&mut self,
player: u128,
+23 -1
View File
@@ -450,6 +450,11 @@ pub(super) struct Remote {
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
pub(super) dimension: DimensionType,
/// The level's key (`minecraft:overworld`): where its far terrain is kept.
pub(super) level: String,
/// The level's hashed seed (login, respawn): the folder of its kept chunks
/// (`server_terrain::level_dir`).
pub(super) hashed_seed: i64,
/// What chunk packets decode with (the dimension's height and sky, the
/// biome registry); replaced whenever one of them changes.
pub(super) chunk_format: Arc<ChunkFormat>,
@@ -960,6 +965,8 @@ impl Game<'_> {
camera: None,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
level: "minecraft:overworld".into(),
hashed_seed: 0,
chunk_format: Arc::new(ChunkFormat::new(
&DimensionType::vanilla("minecraft:overworld"),
Vec::new(),
@@ -2032,6 +2039,8 @@ impl Game<'_> {
.in_column(shape.column()),
);
r.dimension = dimension;
r.level = spawn.dimension.clone();
r.hashed_seed = spawn.seed;
r.game_mode = spawn.game_type;
r.flat = spawn.flat;
r.loaded_sent = false;
@@ -2139,6 +2148,19 @@ impl Game<'_> {
(x - r.chunk_center.0).abs() <= range && (z - r.chunk_center.1).abs() <= range
})
}
/// A column the client no longer holds; far terrain keeps it as it was
/// last (`server_terrain`).
fn forget_remote_chunk(
&mut self,
x: i32,
z: i32,
) -> Option<std::sync::Arc<horizon_client::chunk::Chunk>> {
let chunk = self.streaming.forget(&mut self.world, x, z)?;
if let Some(lod) = &mut self.lod {
lod.observe(chunk.clone(), self.res.facts());
}
Some(chunk)
}
/// The columns a moved or resized storage no longer holds.
fn drop_remote_chunks_out_of_range(&mut self) {
let mut gone: horizon_client::fast_hash::FxHashSet<(i32, i32)> = self
@@ -2162,7 +2184,7 @@ impl Game<'_> {
}
let mut freed = Vec::with_capacity(gone.len());
for &(x, z) in gone.iter() {
freed.extend(self.streaming.forget(&mut self.world, x, z));
freed.extend(self.forget_remote_chunk(x, z));
}
if let Some(renderer) = self.renderer.as_mut() {
renderer.remove_columns(gone.iter().copied());
+34 -6
View File
@@ -62,11 +62,13 @@ impl PrimedTnt {
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
if horizon_client::entities::move_applies(wanted, moved) {
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
}
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
if moved.x != wanted.x {
self.velocity.x = 0.0;
@@ -75,7 +77,19 @@ impl PrimedTnt {
self.velocity.z = 0.0;
}
if moved.y != wanted.y {
self.velocity.y = 0.0;
// Entity.restituteMovementAfterCollisions: slime bounces it.
let below = world.block(
self.position.x.floor() as i32,
(self.position.y - f64::from(1.0e-5_f32)).floor() as i32,
self.position.z.floor() as i32,
);
self.velocity.y = horizon_client::entities::vertical_collision_velocity(
wanted.y,
moved.y,
0.04,
wanted.y < 0.0,
&*facts.get(below).name == "minecraft:slime_block",
);
}
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
// columns it is at the top of send their surface particles.
@@ -89,6 +103,20 @@ impl PrimedTnt {
&mut self.velocity,
|pos| tops.push(pos),
);
if self.on_ground {
// Entity.applyEffectsFromBlocks -> SlimeBlock.stepOn.
let under = world.block(
self.position.x.floor() as i32,
(self.position.y - f64::from(0.2_f32)).floor() as i32,
self.position.z.floor() as i32,
);
if &*facts.get(under).name == "minecraft:slime_block"
&& let Some(scale) = horizon_client::entities::slime_step_on_scale(self.velocity.y)
{
self.velocity.x *= scale;
self.velocity.z *= scale;
}
}
self.velocity = Vec3::new(
self.velocity.x * 0.98,
self.velocity.y * 0.98,
+17
View File
@@ -1204,6 +1204,23 @@ mod tests {
));
}
/// `ServerEntity.getRequiresPrecisePosition` is false for an item, so a
/// drop whose data has to be sent again still moves by a delta; only the
/// collision case takes the exact-position packet.
#[test]
fn an_item_that_needs_its_data_sent_again_still_moves_by_a_delta() {
let mut e = state(0.0);
e.kind = "minecraft:item".into();
e.position = [0.3, 64.0, 0.3];
let mut s = added(&e);
e.needs_sync = true;
e.position = [0.7, 64.0, 0.3];
assert!(matches!(
changes(&mut s, &e)[..],
[play::Clientbound::MoveEntityPos(_)]
));
}
#[test]
fn fireballs_bundle_their_motion_with_their_power() {
let mut e = state(0.0);
+125 -3
View File
@@ -2629,6 +2629,27 @@ pub trait Hooks: Send + Sync {
let _ = event;
Flow::Continue
}
/// A server player who ate chorus fruit (or another item with the
/// `teleport_randomly` consume effect) is about to try a random spot
/// (NeoForge's `EntityTeleportEvent.ChorusFruit`, vanilla's
/// `TeleportRandomlyConsumeEffect.apply`): once per attempt (up to 16),
/// after they stopped riding. `from` is their feet, `target` the spot
/// drawn; a mod may move it (its y kept inside the level's height, a
/// value that is not a number ignored), and the game still looks down
/// from it for ground it may land on. Canceling ends the effect: no teleport and no
/// more attempts. The player's state is swapped in
/// (`native_acting_player`). Foxes are not heard. @API:chorus_fruit_teleport@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn chorus_fruit_teleport(
&self,
host: &mut dyn Host,
event: &mut ChorusFruitTeleportEvent<'_>,
) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// A compostable item was added to the composter at `pos`. @API:compost_item@.
fn compost_item(&self, host: &dyn Host, event: &mut CompostItemEvent<'_>) {
let _ = host;
@@ -2674,6 +2695,28 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A server player's ender pearl landed and is about to teleport them
/// (NeoForge's `EntityTeleportEvent.EnderPearl`, vanilla's
/// `ThrownEnderpearl.onHit` for a player owner): `pearl` is the pearl's
/// entity id, `from` their feet now, `target` where the pearl started
/// its last tick and `damage` the fall damage they then take (5). A mod
/// may move `target` (a value that is not a number is ignored) or change
/// `damage` (0 or less: none). Canceling
/// keeps the player where they are: no endermite roll, no teleport, no
/// damage and no sound; the pearl is used up either way. The player's
/// state is swapped in (`native_acting_player`). The hit itself is
/// heard by `native_projectile_impact`. @API:ender_pearl_teleport@.
///
/// Return `Flow::Cancel` to take it: this sets `cancel`, and later mods are skipped.
fn ender_pearl_teleport(
&self,
host: &mut dyn Host,
event: &mut EnderPearlTeleportEvent<'_>,
) -> Flow {
let _ = host;
let _ = event;
Flow::Continue
}
/// Entities from `chunk` finished loading into the server world. `count`
/// is the number of saved entity records read. @API:native_entities_load@.
fn entities_load(&self, host: &mut dyn Host, event: &mut EntitiesLoadEvent<'_>) {
@@ -3398,9 +3441,9 @@ pub trait Hooks: Send + Sync {
/// teleport: when the new level is entered, before it has loaded around
/// them), so it cannot stop it. The player's state is swapped in, so the
/// helpers that read the acting player and `native_tell` work. Once for
/// each teleported player, on a server. Other teleports are not heard
/// yet: ender pearls, chorus fruit, a spectator's teleport menu; portals
/// and respawns are not teleports here. @API:native_player_teleported@.
/// each teleported player, on a server. Ender pearls and chorus fruit
/// have their own hooks; a spectator's teleport menu is not heard yet;
/// portals and respawns are not teleports here. @API:native_player_teleported@.
fn player_teleported(&self, host: &mut dyn Host, event: &mut PlayerTeleportedEvent<'_>) {
let _ = host;
let _ = event;
@@ -8944,6 +8987,42 @@ pub fn dispatch_cauldron_level_changed(
}
}
}
/// What [`Hooks::chorus_fruit_teleport`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ChorusFruitTeleportEvent<'a> {
pub player: u128,
pub name: &'a str,
pub from: [f64; 3],
pub target: &'a mut [f64; 3],
}
pub fn dispatch_chorus_fruit_teleport(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
from: [f64; 3],
target: &mut [f64; 3],
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = ChorusFruitTeleportEvent {
player,
name,
from,
target,
};
for mod_hook in mods {
if matches!(
mod_hook.chorus_fruit_teleport(host, &mut event),
Flow::Cancel
) {
*cancel = true;
break;
}
}
}
/// What [`Hooks::compost_item`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct CompostItemEvent<'a> {
@@ -9083,6 +9162,49 @@ pub fn dispatch_enchant_item(
mod_hook.enchant_item(host, &mut event);
}
}
/// What [`Hooks::ender_pearl_teleport`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct EnderPearlTeleportEvent<'a> {
pub player: u128,
pub name: &'a str,
pub pearl: u32,
pub from: [f64; 3],
pub target: &'a mut [f64; 3],
pub damage: f32,
}
pub fn dispatch_ender_pearl_teleport(
host: &mut dyn Host,
mods: &[Box<dyn Hooks>],
player: u128,
name: &str,
pearl: u32,
from: [f64; 3],
target: &mut [f64; 3],
damage: &mut f32,
cancel: &mut bool,
) {
if *cancel {
return;
}
let mut event = EnderPearlTeleportEvent {
player,
name,
pearl,
from,
target,
damage: *damage,
};
for mod_hook in mods {
if matches!(
mod_hook.ender_pearl_teleport(host, &mut event),
Flow::Cancel
) {
*cancel = true;
break;
}
}
*damage = event.damage;
}
/// What [`Hooks::entities_load`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct EntitiesLoadEvent<'a> {
+130 -1
View File
@@ -26,6 +26,13 @@ pub trait TeleportConsumeWorld {
fn is_passenger(&mut self) -> Result<bool, Self::Error>;
fn stop_riding(&mut self) -> Result<(), Self::Error>;
fn position(&mut self) -> Result<Vec3, Self::Error>;
/// Horizon's mods (NeoForge's `EntityTeleportEvent.ChorusFruit`): before
/// each attempt, after the user stopped riding. They may move `target`;
/// false stops the effect without teleporting and without more draws.
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Self::Error> {
let _ = target;
Ok(true)
}
/// showParticles=true and forbidden tag=CONSUMABLE_DOES_NOT_TELEPORT_TO.
fn random_teleport(&mut self, position: Vec3) -> Result<bool, Self::Error>;
/// Level TELEPORT event, old position, context containing this actor.
@@ -91,7 +98,11 @@ impl TeleportConsume {
world.stop_riding()?;
}
let old = world.position()?;
if world.random_teleport(Vec3::new(x, y, z))? {
let mut target = Vec3::new(x, y, z);
if !world.teleport_allowed(&mut target)? {
return Ok(false);
}
if world.random_teleport(target)? {
world.teleport_event(level, old)?;
let fox = world.is_fox();
world.teleport_sound(level, world.position_field(), fox)?;
@@ -116,3 +127,121 @@ impl TeleportConsume {
Ok(false)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_core::random::LegacyRandom;
use std::convert::Infallible;
/// A user at the origin whose every landing fails unless `lands`; it
/// records the targets tried and how the mods answer.
struct Stub {
random: LegacyRandom,
allow: fn(&mut Vec3) -> bool,
lands: bool,
asked: usize,
tried: Vec<Vec3>,
}
impl TeleportConsumeWorld for Stub {
type Error = Infallible;
type Level = ();
type Random = ();
type Source = LegacyRandom;
fn position_field(&self) -> Vec3 {
Vec3::ZERO
}
fn get_random(&mut self) -> Result<(), Infallible> {
Ok(())
}
fn random_source(&mut self, (): ()) -> &mut LegacyRandom {
&mut self.random
}
fn min_y(&mut self, (): ()) -> Result<i32, Infallible> {
Ok(-64)
}
fn logical_height(&mut self, (): ()) -> Result<i32, Infallible> {
Ok(384)
}
fn is_passenger(&mut self) -> Result<bool, Infallible> {
Ok(false)
}
fn stop_riding(&mut self) -> Result<(), Infallible> {
Ok(())
}
fn position(&mut self) -> Result<Vec3, Infallible> {
Ok(Vec3::ZERO)
}
fn teleport_allowed(&mut self, target: &mut Vec3) -> Result<bool, Infallible> {
self.asked += 1;
Ok((self.allow)(target))
}
fn random_teleport(&mut self, position: Vec3) -> Result<bool, Infallible> {
self.tried.push(position);
Ok(self.lands)
}
fn teleport_event(&mut self, (): (), _: Vec3) -> Result<(), Infallible> {
Ok(())
}
fn is_fox(&self) -> bool {
false
}
fn teleport_sound(&mut self, (): (), _: Vec3, _: bool) -> Result<(), Infallible> {
Ok(())
}
fn block_position(&mut self) -> Result<BlockPos, Infallible> {
Ok(BlockPos::new(0, 0, 0))
}
fn directional_particles(&mut self, (): (), _: BlockPos, _: i32) -> Result<(), Infallible> {
Ok(())
}
fn reset_fall_distance(&mut self) -> Result<(), Infallible> {
Ok(())
}
fn reset_impulse(&mut self) -> Result<(), Infallible> {
Ok(())
}
}
fn stub(allow: fn(&mut Vec3) -> bool, lands: bool) -> Stub {
Stub {
random: LegacyRandom::new(7),
allow,
lands,
asked: 0,
tried: Vec::new(),
}
}
const CHORUS: TeleportConsume = TeleportConsume {
diameter: 16.0,
directional_particles: false,
};
#[test]
fn a_refused_attempt_ends_the_effect_without_teleporting() {
let mut w = stub(|_| false, true);
assert_eq!(CHORUS.apply(&mut w, ()), Ok(false));
assert_eq!((w.asked, w.tried.len()), (1, 0), "asked once, never tried");
}
#[test]
fn the_mods_target_is_where_the_user_tries_to_land() {
let mut w = stub(
|t| {
*t = Vec3::new(100.5, 70.0, -3.5);
true
},
true,
);
assert_eq!(CHORUS.apply(&mut w, ()), Ok(true));
assert_eq!(w.tried, [Vec3::new(100.5, 70.0, -3.5)]);
}
#[test]
fn without_mods_all_sixteen_attempts_stay_vanillas() {
let mut w = stub(|_| true, false);
assert_eq!(CHORUS.apply(&mut w, ()), Ok(false));
assert_eq!((w.asked, w.tried.len()), (16, 16));
// Each target is within half the diameter of the user.
assert!(w.tried.iter().all(|t| t.x.abs() <= 8.0 && t.z.abs() <= 8.0));
}
}
+8
View File
@@ -176,6 +176,14 @@ and the container screen tests are unchanged).
- `chatIndicator` off removes the coloured bar at the left of a line. It hides
the bar only, never the message.
### Scroll wheel changes settings (`wheelCyclesSettings`, default on)
- On, the wheel works as in vanilla: over a button in the settings it changes the button's value
(`CycleButton.mouseScrolled`), and a button focused with the keyboard or a pad steps on the
wheel wherever the cursor is.
- Off, the wheel only scrolls the list under the cursor, on every settings page, Video's included
(`OptionsUi::wheel`, `GraphicsUi::wheel`). Scrolling through a long page then can't change an
option that passes under the cursor (#5455). Client-only.
### Splitscreen (`splitscreen`, default off)
**Options → Horizon Settings... → Splitscreen...** turns local joining on or off at once.
+3 -1
View File
@@ -63,11 +63,13 @@ A full opaque cube means `RenderShape::Model`, `can_occlude`, `solid_render` and
Distant terrain uses progressively coarser voxel columns. Columns retain separate material layers and air gaps; matching exposed faces are merged, with block-scale texture repeats. Complete chunk data is reduced once on the terrain worker and cached at each supported scale, across each cell’s area instead of enlarging its corner column. Material votes combine different light levels of the same block; reduced cells retain the brightest sampled sky and block light separately. Finer tiles replace only their own footprints in a loading fallback, and a movement margin limits rapid resolution changes at tile boundaries. When Full Detail Distance shrinks, the chunks it no longer covers stay drawn until the tiles planned there are ready, so the coarsest loaded tiles never stand in for them. Local Overworld, Nether and End worlds and a LAN world you host can supply their generator. Remote servers do not supply the client's world-generation seed or generator configuration.
On a server that doesn't sample far terrain for you (any vanilla, Paper or Spigot server; Horizon servers that do are below), distant terrain is what the server has sent: every chunk you load is kept as its reduced columns in `server-terrain/<server>/<seed>/<namespace>/<dimension>/` (Anvil region files, about a sector a chunk; `<seed>` is the hashed seed the server sends, which keeps the worlds behind one address apart) in the game folder, and drawn beyond the server's view distance on this visit and later ones. Nothing is pruned: delete `server-terrain/<server>` to forget a server (a server whose plugin sends another hashed seed at every login starts a new folder each time; the worlds of a proxy whose servers all hide their seed as 0 share one). A friend's world is not kept (its invite code changes at every share), nor a splitscreen guest's (it is the player's own world). Areas you have never loaded stay empty; far off, where a cell of far terrain is wider than a chunk, it is drawn from the kept chunk nearest its middle. A chunk the server sends again replaces the kept one; changes made while you are away show on your next visit. The kept columns are tied to the game version; after an update a server's terrain fills in again as you play.
Unknown areas are sampled directly from the dimension's seed-bound density and surface rules, with shared per-tile scratch and material evaluation. A small pool samples tiles independently of meshing. Refinement prioritizes visible screen error; high aerial views use the distance to the world height range to avoid unnecessary close detail below the camera. Initial vegetation uses the world's configured tree types, placement densities and biome assignments at every distance level, and chorus plants on the End's end stone in the same way. The End's small islands are estimated above the void from the `end_island_decorated` feature. Tree, chorus and island positions, shapes and large-cell coverage are visual approximations, not a replay of native feature placement; chorus is drawn only while its cells stay small enough to read as a plant.
A separate bounded worker refines the nearest LOD levels with disposable ordinary chunk generation, including supported carvers, structures and decoration. Groups of 4×4 chunks share their generation halos, and each chunk is published once every neighbouring feature center has completed. Farther tiles use direct sampling rather than waiting for every intervening ordinary chunk. Saved and visited blocks take precedence over both paths. These temporary chunks never enter gameplay, simulation or normal save queues. Generated-column caches and request queues are bounded; far data is regenerated after reopening a world.
On a dedicated server, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
On a Horizon dedicated server that offers it, the client has no generator of the world: the server samples the far tiles for it over Horizon's own `horizon:lod` channel, out to the distance its owner allows (`horizon-lod-distance`, off unless the owner sets it: see [hosting](hosting.md)), and the client's LOD Distance stops there. Those tiles are sampled from the generator, without the decoration refinement above; the chunks the server sent are summarized into far terrain as in single player. A friend's open-to-LAN world doesn't offer far terrain yet: it has no setting for that distance.
Coverage and detail improve progressively; a large selected radius does not mean all its target detail is already resident. Far sampling runs behind the game's frame and tick threads, on half the machine's threads (at most 8) from 8 threads up and one below that; it queues its full share of tiles only once the nearby chunks have loaded. On machines with four threads or fewer it samples one tile every 500 ms and meshes nothing while nearby chunks are generated, lit or meshed for the first time; remeshing chunks already drawn doesn't hold it back. The generated detail uses a third of the threads, rounded up, at idle priority. The control status reports target and sampled target tile counts separately (`lod.target_tiles`, `lod.sampled_target_tiles`), and the far columns that take generated detail and how many have it (`lod.detail_target_columns`, `lod.refined_target_columns`). Direct far sampling approximates vertical noise interpolation, fluid boundaries and lighting; surface slopes and column extensions, small features and partial block shapes can differ. Unexplored distant structures are not synthesized by the direct path, and native decoration order can differ from subsequent gameplay generation. Horizon-supported data-driven noise and tree configurations can be reused; this does not execute arbitrary Fabric/NeoForge worldgen or structure mods.
+15 -2
View File
@@ -1424,8 +1424,21 @@ For mods that run where the world is simulated: a dedicated server, a LAN host,
before and after and `dimension` the level they are in now. Heard after the move has reached the player's state and
their client (into another level: when that level is entered, before it has loaded around them), so it cannot stop it
(NeoForge's `EntityTeleportEvent`). The player's state is swapped in, so `native_tell` and the acting-player helpers
work. Not heard yet: ender pearls, chorus fruit, a spectator's teleport menu; portals and respawns are not teleports
here.
work. Ender pearls and chorus fruit have their own hooks below; not heard yet: a spectator's teleport menu; portals
and respawns are not teleports here.
- **`native_ender_pearl_teleport(player, name, pearl, from, target, damage, cancel)`**: a server player's ender pearl
landed and is about to teleport them (NeoForge's `EntityTeleportEvent.EnderPearl`, `ThrownEnderpearl.onHit`).
`pearl` is the pearl's entity id, `from` their feet, `target` where the pearl started its last tick and `damage` the
fall damage they then take (5). A mod may move `target` or change `damage` (0 or less: none). Canceling keeps the
player where they are: no endermite roll, no teleport, no damage, no sound; the pearl is used up either way. Mobs'
pearls are not heard. Bundles need `"hooks": @API:ender_pearl_teleport@`.
- **`native_chorus_fruit_teleport(player, name, from, target, cancel)`**: a server player whose `teleport_randomly`
consume effect (chorus fruit) is about to try a spot (NeoForge's `EntityTeleportEvent.ChorusFruit`,
`TeleportRandomlyConsumeEffect.apply`): once per attempt, up to 16, after they stopped riding. A mod may move
`target` (its y is kept inside the level's height); the game still looks down from it for ground to land on, inside
the border. Canceling ends the effect: no
teleport and no more attempts. Foxes eating chorus fruit are not heard. Bundles need
`"hooks": @API:chorus_fruit_teleport@`.
- **`native_player_commands_sent(player, name, names)`**: the command tree a player is sent, when they join and when
their level changes (`/op`, `/deop`, the LAN settings). `names` are the root commands their client would get, vanilla's
they may use and the mods'; remove a name and that client's tree lacks it, so its chat box neither completes nor
+2
View File
@@ -445,6 +445,8 @@ const NOT_COPIED: &[&str] = &[
"telemetry",
"mod-browser",
".horizon-cache",
// Far terrain's copies of what servers sent, rebuilt as the instance plays.
"server-terrain",
];
/// Files of the player, not of one instance, that a new instance starts with: the keys and video
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "chorus_fruit_teleport",
"target": "horizon_client::game::native_hooks::Game<'_>::native_chorus_fruit_teleport",
"neoforge": "EntityTeleportEvent.ChorusFruit",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a server player who ate chorus fruit is about to try a random spot (`native_chorus_fruit_teleport`).",
"doc_summary": "a server player's `teleport_randomly` consume effect (chorus fruit) is about to try a spot: `native_chorus_fruit_teleport(player, name, from, target, cancel)`, once per attempt (up to 16). A mod may move `target`; the game still looks down from it for ground to land on. `cancel` ends the effect with no teleport and no more attempts. Foxes are not heard. Bundles need \"hooks\": @API:chorus_fruit_teleport@."
}
@@ -0,0 +1,17 @@
{
"hooks": [
{
"name": "ender_pearl_teleport",
"target": "horizon_client::game::native_hooks::Game<'_>::native_ender_pearl_teleport",
"neoforge": "EntityTeleportEvent.EnderPearl",
"side": "server",
"cancellable": true,
"mod_api": {
"contract": "cancel",
"out": "cancel"
}
}
],
"rust_summary": "a server player's ender pearl is about to teleport them (`native_ender_pearl_teleport`).",
"doc_summary": "a server player's ender pearl landed: `native_ender_pearl_teleport(player, name, pearl, from, target, damage, cancel)` runs before the endermite roll and the teleport. A mod may move `target` or change the fall `damage` (5; 0 or less is none); `cancel` keeps the player where they are, without the endermite roll, damage or sound, and the pearl is used up either way. Bundles need \"hooks\": @API:ender_pearl_teleport@."
}
+6 -6
View File
@@ -40,27 +40,27 @@ FaceInfo.Extent.select skip:data corner selection; Horizon's unit-cube corner t
FaceInfo.VertexInfo.select skip:data corner selection; Horizon's unit-cube corner table PORTAL_FACES in block_entity_render.rs has the same order
FirstPersonHandsAndItemsRenderer.calculateMapTilt inline crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry tilt computed inline with the calculateMapTilt comment
FirstPersonHandsAndItemsRenderer.renderPlayerHand port crates/horizon-client/src/game.rs::Game::hand_geometry empty main hand's arm model with the player skin
FirstPersonHandsAndItemsRenderer.renderMapHand missing player: holding a map in both hands, neither arm is drawn behind it (vanilla draws both arms)
FirstPersonHandsAndItemsRenderer.renderMapHand port crates/horizon-client/src/first_person.rs::map_arm_poses (MapHold::TwoHanded) both arms behind a two-handed map, at the jar's rotations and offsets
FirstPersonHandsAndItemsRenderer.renderOneHandedMap port crates/horizon-client/src/first_person.rs::one_handed_map_pose gap: player: the arm holding a map is not drawn; the map hand branch skips the arm
FirstPersonHandsAndItemsRenderer.renderTwoHandedMap port crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry gap: player: the arms that hold a two-handed map are not drawn
FirstPersonHandsAndItemsRenderer.renderMap port crates/horizon-client/src/game/maps.rs::Game::held_map map background with its 7-pixel border, then the map contents
FirstPersonHandsAndItemsRenderer.applyEatTransform port crates/horizon-client/src/first_person.rs::item_pose ItemPose::Eat: bob and jiggle while eating or drinking
FirstPersonHandsAndItemsRenderer.applyBrushTransform missing player: a brush in use is held still; it does not sweep back and forth while brushing
FirstPersonHandsAndItemsRenderer.applyBrushTransform port crates/horizon-client/src/first_person.rs::brush_transform the brush sweep while brushing, term for term
FirstPersonHandsAndItemsRenderer.applyItemArmAttackTransform port crates/horizon-client/src/first_person.rs::item_pose attack swing rotation in the Swing pose
FirstPersonHandsAndItemsRenderer.applyItemArmTransform port crates/horizon-client/src/first_person.rs::item_pose base item-in-arm translation
FirstPersonHandsAndItemsRenderer.submitHandsWithItems port crates/horizon-client/src/game.rs::Game::hand_geometry sway, both hands, empty-hand arm and item pose
FirstPersonHandsAndItemsRenderer.swingArm port crates/horizon-client/src/first_person.rs::item_pose attack swing translation and arm rotation
GameRenderer.<init> port crates/horizon-client-render/src/gfx.rs::Renderer::new lightmap, hand target, debug crosshair and screen effects are built here
GameRenderer.close skip:other GPU resource teardown; wgpu drops the renderer's objects
GameRenderer.checkEntityPostEffect missing player: spectating a creeper, spider or enderman does not put their overlay shader on the screen
GameRenderer.checkEntityPostEffect port crates/horizon-client/src/game/remote_camera.rs::entity_post_effect spectating maps creeper to minecraft:creeper, spider and cave spider to minecraft:spider, enderman to minecraft:invert, and a first-person camera clears it
GameRenderer.processBlurEffect port crates/horizon-client-render/src/menu_blur.rs::MenuBlur::apply menu background blur pass
GameRenderer.preloadUiShader skip:other GPU pipeline precompile at load; Horizon builds its pipelines in Renderer::new
GameRenderer.update port crates/horizon-client-render/src/camera.rs::AvatarBob::update_bob gap: minor: the requested post-effect list (from /postEffect) is not built
GameRenderer.extract skip:other render-state copy for the vanilla render thread; Horizon renders from game state
GameRenderer.preparePostEffects port crates/horizon-client-render/src/gfx.rs::post_effects (post_chain::PostEffects, built at gfx.rs:8424) vanilla post chains are made on first use from the request the game builds
GameRenderer.applyPostEffects port crates/horizon-client-render/src/gfx.rs::post_effects the same chain runs after the world pass
GameRenderer.tryTakeScreenshotIfNeeded missing player: a new singleplayer world gets no icon.png, so the world list shows no preview picture
GameRenderer.takeAutoScreenshot missing player: no icon.png is written from the first rendered frame; the world list keeps a blank picture
GameRenderer.tryTakeScreenshotIfNeeded port crates/horizon-client/src/game.rs::tick_world_icon (called at game.rs:17852) a local world without an icon requests a 64x64 ScreenshotRequest for saves/<folder>/icon.png once the streamer, uploads and backlog are idle and more than 10 sections are drawn
GameRenderer.takeAutoScreenshot inline crates/horizon-client/src/game.rs::tick_world_icon the same retry, once a second until the file exists; the path comes from game/integrated.rs:485
GameRenderer.integrate3DHudDepth inline crates/horizon-client-render/src/gfx.rs::post_effects runs with a post chain applied, which is vanilla's consistentDepthRequired condition
GameRenderer.extractWindow skip:other render-state copy of the window size; Horizon reads the window directly
GameRenderer.extractOptions skip:other render-state copy of options; Horizon reads settings directly
@@ -100,7 +100,7 @@ LevelRenderer.invalidateCompiledGeometry port crates/horizon-client-render/src/g
LevelRenderer.scheduleTranslucentSectionResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update resort scheduled when the camera point of view changes
LevelRenderer.scheduleResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update per-section resort gated by point-of-view change and translucent geometry
LevelRenderer.resetLevelRenderData port crates/horizon-client-render/src/gfx.rs::Renderer::clear_sections section data released when a level is left
LevelRenderer.hasRenderedAllSections missing minor: only the world icon screenshot asks this, and that screenshot is missing
LevelRenderer.hasRenderedAllSections inline crates/horizon-client/src/game.rs::tick_world_icon renderer.drawn_sections > 10: the icon waits on the same count vanilla's hasRenderedAllSections asks for
LevelRenderer.isSectionCompiledAndVisible port crates/horizon-client-render/src/gfx/resident.rs::Resident::contains gap: minor: no section fade-in, so the 0.3 visibility threshold is not checked
LevelRenderer.finalizeGizmoCollection port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos debug gizmos collected for the frame
LevelTargetBundle.replace skip:other render-target bundle for post chains; Horizon has no frame graph
1 CloudRenderer.getSizeForCloudDistance skip:java GPU ring-buffer capacity for the cloud face buffer; Horizon sizes its vertex buffer from the built mesh
40 FaceInfo.VertexInfo.select skip:data corner selection; Horizon's unit-cube corner table PORTAL_FACES in block_entity_render.rs has the same order
41 FirstPersonHandsAndItemsRenderer.calculateMapTilt inline crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry tilt computed inline with the calculateMapTilt comment
42 FirstPersonHandsAndItemsRenderer.renderPlayerHand port crates/horizon-client/src/game.rs::Game::hand_geometry empty main hand's arm model with the player skin
43 FirstPersonHandsAndItemsRenderer.renderMapHand missing port crates/horizon-client/src/first_person.rs::map_arm_poses (MapHold::TwoHanded) player: holding a map in both hands, neither arm is drawn behind it (vanilla draws both arms) both arms behind a two-handed map, at the jar's rotations and offsets
44 FirstPersonHandsAndItemsRenderer.renderOneHandedMap port crates/horizon-client/src/first_person.rs::one_handed_map_pose gap: player: the arm holding a map is not drawn; the map hand branch skips the arm
45 FirstPersonHandsAndItemsRenderer.renderTwoHandedMap port crates/horizon-client/src/game/maps.rs::Game::two_handed_map_geometry gap: player: the arms that hold a two-handed map are not drawn
46 FirstPersonHandsAndItemsRenderer.renderMap port crates/horizon-client/src/game/maps.rs::Game::held_map map background with its 7-pixel border, then the map contents
47 FirstPersonHandsAndItemsRenderer.applyEatTransform port crates/horizon-client/src/first_person.rs::item_pose ItemPose::Eat: bob and jiggle while eating or drinking
48 FirstPersonHandsAndItemsRenderer.applyBrushTransform missing port crates/horizon-client/src/first_person.rs::brush_transform player: a brush in use is held still; it does not sweep back and forth while brushing the brush sweep while brushing, term for term
49 FirstPersonHandsAndItemsRenderer.applyItemArmAttackTransform port crates/horizon-client/src/first_person.rs::item_pose attack swing rotation in the Swing pose
50 FirstPersonHandsAndItemsRenderer.applyItemArmTransform port crates/horizon-client/src/first_person.rs::item_pose base item-in-arm translation
51 FirstPersonHandsAndItemsRenderer.submitHandsWithItems port crates/horizon-client/src/game.rs::Game::hand_geometry sway, both hands, empty-hand arm and item pose
52 FirstPersonHandsAndItemsRenderer.swingArm port crates/horizon-client/src/first_person.rs::item_pose attack swing translation and arm rotation
53 GameRenderer.<init> port crates/horizon-client-render/src/gfx.rs::Renderer::new lightmap, hand target, debug crosshair and screen effects are built here
54 GameRenderer.close skip:other GPU resource teardown; wgpu drops the renderer's objects
55 GameRenderer.checkEntityPostEffect missing port crates/horizon-client/src/game/remote_camera.rs::entity_post_effect player: spectating a creeper, spider or enderman does not put their overlay shader on the screen spectating maps creeper to minecraft:creeper, spider and cave spider to minecraft:spider, enderman to minecraft:invert, and a first-person camera clears it
56 GameRenderer.processBlurEffect port crates/horizon-client-render/src/menu_blur.rs::MenuBlur::apply menu background blur pass
57 GameRenderer.preloadUiShader skip:other GPU pipeline precompile at load; Horizon builds its pipelines in Renderer::new
58 GameRenderer.update port crates/horizon-client-render/src/camera.rs::AvatarBob::update_bob gap: minor: the requested post-effect list (from /postEffect) is not built
59 GameRenderer.extract skip:other render-state copy for the vanilla render thread; Horizon renders from game state
60 GameRenderer.preparePostEffects port crates/horizon-client-render/src/gfx.rs::post_effects (post_chain::PostEffects, built at gfx.rs:8424) vanilla post chains are made on first use from the request the game builds
61 GameRenderer.applyPostEffects port crates/horizon-client-render/src/gfx.rs::post_effects the same chain runs after the world pass
62 GameRenderer.tryTakeScreenshotIfNeeded missing port crates/horizon-client/src/game.rs::tick_world_icon (called at game.rs:17852) player: a new singleplayer world gets no icon.png, so the world list shows no preview picture a local world without an icon requests a 64x64 ScreenshotRequest for saves/<folder>/icon.png once the streamer, uploads and backlog are idle and more than 10 sections are drawn
63 GameRenderer.takeAutoScreenshot missing inline crates/horizon-client/src/game.rs::tick_world_icon player: no icon.png is written from the first rendered frame; the world list keeps a blank picture the same retry, once a second until the file exists; the path comes from game/integrated.rs:485
64 GameRenderer.integrate3DHudDepth inline crates/horizon-client-render/src/gfx.rs::post_effects runs with a post chain applied, which is vanilla's consistentDepthRequired condition
65 GameRenderer.extractWindow skip:other render-state copy of the window size; Horizon reads the window directly
66 GameRenderer.extractOptions skip:other render-state copy of options; Horizon reads settings directly
100 LevelRenderer.scheduleTranslucentSectionResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update resort scheduled when the camera point of view changes
101 LevelRenderer.scheduleResort port crates/horizon-client-render/src/gfx/translucency.rs::SortState::update per-section resort gated by point-of-view change and translucent geometry
102 LevelRenderer.resetLevelRenderData port crates/horizon-client-render/src/gfx.rs::Renderer::clear_sections section data released when a level is left
103 LevelRenderer.hasRenderedAllSections missing inline crates/horizon-client/src/game.rs::tick_world_icon minor: only the world icon screenshot asks this, and that screenshot is missing renderer.drawn_sections > 10: the icon waits on the same count vanilla's hasRenderedAllSections asks for
104 LevelRenderer.isSectionCompiledAndVisible port crates/horizon-client-render/src/gfx/resident.rs::Resident::contains gap: minor: no section fade-in, so the 0.3 visibility threshold is not checked
105 LevelRenderer.finalizeGizmoCollection port crates/horizon-client/src/game/debug_keys.rs::Game::debug_gizmos debug gizmos collected for the frame
106 LevelTargetBundle.replace skip:other render-target bundle for post chains; Horizon has no frame graph
+1 -1
View File
@@ -8,7 +8,7 @@ Rect2i.contains skip:ui GUI hit test on a rectangle; Horizon's widgets hit-test
RenderPipelines.register skip:data pipeline registry; Horizon creates its pipelines in gfx.rs
RunningTrimmedMean.registerValueAndGetMean skip:debug frame-time smoothing helper; no vanilla caller in this source tree
ScreenEffectRenderer.submit port crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build gap: minor: the item activation is not built here (see renderItemActivationAnimation)
ScreenEffectRenderer.renderItemActivationAnimation missing player: when a totem of undying saves the player, the totem does not pop up in front of the screen; Horizon only plays the totem particles and sound (game/totem.rs totem_event)
ScreenEffectRenderer.renderItemActivationAnimation port crates/horizon-client/src/game.rs::itemActivation + tick_totem (game.rs:2205, 7139) the totem flying up to the screen when it saves the player
ScreenEffectRenderer.submitWater inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build underwater overlay branch (misc/underwater)
ScreenEffectRenderer.submitFire inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build first-person fire overlay branch (block/fire_1, two quads)
ScreenEffectRenderer.buildFireQuad inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build the two fire quads' corners and offsets
1 Projection.setupPerspective inline crates/horizon-client-render/src/camera.rs::perspective gap: minor: the dirty-flag cache and the stored fov/near/far are not kept; the matrix is built from the arguments per call
8 RenderPipelines.register skip:data pipeline registry; Horizon creates its pipelines in gfx.rs
9 RunningTrimmedMean.registerValueAndGetMean skip:debug frame-time smoothing helper; no vanilla caller in this source tree
10 ScreenEffectRenderer.submit port crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build gap: minor: the item activation is not built here (see renderItemActivationAnimation)
11 ScreenEffectRenderer.renderItemActivationAnimation missing port crates/horizon-client/src/game.rs::itemActivation + tick_totem (game.rs:2205, 7139) player: when a totem of undying saves the player, the totem does not pop up in front of the screen; Horizon only plays the totem particles and sound (game/totem.rs totem_event) the totem flying up to the screen when it saves the player
12 ScreenEffectRenderer.submitWater inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build underwater overlay branch (misc/underwater)
13 ScreenEffectRenderer.submitFire inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build first-person fire overlay branch (block/fire_1, two quads)
14 ScreenEffectRenderer.buildFireQuad inline crates/horizon-client-render/src/screen_effects.rs::ScreenEffects::build the two fire quads' corners and offsets
+1 -1
View File
@@ -25,7 +25,7 @@ ParticleDescription.fromJson port crates/horizon-client-render/src/particles.rs:
ParticleEngine.makeParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: VibrationSignal options return None here; ParticleEngine::vibration creates them
ParticleEngine.createParticleGroup skip:other no render-type groups: ParticleEngine::render draws every particle in one pass, and the ITEM_PICKUP group is tick_pickups
ParticleEngine.countParticles skip:debug
ParticleEngine.hasSpaceInParticleLimit missing minor: no per-type particle cap; vanilla limits live SPORE_BLOSSOM air particles to 1000
ParticleEngine.hasSpaceInParticleLimit port crates/horizon-client-render/src/particles.rs (test spore_blossom_air_stops_at_its_particle_limit) per-type particle caps, pinned by that test
ParticleEngine.clearParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::clear clears emitters, live and pending particles; no limit counts to clear
ParticleGroup.tickParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick ticks live particles and drops the removed ones
ParticleGroup.tickParticle skip:debug crash-report wrapper around particle.tick
1 HugeExplosionParticle.tick port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick_particle Behaviour::HugeExplosion: age, sprite from age, removed at lifetime
25 ParticleEngine.makeParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::make gap: minor: VibrationSignal options return None here; ParticleEngine::vibration creates them
26 ParticleEngine.createParticleGroup skip:other no render-type groups: ParticleEngine::render draws every particle in one pass, and the ITEM_PICKUP group is tick_pickups
27 ParticleEngine.countParticles skip:debug
28 ParticleEngine.hasSpaceInParticleLimit missing port crates/horizon-client-render/src/particles.rs (test spore_blossom_air_stops_at_its_particle_limit) minor: no per-type particle cap; vanilla limits live SPORE_BLOSSOM air particles to 1000 per-type particle caps, pinned by that test
29 ParticleEngine.clearParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::clear clears emitters, live and pending particles; no limit counts to clear
30 ParticleGroup.tickParticles port crates/horizon-client-render/src/particles.rs::ParticleEngine::tick ticks live particles and drops the removed ones
31 ParticleGroup.tickParticle skip:debug crash-report wrapper around particle.tick
+2 -2
View File
@@ -99,7 +99,7 @@ ServerPlayer.respawnEntityOnShoulder port crates/horizon-client/src/game/shoulde
ServerPlayer.trackStartFallingPosition port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
ServerPlayer.trackEnteredOrExitedLavaOnVehicle port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
ServerPlayer.updateScoreForCriteria port crates/horizon-client/src/game/scoreboard.rs::Game::tick_scoreboard health, food, air, armor, xp and level objectives
ServerPlayer.tellNeutralMobsThatIDied missing player: wolves, bees, piglins and other mobs that were angry at the player stay angry after the player dies and respawns (vanilla forgives them with forgive_dead_players)
ServerPlayer.tellNeutralMobsThatIDied port crates/horizon-client/src/game/creatures.rs::Game::tell_neutral_mobs_player_died mobs within 32 blocks across and 10 up and down forgive a dead player when the rule is on
ServerPlayer.handleTeamKill inline crates/horizon-client/src/game/scoreboard.rs::Game::score_player_killed teamkill and killedByTeam objectives
ServerPlayer.hurtServer port crates/horizon-client/src/game/lan_pvp.rs::Game::pvp_land pvp rule and team friendly fire gate the damage
ServerPlayer.addPostEffect port crates/horizon-client/src/game/commands/extra.rs::Game::cmd_posteffect gap: minor: one list shared by every target, nothing is sent to guests (an empty list at join) and no post shader is drawn
@@ -125,7 +125,7 @@ ServerPlayer.magicCrit port crates/horizon-client/src/lan/containers.rs::Hub::cr
ServerPlayer.onUpdateAbilities inline crates/horizon-client/src/lan/hub.rs::Hub::set_game_mode abilities go out with a game mode change; there is no other way abilities change
ServerPlayer.createCommandSourceStack inline crates/horizon-client/src/game/lan_actions.rs::Game::run_guest_command the guest is swapped in as the acting player and its permission level applied
ServerPlayer.sendSystemMessage port crates/horizon-client/src/lan/hub.rs::Hub::system_message
ServerPlayer.sendBuildLimitMessage missing player: placing a block at the build limit fails silently; the red build.tooHigh / build.tooLow overlay never shows
ServerPlayer.sendBuildLimitMessage port crates/horizon-client/src/game/lan_actions.rs::Game::build_limit_message the build.tooHigh and build.tooLow overlay at the world height limits
ServerPlayer.getIpAddress skip:server for ban-ip and the dedicated server log
ServerPlayer.updateOptions port crates/horizon-client/src/lan/hub.rs::Hub::process_inbox the ClientInformation arm: view distance, main hand, model parts and chat visibility are kept
ServerPlayer.clientInformation skip:java getter
1 ServerBossEvent.setProgress inline crates/horizon-client/src/game/bossbars.rs::Game::diff_boss_events UpdateProgress goes out when a viewer's last-sent progress differs
99 ServerPlayer.trackStartFallingPosition port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
100 ServerPlayer.trackEnteredOrExitedLavaOnVehicle port crates/horizon-client/src/game/advancements.rs::Game::tick_marks the fall start and the lava ride feed `DistanceTrigger` (`fall_from_height`, `ride_entity_in_lava`)
101 ServerPlayer.updateScoreForCriteria port crates/horizon-client/src/game/scoreboard.rs::Game::tick_scoreboard health, food, air, armor, xp and level objectives
102 ServerPlayer.tellNeutralMobsThatIDied missing port crates/horizon-client/src/game/creatures.rs::Game::tell_neutral_mobs_player_died player: wolves, bees, piglins and other mobs that were angry at the player stay angry after the player dies and respawns (vanilla forgives them with forgive_dead_players) mobs within 32 blocks across and 10 up and down forgive a dead player when the rule is on
103 ServerPlayer.handleTeamKill inline crates/horizon-client/src/game/scoreboard.rs::Game::score_player_killed teamkill and killedByTeam objectives
104 ServerPlayer.hurtServer port crates/horizon-client/src/game/lan_pvp.rs::Game::pvp_land pvp rule and team friendly fire gate the damage
105 ServerPlayer.addPostEffect port crates/horizon-client/src/game/commands/extra.rs::Game::cmd_posteffect gap: minor: one list shared by every target, nothing is sent to guests (an empty list at join) and no post shader is drawn
125 ServerPlayer.onUpdateAbilities inline crates/horizon-client/src/lan/hub.rs::Hub::set_game_mode abilities go out with a game mode change; there is no other way abilities change
126 ServerPlayer.createCommandSourceStack inline crates/horizon-client/src/game/lan_actions.rs::Game::run_guest_command the guest is swapped in as the acting player and its permission level applied
127 ServerPlayer.sendSystemMessage port crates/horizon-client/src/lan/hub.rs::Hub::system_message
128 ServerPlayer.sendBuildLimitMessage missing port crates/horizon-client/src/game/lan_actions.rs::Game::build_limit_message player: placing a block at the build limit fails silently; the red build.tooHigh / build.tooLow overlay never shows the build.tooHigh and build.tooLow overlay at the world height limits
129 ServerPlayer.getIpAddress skip:server for ban-ip and the dedicated server log
130 ServerPlayer.updateOptions port crates/horizon-client/src/lan/hub.rs::Hub::process_inbox the ClientInformation arm: view distance, main hand, model parts and chat visibility are kept
131 ServerPlayer.clientInformation skip:java getter
+1 -1
View File
@@ -39,7 +39,7 @@ AbstractMinecart.animateHurt port crates/horizon-client/src/game/minecarts.rs::G
AbstractMinecart.getCurrentBlockPosOrRailBelow port crates/horizon-client/src/game/minecarts.rs::Game::rail_block the rail one block below counts; the experimental y offset is not used
AbstractMinecart.comeOffTrack port crates/horizon-client/src/game/minecarts.rs::Game::come_off_track clamps speed, halves it on the ground, moves and drags in the air
AbstractMinecart.move port crates/horizon-client/src/game/minecarts.rs::Game::move_cart collision movement only; gap: minor: no push or pickup during the move, which push_carts and the cart tick do instead
AbstractMinecart.applyEffectsFromBlocks inline crates/horizon-client/src/game/minecarts.rs::Game::cart_lava_tick lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush, bubble column) are not applied
AbstractMinecart.applyEffectsFromBlocks inline crates/horizon-client/src/game/minecarts.rs::Game::cart_lava_tick lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush) are not applied; under minecart_improvements, NewMinecartBehavior.moveAlongTrack applies block effects twice per step, and Horizon keeps the old order
AbstractMinecart.getRedstoneDirection skip:other used only by the experimental NewMinecartBehavior; Horizon runs the old track behaviour
AbstractMinecart.applyNaturalSlowdown port crates/horizon-client/src/game/minecarts.rs::Game::natural_slowdown slowdown per ride state, the furnace push and water
AbstractMinecart.readAdditionalSaveData port crates/horizon-client/src/game/minecarts.rs::Minecart::from_nbt flipped and HasTicked read back; gap: minor: a custom display block and offset are not kept
1 AbstractBoat.setInitialPos port crates/horizon-client/src/game/boats.rs::Boat::new spawn sets the position and its previous copy; velocity and yaw start at zero
39 AbstractMinecart.getCurrentBlockPosOrRailBelow port crates/horizon-client/src/game/minecarts.rs::Game::rail_block the rail one block below counts; the experimental y offset is not used
40 AbstractMinecart.comeOffTrack port crates/horizon-client/src/game/minecarts.rs::Game::come_off_track clamps speed, halves it on the ground, moves and drags in the air
41 AbstractMinecart.move port crates/horizon-client/src/game/minecarts.rs::Game::move_cart collision movement only; gap: minor: no push or pickup during the move, which push_carts and the cart tick do instead
42 AbstractMinecart.applyEffectsFromBlocks inline crates/horizon-client/src/game/minecarts.rs::Game::cart_lava_tick lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush, bubble column) are not applied lava is applied there; gap: minor: other block effects on a cart (cobweb, berry bush) are not applied; under minecart_improvements, NewMinecartBehavior.moveAlongTrack applies block effects twice per step, and Horizon keeps the old order
43 AbstractMinecart.getRedstoneDirection skip:other used only by the experimental NewMinecartBehavior; Horizon runs the old track behaviour
44 AbstractMinecart.applyNaturalSlowdown port crates/horizon-client/src/game/minecarts.rs::Game::natural_slowdown slowdown per ride state, the furnace push and water
45 AbstractMinecart.readAdditionalSaveData port crates/horizon-client/src/game/minecarts.rs::Minecart::from_nbt flipped and HasTicked read back; gap: minor: a custom display block and offset are not kept
+3 -3
View File
@@ -7,7 +7,7 @@ MenuType.register skip:data menu type registry bootstrap
MerchantContainer.isEmpty skip:java container emptiness helper; Horizon checks the stacks it needs directly
MerchantContainer.setItem inline crates/horizon-client/src/menu.rs::Menu::slot_changed the store is the generic put; the payment update is merchant_update, run from slot_changed for every merchant slot
MerchantMenu.quickMoveStack inline crates/horizon-client/src/menu.rs::Menu::quick_move the Kind::Merchant arm; a result shift-click also queues the trade sound event
MerchantMenu.playTradeSound missing player: a shift-click trade should play the villager yes sound; the menu queues Event::TradeSound and game/trading.rs drops it with an empty arm
MerchantMenu.playTradeSound port crates/horizon-client/src/game/trading.rs::Game::merchant_apply the TradeSound event plays entity.villager.yes or the wandering trader yes
MerchantResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally that feeds the crafted statistic is not kept
MerchantResultSlot.mayPickup port crates/horizon-client/src/menu.rs::Menu::may_pickup the Role::MerchantResult arm
ResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally for the crafted statistic is not kept
@@ -115,7 +115,7 @@ ItemStack.applyComponentsAndValidate missing unsure: a validated component patc
ItemStack.getStyledHoverName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name gap: minor: plain text; the rarity colour and italic style are not applied here
ItemStack.addToTooltip port crates/horizon-client/src/item_tooltip.rs::lines
ItemStack.addUnitComponentToTooltip inline crates/horizon-client/src/game/stations.rs::Game::stack_tooltip_lines unit lines such as unbreakable, gated by the tooltip display
ItemStack.canPlaceOnBlockInAdventureMode missing player: in adventure mode a block can be placed only when the held item's can_place_on list allows it; Horizon does not read that component
ItemStack.canPlaceOnBlockInAdventureMode port crates/horizon-client/src/game/game_mode.rs::Game::can_place_on_in_adventure the can_place_on predicates gate a placement in adventure mode
ItemStack.canBreakBlockInAdventureMode missing player: in adventure mode a block can be broken only when the held item's can_break list allows it; Horizon does not read that component
ItemStack.getDamageSource missing unsure: a stack's damage_type component is not read when a weapon hits; no Horizon reader found
ItemStackTemplate.fromNonEmptyStack port crates/horizon-items/src/item_data.rs::template_tag stack to template tag
@@ -141,7 +141,7 @@ MapItem.lockMap port crates/horizon-client/src/menu.rs::Menu::map_crafted the lo
MapItem.useOn missing player: using a map on a banner block should toggle that banner's marker on the map; Horizon has no banner toggle
MobBucketItem.emptyContents port crates/horizon-world/src/level/buckets.rs::Sim::bucket_empty the MobWater and Nothing contents
PotionItem.getDefaultInstance skip:data default stack data
PotionItem.useOn missing player: a water bottle used on a block that turns to mud should make mud; no such conversion was found in potions.rs
PotionItem.useOn port crates/horizon-client/src/game/potions.rs::Game::use_water_bottle_on_block a water bottle poured on #convertible_to_mud makes mud and empties the bottle
PotionItem.getName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name potion name from the contents
ProjectileItem.DispenseConfig.Builder.build inline crates/horizon-client/src/game/dispenser.rs::Game::dispense_projectile per-item dispense config
ProjectileWeaponItem.getHeldProjectile port crates/horizon-client/src/game/crossbow.rs::Game::projectile_slot offhand first, then the held slot
1 LoomMenu.<init> port crates/horizon-client/src/menu.rs::Menu::new the loom's banner, dye, pattern and result slots are laid out for Kind::Loom in the constructor
7 MerchantContainer.isEmpty skip:java container emptiness helper; Horizon checks the stacks it needs directly
8 MerchantContainer.setItem inline crates/horizon-client/src/menu.rs::Menu::slot_changed the store is the generic put; the payment update is merchant_update, run from slot_changed for every merchant slot
9 MerchantMenu.quickMoveStack inline crates/horizon-client/src/menu.rs::Menu::quick_move the Kind::Merchant arm; a result shift-click also queues the trade sound event
10 MerchantMenu.playTradeSound missing port crates/horizon-client/src/game/trading.rs::Game::merchant_apply player: a shift-click trade should play the villager yes sound; the menu queues Event::TradeSound and game/trading.rs drops it with an empty arm the TradeSound event plays entity.villager.yes or the wandering trader yes
11 MerchantResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally that feeds the crafted statistic is not kept
12 MerchantResultSlot.mayPickup port crates/horizon-client/src/menu.rs::Menu::may_pickup the Role::MerchantResult arm
13 ResultSlot.remove port crates/horizon-client/src/menu.rs::Menu::remove gap: minor: the removeCount tally for the crafted statistic is not kept
115 ItemStack.getStyledHoverName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name gap: minor: plain text; the rarity colour and italic style are not applied here
116 ItemStack.addToTooltip port crates/horizon-client/src/item_tooltip.rs::lines
117 ItemStack.addUnitComponentToTooltip inline crates/horizon-client/src/game/stations.rs::Game::stack_tooltip_lines unit lines such as unbreakable, gated by the tooltip display
118 ItemStack.canPlaceOnBlockInAdventureMode missing port crates/horizon-client/src/game/game_mode.rs::Game::can_place_on_in_adventure player: in adventure mode a block can be placed only when the held item's can_place_on list allows it; Horizon does not read that component the can_place_on predicates gate a placement in adventure mode
119 ItemStack.canBreakBlockInAdventureMode missing player: in adventure mode a block can be broken only when the held item's can_break list allows it; Horizon does not read that component
120 ItemStack.getDamageSource missing unsure: a stack's damage_type component is not read when a weapon hits; no Horizon reader found
121 ItemStackTemplate.fromNonEmptyStack port crates/horizon-items/src/item_data.rs::template_tag stack to template tag
141 MapItem.useOn missing player: using a map on a banner block should toggle that banner's marker on the map; Horizon has no banner toggle
142 MobBucketItem.emptyContents port crates/horizon-world/src/level/buckets.rs::Sim::bucket_empty the MobWater and Nothing contents
143 PotionItem.getDefaultInstance skip:data default stack data
144 PotionItem.useOn missing port crates/horizon-client/src/game/potions.rs::Game::use_water_bottle_on_block player: a water bottle used on a block that turns to mud should make mud; no such conversion was found in potions.rs a water bottle poured on #convertible_to_mud makes mud and empties the bottle
145 PotionItem.getName port crates/horizon-client/src/game/stations.rs::Game::stack_hover_name potion name from the contents
146 ProjectileItem.DispenseConfig.Builder.build inline crates/horizon-client/src/game/dispenser.rs::Game::dispense_projectile per-item dispense config
147 ProjectileWeaponItem.getHeldProjectile port crates/horizon-client/src/game/crossbow.rs::Game::projectile_slot offhand first, then the held slot
+1 -1
View File
@@ -25,7 +25,7 @@ CraftingTableBlock.useWithoutItem port crates/horizon-world/src/level/interact.r
CreakingHeartBlock.getTicker port crates/horizon-world/src/level/creaking.rs::Sim::heart_tick scheduled heart tick; the uprooted check is done inside heart_update_state
CreakingHeartBlock.animateTick port crates/horizon-client/src/animate_tick.rs::step ticked in #3516; `CreakingHeartBlock.animateTick` is cited at crates/horizon-client/src/animate_tick.rs:104
CreakingHeartBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class CreakingHeart arm schedules the 1-tick heart update then the default shape
CreakingHeartBlock.isSurroundedByLogs missing player: the six-side pale oak log check only gates the idle creak sound in animateTick
CreakingHeartBlock.isSurroundedByLogs port crates/horizon-client/src/mobs/creaking_heart.rs::has_required_logs the six sides must be pale oak logs; a heart without them spawns no creaking
CreakingHeartBlock.createBlockStateDefinition skip:data axis, creaking_heart_state and natural properties come from the block state tables
CreakingHeartBlock.tryAwardExperience inline crates/horizon-client/src/game/creaking.rs::Game::creaking_heart_broken natural heart drops 20-24 xp orbs when broken by a player outside creative and spectator
CreakingHeartBlock.getAnalogOutputSignal port crates/horizon-client/src/mobs/creaking_heart.rs::output_signal gap: minor: distance-based signal, no explicit uprooted check; a heart with no creaking or protector gives 0
1 ConcretePowderBlock.onLand port crates/horizon-world/src/level/falling.rs::Behaviours::falling_block_landed landing branch turns the powder to concrete when the spot is water or touches liquid
25 CreakingHeartBlock.getTicker port crates/horizon-world/src/level/creaking.rs::Sim::heart_tick scheduled heart tick; the uprooted check is done inside heart_update_state
26 CreakingHeartBlock.animateTick port crates/horizon-client/src/animate_tick.rs::step ticked in #3516; `CreakingHeartBlock.animateTick` is cited at crates/horizon-client/src/animate_tick.rs:104
27 CreakingHeartBlock.updateShape port crates/horizon-world/src/level/shapes.rs::Sim::update_shape_class CreakingHeart arm schedules the 1-tick heart update then the default shape
28 CreakingHeartBlock.isSurroundedByLogs missing port crates/horizon-client/src/mobs/creaking_heart.rs::has_required_logs player: the six-side pale oak log check only gates the idle creak sound in animateTick the six sides must be pale oak logs; a heart without them spawns no creaking
29 CreakingHeartBlock.createBlockStateDefinition skip:data axis, creaking_heart_state and natural properties come from the block state tables
30 CreakingHeartBlock.tryAwardExperience inline crates/horizon-client/src/game/creaking.rs::Game::creaking_heart_broken natural heart drops 20-24 xp orbs when broken by a player outside creative and spectator
31 CreakingHeartBlock.getAnalogOutputSignal port crates/horizon-client/src/mobs/creaking_heart.rs::output_signal gap: minor: distance-based signal, no explicit uprooted check; a heart with no creaking or protector gives 0
+1 -1
View File
@@ -136,7 +136,7 @@ LocalPlayer.shouldStopRunSprinting port crates/horizon-client/src/sprint.rs::Con
LocalPlayer.shouldStopSwimSprinting port crates/horizon-client/src/sprint.rs::Conditions::should_stop
LocalPlayer.tickDeath missing minor: unsure: the dead local player is not removed after 20 ticks; the death screen flow covers it
LocalPlayer.rideTick missing minor: unsure: no code found that feeds movement keys into a boat's paddling
LocalPlayer.isHorizontalCollisionMinor missing player: sprinting into a wall at an angle stops the sprint (motion.rs minor_horizontal_collision is a stub returning false)
LocalPlayer.isHorizontalCollisionMinor port crates/horizon-client/src/motion.rs::EntityMoveWorld::minor_horizontal_collision the sprint keeps when a move hits a wall at under 8 degrees
LocalPlayer.isMoving inline crates/horizon-client/src/auto_jump.rs::can_auto_jump
LocalPlayer.isSprintingPossible port crates/horizon-client/src/sprint.rs::Conditions::possible
LocalPlayer.canStartSprinting port crates/horizon-client/src/sprint.rs::Conditions::can_start gap: player: slow_item is never set, so eating does not block starting a sprint
1 OptionInstance.SliderableEnum.toSliderValue port crates/horizon-client-render/src/options_ui.rs::Opt::kind slider position of the graphics preset (index / 3)
136 LocalPlayer.shouldStopSwimSprinting port crates/horizon-client/src/sprint.rs::Conditions::should_stop
137 LocalPlayer.tickDeath missing minor: unsure: the dead local player is not removed after 20 ticks; the death screen flow covers it
138 LocalPlayer.rideTick missing minor: unsure: no code found that feeds movement keys into a boat's paddling
139 LocalPlayer.isHorizontalCollisionMinor missing port crates/horizon-client/src/motion.rs::EntityMoveWorld::minor_horizontal_collision player: sprinting into a wall at an angle stops the sprint (motion.rs minor_horizontal_collision is a stub returning false) the sprint keeps when a move hits a wall at under 8 degrees
140 LocalPlayer.isMoving inline crates/horizon-client/src/auto_jump.rs::can_auto_jump
141 LocalPlayer.isSprintingPossible port crates/horizon-client/src/sprint.rs::Conditions::possible
142 LocalPlayer.canStartSprinting port crates/horizon-client/src/sprint.rs::Conditions::can_start gap: player: slow_item is never set, so eating does not block starting a sprint
+2 -2
View File
@@ -67,7 +67,7 @@ SoundBufferLibrary.enumerate port crates/horizon-client-render/src/audio.rs::Aud
SoundBufferLibrary.DebugOutput.Counter.accountBuffer inline crates/horizon-client-render/src/audio.rs::Audio::census counts buffers and sizes
SoundEngine.reload port crates/horizon-client-render/src/audio.rs::Audio::reload stops sounds and reads the new resource root
SoundEngine.loadLibrary port crates/horizon-client-render/src/audio.rs::Audio::with_output opens the output device
SoundEngine.shouldChangeDevice missing player: when the output device is lost or the system default changes, vanilla reopens sound output; Horizon's Audio has no device watch
SoundEngine.shouldChangeDevice port crates/horizon-client-render/src/audio.rs: devices_seen / last_seen_devices the output device list is watched and the sink reopened when it changes
SoundEngine.notifyListeners port crates/horizon-client-render/src/sound_engine.rs::SoundEngine::notify subtitle and listener notification with range
SoundEngine.updateSource port crates/horizon-client-render/src/audio.rs::Audio::set_listener listener position and orientation
SoundEngineExecutor.createThread skip:other sound thread creation; Horizon's audio runs on the cpal callback
@@ -78,7 +78,7 @@ SoundManager.apply port crates/horizon-client-render/src/audio.rs::Audio::reload
SoundManager.validateSoundResource inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file file existence check through the exists closure
SoundManager.Preparations.handleRegistration port crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file replace flag and sound entries
SoundManager.Preparations.apply inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::load registry replaced with the prepared one
SoundPreviewHandler.preview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::preview
SoundPreviewHandler.preview port crates/horizon-client-render/src/audio.rs::preview the pack screen's sound preview
SoundPreviewHandler.stopOtherCategoryPreview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::stop_other_category_preview
WeighedSoundEvents.preloadIfRequired skip:java preload is a cache; Horizon decodes on demand
CraftPlanksTutorialStep.tick missing player: a new survival world shows no craft-planks tutorial toast; Horizon has no tutorial steps (tutorialStep is only stored in options)
1 RemotePlayer.shouldRenderAtSqrDistance port crates/horizon-client/src/game/entity_culling.rs::entity_view_scale every entity's draw distance is scaled by the view scale and the limit, remote players included
67 SoundBufferLibrary.DebugOutput.Counter.accountBuffer inline crates/horizon-client-render/src/audio.rs::Audio::census counts buffers and sizes
68 SoundEngine.reload port crates/horizon-client-render/src/audio.rs::Audio::reload stops sounds and reads the new resource root
69 SoundEngine.loadLibrary port crates/horizon-client-render/src/audio.rs::Audio::with_output opens the output device
70 SoundEngine.shouldChangeDevice missing port crates/horizon-client-render/src/audio.rs: devices_seen / last_seen_devices player: when the output device is lost or the system default changes, vanilla reopens sound output; Horizon's Audio has no device watch the output device list is watched and the sink reopened when it changes
71 SoundEngine.notifyListeners port crates/horizon-client-render/src/sound_engine.rs::SoundEngine::notify subtitle and listener notification with range
72 SoundEngine.updateSource port crates/horizon-client-render/src/audio.rs::Audio::set_listener listener position and orientation
73 SoundEngineExecutor.createThread skip:other sound thread creation; Horizon's audio runs on the cpal callback
78 SoundManager.validateSoundResource inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file file existence check through the exists closure
79 SoundManager.Preparations.handleRegistration port crates/horizon-client-render/src/sound_events.rs::SoundRegistry::add_file replace flag and sound entries
80 SoundManager.Preparations.apply inline crates/horizon-client-render/src/sound_events.rs::SoundRegistry::load registry replaced with the prepared one
81 SoundPreviewHandler.preview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::preview crates/horizon-client-render/src/audio.rs::preview the pack screen's sound preview
82 SoundPreviewHandler.stopOtherCategoryPreview port crates/horizon-client-render/src/sound_preview.rs::SoundPreview::stop_other_category_preview
83 WeighedSoundEvents.preloadIfRequired skip:java preload is a cache; Horizon decodes on demand
84 CraftPlanksTutorialStep.tick missing player: a new survival world shows no craft-planks tutorial toast; Horizon has no tutorial steps (tutorialStep is only stored in options)
+5 -5
View File
@@ -4,16 +4,16 @@ LinkFileSystem skip:other link filesystem and its getPath; Horizon builds overl
LinkFileSystem.Builder skip:other builder of the link filesystem tree; no equivalent, see packs.rs::build_overlay
PackFormat.fullCodec skip:java codec for supported_formats; packs.rs::compatible reads the same array
PackFormat.validateHolderList inline crates/horizon-client-render/src/packs.rs::overlays_of gap: minor: a broken overlay entry is dropped alone; vanilla rejects the whole overlays list
PackFormat.lastPreMinorVersion missing minor: the 64 and 81 cut-offs feed only the format validators, which Horizon lacks
PackFormat.lastPreMinorVersion inline crates/horizon-client-render/src/packs.rs the cut-off the old-format validator compares against
PackFormat.packCodec skip:java codec wiring; the validation it runs is missing (IntermediaryFormat.validate)
PackFormat.of skip:java constructor; Horizon keeps (major, minor) tuples (packs.rs::RESOURCE_FORMAT)
PackFormat.compareTo skip:java ordering; Horizon compares the (major, minor) tuples directly (packs.rs::compatibility)
PackFormat.toString skip:debug text form of a format, used in validation messages only
PackFormat.IntermediaryFormat.fromRange skip:java encoder back to pack.mcmeta; Horizon never writes pack.mcmeta
PackFormat.IntermediaryFormat.effectiveMinMajorVersion missing minor: only the overlay validator uses it
PackFormat.IntermediaryFormat.validate missing player: vanilla refuses a pack.mcmeta with incomplete format fields (min_format without max_format, or pack_format outside the range); Horizon takes the ranges as written
PackFormat.IntermediaryFormat.validateNewFormat missing player: vanilla refuses min_format above max_format and a missing supported_formats or pack_format where an older game needs them; Horizon does not check
PackFormat.IntermediaryFormat.validateOldFormat missing player: vanilla refuses a supported_formats range above the last pre-minor version, or one without pack_format; Horizon does not check
PackFormat.IntermediaryFormat.effectiveMinMajorVersion inline crates/horizon-client-render/src/packs.rs::validate_pack_format_for_range feeds the range check
PackFormat.IntermediaryFormat.validate port crates/horizon-client-render/src/packs.rs::validate_pack_range pack.mcmeta format fields are checked against the supported range
PackFormat.IntermediaryFormat.validateNewFormat port crates/horizon-client-render/src/packs.rs::validate_new_format min above max and a missing supported_formats / pack_format are refused
PackFormat.IntermediaryFormat.validateOldFormat port crates/horizon-client-render/src/packs.rs::validate_old_format an old range above the last pre-minor version is refused
PackFormat.IntermediaryFormat.validatePackFormatForRange missing player: vanilla refuses a pack_format outside its min to max range, or a multi-version pack below format 15; Horizon does not check
PackMetadataSection.codecForPackType skip:java codec for the pack section; packs.rs::pack reads the same fields
PackMetadataSection.forPackType skip:java picks the section type per pack type; Horizon reads one pack section
1 LinkFSPath skip:other Java path of the in-memory link filesystem; Horizon links host paths (packs.rs::link)
4 LinkFileSystem.Builder skip:other builder of the link filesystem tree; no equivalent, see packs.rs::build_overlay
5 PackFormat.fullCodec skip:java codec for supported_formats; packs.rs::compatible reads the same array
6 PackFormat.validateHolderList inline crates/horizon-client-render/src/packs.rs::overlays_of gap: minor: a broken overlay entry is dropped alone; vanilla rejects the whole overlays list
7 PackFormat.lastPreMinorVersion missing inline crates/horizon-client-render/src/packs.rs minor: the 64 and 81 cut-offs feed only the format validators, which Horizon lacks the cut-off the old-format validator compares against
8 PackFormat.packCodec skip:java codec wiring; the validation it runs is missing (IntermediaryFormat.validate)
9 PackFormat.of skip:java constructor; Horizon keeps (major, minor) tuples (packs.rs::RESOURCE_FORMAT)
10 PackFormat.compareTo skip:java ordering; Horizon compares the (major, minor) tuples directly (packs.rs::compatibility)
11 PackFormat.toString skip:debug text form of a format, used in validation messages only
12 PackFormat.IntermediaryFormat.fromRange skip:java encoder back to pack.mcmeta; Horizon never writes pack.mcmeta
13 PackFormat.IntermediaryFormat.effectiveMinMajorVersion missing inline crates/horizon-client-render/src/packs.rs::validate_pack_format_for_range minor: only the overlay validator uses it feeds the range check
14 PackFormat.IntermediaryFormat.validate missing port crates/horizon-client-render/src/packs.rs::validate_pack_range player: vanilla refuses a pack.mcmeta with incomplete format fields (min_format without max_format, or pack_format outside the range); Horizon takes the ranges as written pack.mcmeta format fields are checked against the supported range
15 PackFormat.IntermediaryFormat.validateNewFormat missing port crates/horizon-client-render/src/packs.rs::validate_new_format player: vanilla refuses min_format above max_format and a missing supported_formats or pack_format where an older game needs them; Horizon does not check min above max and a missing supported_formats / pack_format are refused
16 PackFormat.IntermediaryFormat.validateOldFormat missing port crates/horizon-client-render/src/packs.rs::validate_old_format player: vanilla refuses a supported_formats range above the last pre-minor version, or one without pack_format; Horizon does not check an old range above the last pre-minor version is refused
17 PackFormat.IntermediaryFormat.validatePackFormatForRange missing player: vanilla refuses a pack_format outside its min to max range, or a multi-version pack below format 15; Horizon does not check
18 PackMetadataSection.codecForPackType skip:java codec for the pack section; packs.rs::pack reads the same fields
19 PackMetadataSection.forPackType skip:java picks the section type per pack type; Horizon reads one pack section
+2 -2
View File
@@ -24,8 +24,8 @@ Util.getRegisteredName skip:debug registry key text for logs and debug output (
Util.allOf skip:java predicate combinator
Util.anyOf skip:java predicate combinator
Util.growByHalf skip:java array growth; Rust Vec grows itself
Util.findNextInIterable missing player: an op with a debug stick in creative right-clicks and no block state property cycles to the next value; Horizon has no DebugStickItem behaviour (only datafix and item model names)
Util.findPreviousInIterable missing player: the same debug stick sneak-click does not cycle a property backwards; Horizon has no DebugStickItem behaviour
Util.findNextInIterable port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact the debug stick cycles a property forward, and backward while sneaking
Util.findPreviousInIterable port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact same function: relative_index walks the value list backward on sneak
Util.makeEnumMap skip:java EnumMap builder
Util.mapValues skip:java map stream helper
Util.sequence skip:java future combinator
1 SortedArraySet skip:java sorted unique array list, a java.util collection; Horizon keeps its own ordered containers
24 Util.allOf skip:java predicate combinator
25 Util.anyOf skip:java predicate combinator
26 Util.growByHalf skip:java array growth; Rust Vec grows itself
27 Util.findNextInIterable missing port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact player: an op with a debug stick in creative right-clicks and no block state property cycles to the next value; Horizon has no DebugStickItem behaviour (only datafix and item model names) the debug stick cycles a property forward, and backward while sneaking
28 Util.findPreviousInIterable missing port crates/horizon-client/src/game/debug_stick.rs::Game::debug_stick_interact player: the same debug stick sneak-click does not cycle a property backwards; Horizon has no DebugStickItem behaviour same function: relative_index walks the value list backward on sneak
29 Util.makeEnumMap skip:java EnumMap builder
30 Util.mapValues skip:java map stream helper
31 Util.sequence skip:java future combinator
+5 -5
View File
@@ -6,8 +6,8 @@
A real Horizon client (horizon-ctl, offscreen) with LOD Distance 32 joins a dedicated server:
- `horizon-lod-distance=16`: the server samples the client's far terrain (`status`'s lod.source is "server"), no
farther than 16 chunks (effective_distance 16), and far tiles are drawn (active_tiles > 0);
- no `horizon-lod-distance` (off by default): the server offers none and the client has no far terrain (source
"none").
- no `horizon-lod-distance` (off by default): the server offers none, so the client draws only the chunks it was
sent (source "server-terrain", `server_terrain.rs`), nothing the server sampled.
Each server and game runs in fresh folders with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free
port. Build first:
@@ -94,9 +94,9 @@ def check(bins, scratch, out):
finally:
s.stop()
print(f'off: {lod}')
if lod['source'] != 'none' or lod['active_tiles'] != 0:
raise Failed(f'off: the client has far terrain from a server that offers none: {lod}')
print('off: no far terrain')
if lod['source'] != 'server-terrain':
raise Failed(f'off: expected only the chunks the server sent (server-terrain): {lod}')
print('off: only the chunks the server sent')
def main():