Merge branch 'main' into feat/quit-shortcuts

This commit is contained in:
ElyTheCat authored and GitHub committed 2026-10-30 18:27:01 +03:30
commit 7ba8d7ee65
240 files changed
+12043 -2433

No files matched your search

Generated
+27
View File
@@ -561,6 +561,12 @@ dependencies = [
"typenum",
]
[[package]]
name = "cty"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b365fabc795046672053e29c954733ec3b05e4be654ab130fe8f1f94d7051f35"
[[package]]
name = "cursor-icon"
version = "1.2.0"
@@ -1412,6 +1418,8 @@ name = "horizon-threads"
version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"mimalloc",
"windows-sys 0.61.2",
]
@@ -1787,6 +1795,16 @@ version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a45a52f43e1c16f667ccfe4dd8c85b7f7c204fd5e3bf46c5b0db9a5c3c0b8e9"
dependencies = [
"cc",
"cty",
]
[[package]]
name = "libredox"
version = "0.1.25"
@@ -1878,6 +1896,15 @@ dependencies = [
"libc",
]
[[package]]
name = "mimalloc"
version = "0.1.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d4139bb28d14ad1facf21d5eb8825051b326e172d216b39f6d31df53cc97862"
dependencies = [
"libmimalloc-sys",
]
[[package]]
name = "minisign-verify"
version = "0.3.0"
+1 -1
View File
@@ -166,7 +166,7 @@ lists every method of vanilla 26.3 next to the Rust functions that port it, with
### Known issues
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; the creative hotbar keys (C/X) aren't wired to the hotbar loader; item tooltips lack attribute lines.
Skins served over https show the default skin unless already cached; pack icons show the unknown-pack texture in game; item tooltips lack attribute lines.
## Licence
@@ -28,6 +28,10 @@ pub struct Registries {
/// Game rule name -> integer-valued.
pub game_rules: Vec<(String, bool)>,
pub players: Vec<String>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions the server sent (ClientboundCustomChatCompletionsPacket),
/// each once.
pub custom_completions: std::collections::BTreeSet<String>,
/// Scoreboard objective names, team names and tracked score holders.
pub objectives: Vec<String>,
pub teams: Vec<String>,
@@ -87,7 +91,55 @@ pub struct RegistryIds {
pub tags: Vec<String>,
}
const EMPTY: &[String] = &[];
/// ClientboundCustomChatCompletionsPacket.Action.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CustomCompletionAction {
Add,
Remove,
Set,
}
impl CustomCompletionAction {
/// `Action.BY_ID`: `ByIdMap.continuous` with `OutOfBoundsStrategy.ZERO`,
/// so an unknown id is `ADD`.
pub fn from_id(id: i32) -> Self {
match id {
1 => Self::Remove,
2 => Self::Set,
_ => Self::Add,
}
}
}
/// `ClientSuggestionProvider.modifyCustomCompletions`: the server adds,
/// removes or replaces the extra chat completions.
pub fn modify_custom_completions(
set: &mut std::collections::BTreeSet<String>,
action: CustomCompletionAction,
entries: &[String],
) {
match action {
CustomCompletionAction::Add => set.extend(entries.iter().cloned()),
CustomCompletionAction::Remove => {
for e in entries {
set.remove(e);
}
}
CustomCompletionAction::Set => {
set.clear();
set.extend(entries.iter().cloned());
}
}
}
impl Registries {
/// `ClientSuggestionProvider.getCustomTabSuggestions`: what chat (not a
/// command) completes, the online players' names with the server's custom
/// completions, each once.
pub fn custom_tab_suggestions(&self) -> std::collections::BTreeSet<&str> {
self.players
.iter()
.chain(&self.custom_completions)
.map(String::as_str)
.collect()
}
/// Fill what the game did not build itself from vanilla's exported
/// registries (`command-registries.json`, written by
/// `VanillaCommandParseOracle registries`): built-in registries such as
+11 -5
View File
@@ -192,8 +192,9 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the models and
/// the sprites' layers, without the textures' pixels.
/// [`Assets::reload`] for a server, which draws nothing: the item models
/// and the sprites' layers, without the block models or the textures'
/// pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
@@ -202,14 +203,19 @@ impl RenderAssets {
pub fn load(root: &Path, assets: &Assets) -> Result<Self> {
Self::load_with(root, assets, true)
}
fn load_with(root: &Path, assets: &Assets, pixels: bool) -> Result<Self> {
fn load_with(root: &Path, assets: &Assets, draws: bool) -> Result<Self> {
let registry = assets.registry;
let mut sprites = crate::sprites::Sprites::with_disk_cache(root);
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
let models = crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?;
// A server draws no block: its models (about 24 MB baked) stay empty.
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
crate::models::PreparedModels::default()
};
let model_library = parsed.into_item_library();
// Fluids are drawn by the fluid renderer, not block models; breaking
// progress uses the destroy-stage overlays.
@@ -226,7 +232,7 @@ impl RenderAssets {
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
}
sprites.save_disk_cache();
if pixels {
if draws {
sprites.finish();
} else {
sprites.finish_without_pixels();
+10
View File
@@ -165,6 +165,8 @@ pub struct Audio {
/// How many times each selection was stopped ("record *", "* minecraft:…", `*` for
/// any), with or without an audio device, as `heard`.
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -634,6 +636,7 @@ impl Audio {
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
}
}
#[cfg(not(feature = "audio"))]
@@ -817,6 +820,13 @@ impl Audio {
false,
);
}
/// `SoundPreviewHandler.preview`: a category volume slider moved to
/// `volume` on the title screen's options.
pub fn preview(&mut self, source: usize, volume: f32) {
let mut preview = std::mem::take(&mut self.preview);
preview.preview(self, source, volume);
self.preview = preview;
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
+6 -6
View File
@@ -52,20 +52,20 @@ impl AuthSession {
/// The session file's JSON: {"name", "uuid", "access_token", "user_type": "msa", "xuid"}.
pub fn parse(text: &str) -> Result<Self, String> {
let v: serde_json::Value =
serde_json::from_str(text).map_err(|_| "the session file is not valid JSON")?;
serde_json::from_str(text).map_err(|_| "the session is not valid JSON")?;
let field = |k: &str| v.get(k).and_then(|s| s.as_str());
let name = field("name").ok_or("the session file has no name")?;
let name = field("name").ok_or("the session has no name")?;
if name.is_empty() || name.chars().count() > 16 {
return Err("the session file's name is not a valid player name".into());
return Err("the session's name is not a valid player name".into());
}
let uuid = field("uuid")
.and_then(parse_uuid)
.ok_or("the session file has no valid uuid")?;
.ok_or("the session has no valid uuid")?;
let access_token = field("access_token")
.filter(|t| !t.is_empty())
.ok_or("the session file has no access token")?;
.ok_or("the session has no access token")?;
if field("user_type").is_some_and(|t| t != "msa") {
return Err("the session file is not a Microsoft account session".into());
return Err("the session is not a Microsoft account session".into());
}
Ok(Self {
name: name.to_owned(),
+3 -1
View File
@@ -929,7 +929,9 @@ impl ChatScreen {
} else if !command.chars().all(char::is_whitespace) {
let partial = &command[..cursor_b];
let mut builder = SuggestionsBuilder::new(partial, last_word_index(partial));
builder.suggest_matching(c.registries.players.iter().map(String::as_str));
// ClientSuggestionProvider.getCustomTabSuggestions: the players
// and the server's custom chat completions.
builder.suggest_matching(c.registries.custom_tab_suggestions());
let (range, list) = merge_suggestions(partial, builder.out);
self.awaiting = None;
self.pending = Some((range.0, list));
@@ -244,6 +244,58 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
assert_eq!(screen.value(), "/coords");
}
#[test]
fn chat_completes_the_servers_custom_completions_with_the_players() {
// CommandSuggestions.updateCommandInfo completes chat from
// ClientSuggestionProvider.getCustomTabSuggestions: the online players
// and what ClientboundCustomChatCompletionsPacket added.
use crate::command::{CustomCompletionAction as Action, modify_custom_completions};
let font = font();
let m = Measure(&font);
let mut registries = Registries {
players: vec!["Steve".into()],
..Registries::default()
};
let entries = |e: &[&str]| e.iter().map(|s| s.to_string()).collect::<Vec<_>>();
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::from_id(0), &entries(&["Alex", "#trade"]));
modify_custom_completions(set, Action::Add, &entries(&["Notch", "Steve"]));
modify_custom_completions(set, Action::Remove, &entries(&["Alex"]));
// An id past the last action is ADD (OutOfBoundsStrategy.ZERO).
modify_custom_completions(set, Action::from_id(7), &entries(&["Zuri"]));
assert_eq!(
registries
.custom_tab_suggestions()
.into_iter()
.collect::<Vec<_>>(),
["#trade", "Notch", "Steve", "Zuri"]
);
let completed = |registries: &Registries, line: &str| {
let dispatcher = crate::command::vanilla::game_dispatcher();
let c = Commands {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
}
screen.value().to_owned()
};
assert_eq!(completed(&registries, "hi no"), "hi Notch");
assert_eq!(completed(&registries, "sell #tr"), "sell #trade");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
assert_eq!(completed(&registries, "hi al"), "hi al");
// SET replaces them all; the players stay.
let set = &mut registries.custom_completions;
modify_custom_completions(set, Action::Set, &entries(&["Alex"]));
assert_eq!(completed(&registries, "hi no"), "hi no");
assert_eq!(completed(&registries, "hi al"), "hi Alex");
assert_eq!(completed(&registries, "hi st"), "hi Steve");
}
#[test]
fn ask_server_nodes_wait_for_the_servers_completions() {
// ClientSuggestionProvider.customSuggestion: an `ask_server` node at the
@@ -160,6 +160,13 @@ impl ColumnLayout {
self.min_y + index as i32 * 16
}
}
/// `true` for each section of `layout`: what callers that keep no
/// per-section record (of fluids, say) pass as "every section may have it".
pub fn every_section(layout: ColumnLayout) -> &'static [bool] {
static ALL: [bool; crate::section_mask::SectionMask::CAPACITY] =
[true; crate::section_mask::SectionMask::CAPACITY];
&ALL[..layout.sections()]
}
impl Default for ColumnLayout {
fn default() -> Self {
Self::VANILLA
@@ -305,7 +305,7 @@ impl<'w> BlockCollisionWorld for SceneCollisions<'w, '_> {
/// From the section's palette, without reading its blocks; a palette
/// of the whole registry reads every cell.
fn may_reach_out(&mut self, chunk: Self::Chunk, section_y: i32) -> io::Result<bool> {
let blocks = usize::try_from(section_y - (crate::chunk::MIN_Y >> 4))
let blocks = usize::try_from(section_y - (chunk.min_y() >> 4))
.ok()
.and_then(|s| chunk.sections.get(s))
.map(|section| &section.blocks);
+5 -1
View File
@@ -320,6 +320,10 @@ fn thread_name() -> String {
}
}
/// What starts the stderr line naming a saved report, the path follows: the Launch API's
/// promise to launchers (docs/api/launch.md), so it never changes within a line of that API.
pub const SAVED_LINE: &str = "Horizon: crash report saved to ";
/// Writes the report and tells where it is; the log gets the error too.
fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
if REPORTED.swap(true, Ordering::SeqCst) {
@@ -343,7 +347,7 @@ fn deliver(home: &Path, report: Report) -> Option<PathBuf> {
crate::telemetry::keep_crash(home, &report);
match report.save(home) {
Ok(path) => {
eprintln!("Horizon: crash report saved to {}", path.display());
eprintln!("{SAVED_LINE}{}", path.display());
Some(path)
}
Err(e) => {
+205 -89
View File
@@ -590,6 +590,10 @@ pub struct Renderer {
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
pub suspended: bool,
/// Consecutive frames whose surface acquisition failed validation. The
/// first gets the swapchain a fresh configuration; later ones reattach
/// the window, as a lost surface does.
acquire_validation_failures: u32,
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
@@ -792,9 +796,14 @@ fn mip_chain(
/// pass is also what passes each draw's first instance to shaders reading
/// `instance_index`, and DirectX 12 is the fallback, where working matters
/// more than CPU time.
/// `allow_nonconformant` offers the adapters wgpu hides for reporting no Vulkan
/// conformance version: Mesa's hasvk, which drives Ivy Bridge and Haswell,
/// reports 0.0.0.0 while it works. `open_device` asks for them only on a second
/// try at a backend that had no adapter at all.
fn instance_descriptor(
backends: wgpu::Backends,
window: Option<&Arc<Window>>,
allow_nonconformant: bool,
) -> wgpu::InstanceDescriptor {
let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
if let Some(window) = window {
@@ -807,7 +816,13 @@ fn instance_descriptor(
.flags
.remove(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL);
}
descriptor.with_env()
let mut descriptor = descriptor.with_env();
if allow_nonconformant {
descriptor
.flags
.insert(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER);
}
descriptor
}
/// The window's display connection, so the OpenGL backend creates its EGL
/// display from the X11 or Wayland connection the window lives on. Without
@@ -946,96 +961,147 @@ async fn open_device(
if attempt > 0 {
log::warn!("Horizon renderer: trying {name} ({})", failures.join("; "));
}
let instance = wgpu::Instance::new(instance_descriptor(backends, Some(window)));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => {
failures.push(format!("{name} surface: {e}"));
continue;
}
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => {
failures.push(format!("{name}: {e}"));
continue;
}
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
failures.push(format!("{name}: {e}"));
continue;
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
return Ok(Opened {
instance,
surface,
adapter,
device,
queue,
});
}
Err(e) => failures.push(format!("{name} device: {e}")),
match open_with_retry(backends, window, offscreen, layers).await {
Ok(opened) => return Ok(opened),
Err(e) => failures.push(e),
}
}
Err(format!("{NO_GPU_DRIVER} ({})", failures.join("; ")).into())
}
/// Why a backend gave no device: no adapter at all, which a second try with the
/// adapters wgpu hides can still find, or a failure after an adapter.
enum NoDevice {
NoAdapter(String),
Failed(String),
}
/// Opens a device on one backend, and on a backend whose adapters wgpu all hid
/// for reporting no Vulkan conformance version tries it again with them shown:
/// Mesa's hasvk drives Ivy Bridge and Haswell, works, and reports 0.0.0.0, so
/// without this the player is told they have no graphics driver while Vulkan
/// runs (a `gl not compatible with provided surface` fallback, or nothing, is
/// all they get). Returns why the backend gave no device.
async fn open_with_retry(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
layers: u32,
) -> std::result::Result<Opened, String> {
match open_backend(backends, window, offscreen, false, layers).await {
Err(NoDevice::NoAdapter(hidden)) => {
log::warn!(
"Horizon renderer: {} has no conformant adapter ({hidden}); asking again for the \
ones wgpu hides as non-conformant",
backends_name(backends)
);
match open_backend(backends, window, offscreen, true, layers).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: using {} although its driver reports no Vulkan \
conformance version",
opened.adapter.get_info().name
);
Ok(opened)
}
Err(NoDevice::NoAdapter(e)) | Err(NoDevice::Failed(e)) => Err(e),
}
}
Err(NoDevice::Failed(e)) => Err(e),
Ok(opened) => Ok(opened),
}
}
/// Opens a device on `backends` alone: its surface, adapter and device, or what
/// stopped it. `allow_nonconformant` is `open_with_retry`'s second try.
async fn open_backend(
backends: wgpu::Backends,
window: &Arc<Window>,
offscreen: bool,
allow_nonconformant: bool,
layers: u32,
) -> std::result::Result<Opened, NoDevice> {
let name = backends_name(backends);
let instance = wgpu::Instance::new(instance_descriptor(
backends,
Some(window),
allow_nonconformant,
));
let surface = if offscreen {
None
} else {
match instance.create_surface(window.clone()) {
Ok(surface) => Some(surface),
Err(e) => return Err(NoDevice::Failed(format!("{name} surface: {e}"))),
}
};
let adapter = match instance
.request_adapter(&wgpu::RequestAdapterOptions {
power_preference: wgpu::PowerPreference::HighPerformance,
compatible_surface: surface.as_ref(),
force_fallback_adapter: false,
..Default::default()
})
.await
{
Ok(adapter) => adapter,
Err(e) => return Err(NoDevice::NoAdapter(format!("{name}: {e}"))),
};
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
// targets with the adapter's own format features) and R11F_G11F_B10F: a float
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES
| wgpu::Features::RG11B10UFLOAT_RENDERABLE));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
limits (buffers up to {} bytes, storage bindings up to {} bytes, {} texture layers)",
match driver.trim() {
"" => "unnamed",
driver => driver,
},
limits.max_buffer_size,
limits.max_storage_buffer_binding_size,
limits.max_texture_array_layers,
));
let desc = wgpu::DeviceDescriptor {
label: Some("Horizon device"),
required_features: features,
required_limits: limits,
..Default::default()
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
let opened = None;
let opened = match opened {
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => Ok(Opened {
instance,
surface,
adapter,
device,
queue,
}),
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
/// missing driver shows here rather than as a missing-DLL dialog.
@@ -2530,10 +2596,19 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &TERRAIN_ATTRIBUTES,
})];
let info = adapter.get_info();
let min_sections = if flat::is_broken_intel_vulkan(&info)
|| std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1")
{
0
} else {
flat::MIN_SECTIONS
};
flat::Flat::new(
&device,
&bindings,
arena.page_vertices(),
min_sections,
|layout, label, fragment, blend| {
pipeline_in(
layout,
@@ -2764,6 +2839,7 @@ impl Renderer {
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
acquire_validation_failures: 0,
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -3765,9 +3841,32 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Validation) => {
return Err("surface acquisition failed validation".into());
// Acquire caught a validation error: the swapchain cannot be
// used as configured (wgpu reports a configuration raced with
// a submission this way). Give it a fresh configuration like
// an Outdated one, and once that has not helped, reattach the
// window like a Lost one — a fullscreen loading screen then
// skips frames instead of failing every one of them.
if self.acquire_validation_failures == 0 {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
} else {
self.recreate_surface()?;
}
self.acquire_validation_failures =
self.acquire_validation_failures.saturating_add(1);
return Ok(false);
}
};
if surface_frame.is_some() {
self.acquire_validation_failures = 0;
}
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
@@ -5947,6 +6046,23 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
#[test]
fn the_second_try_at_a_backend_shows_nonconformant_adapters() {
let shown = |allow| {
super::instance_descriptor(wgpu::Backends::VULKAN, None, allow)
.flags
.contains(wgpu::InstanceFlags::ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER)
};
assert!(shown(true));
// The first try keeps wgpu's own filtering, unless `with_env` turns the
// flag on from the environment (WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER).
if std::env::var_os("WGPU_ALLOW_UNDERLYING_NONCOMPLIANT_ADAPTER").is_none() {
assert!(!shown(false));
}
}
/// Every WGSL shader file translates to HLSL as wgpu's DirectX 12 backend
/// writes it for FXC (shader model 5.1), entry point by entry point.
#[test]
@@ -6102,7 +6218,7 @@ mod tests {
#[test]
fn indirect_draws_are_validated_only_in_debug_builds_and_on_directx_12() {
let validated = |backends| {
super::instance_descriptor(backends, None)
super::instance_descriptor(backends, None, false)
.flags
.contains(wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL)
};
@@ -147,7 +147,7 @@ impl SpriteAnimator {
group,
});
}
let Some(pipeline) = pipeline else {
let Some((pipeline, layout)) = pipeline else {
let groups = keys
.iter()
.zip(&counts)
@@ -193,7 +193,6 @@ impl SpriteAnimator {
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
@@ -364,11 +363,15 @@ impl SpriteAnimator {
}
}
/// `animate.wgsl`'s pipeline, unless the device fails to make it: an OpenGL
/// driver can list compute shaders among its extensions while wgpu writes
/// the GLSL version of its core version, which has none (Mesa's
/// `MESA_GL_VERSION_OVERRIDE`, older drivers).
fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
/// `animate.wgsl`'s pipeline and the bind group layout of its group 0,
/// unless the device fails to make the pipeline: an OpenGL driver can list
/// compute shaders among its extensions while wgpu writes the GLSL version
/// of its core version, which has none (Mesa's `MESA_GL_VERSION_OVERRIDE`,
/// older drivers). A device lost between the scopes' pushes answers pipeline
/// creation with a silent placeholder and reports its invalidity only when
/// the layout is asked for — inside the scopes that error is caught, outside
/// them it is uncaptured and panics (#3866).
fn pipeline(device: &wgpu::Device) -> Option<(wgpu::ComputePipeline, wgpu::BindGroupLayout)> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -383,9 +386,10 @@ fn pipeline(device: &wgpu::Device) -> Option<wgpu::ComputePipeline> {
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
match internal.or(pollster::block_on(validation.pop())) {
None => Some(pipeline),
None => Some((pipeline, layout)),
Some(e) => {
log::warn!("Horizon renderer: animated sprites drawn on the CPU ({e})");
None
+17 -7
View File
@@ -115,7 +115,7 @@ pub struct Flat {
/// (9,300 draws) the CPU saves 1.3 ms. The bar was 4,096 draws when they
/// were 2.2 to 2.5 per section; a section's draw count now depends on the
/// sides it shows the eye (`mesher::Sides`), so it counts sections.
const MIN_SECTIONS: usize = 1600;
pub const MIN_SECTIONS: usize = 1600;
/// Whether flat draws apply: the backend emulates multi-draw, and vertex
/// shaders can read storage buffers large enough for an index page.
@@ -126,7 +126,20 @@ pub fn wanted(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
Ok("1") => return supported(adapter, device),
_ => {}
}
adapter.get_info().backend == wgpu::Backend::Metal && supported(adapter, device)
let info = adapter.get_info();
(info.backend == wgpu::Backend::Metal || is_broken_intel_vulkan(&info))
&& supported(adapter, device)
}
/// Intel Gen11 (Ice Lake) ANV Vulkan driver has broken `first_instance` for instance-step
/// vertex attributes (#3529), causing terrain sections to be drawn with another section's origin
/// and flicker.
pub fn is_broken_intel_vulkan(info: &wgpu::AdapterInfo) -> bool {
info.backend == wgpu::Backend::Vulkan
&& info.vendor == 0x8086
&& (info.name.contains("ICL")
|| info.name.contains("Ice Lake")
|| (info.device >= 0x8a50 && info.device <= 0x8a71))
}
fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
@@ -254,6 +267,7 @@ impl Flat {
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
page_vertices: u64,
min_sections: usize,
pipeline: impl Fn(
&wgpu::PipelineLayout,
&str,
@@ -357,11 +371,7 @@ impl Flat {
phase1_calls: 0,
solid_calls: 0,
shadow_calls: [0; 2],
min_sections: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
} else {
MIN_SECTIONS
},
min_sections,
range_align: (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16)
.max(1),
}
+108 -5
View File
@@ -10,19 +10,36 @@ const CHUNK: u64 = 1 << 20;
pub struct Uploads {
belt: wgpu::util::StagingBelt,
chunk: u64,
}
impl Uploads {
pub fn new(device: &wgpu::Device) -> Self {
Self::with_chunk(device, CHUNK)
}
/// A belt whose staging buffers hold `chunk` bytes (larger writes get
/// a buffer of their own).
/// A belt whose staging buffers hold `chunk` bytes (a larger write gets
/// a buffer of its own, rounded up to a power of two).
pub fn with_chunk(device: &wgpu::Device, chunk: u64) -> Self {
Self {
belt: wgpu::util::StagingBelt::new(device.clone(), chunk),
chunk,
}
}
/// A mapped staging slice of at least `size` bytes. The belt gives a
/// write larger than its chunk a buffer of exactly that size, keeps it for
/// good and only reuses it for writes that fit: an upload that grows a
/// little every frame (the entity batch while mobs appear) took a new
/// buffer each frame until the game ran out of memory (#2826). Rounded up
/// to a power of two, the large buffers it keeps are reused, one per
/// doubling.
fn allocate(&mut self, size: NonZeroU64, alignment: NonZeroU64) -> wgpu::BufferSlice<'_> {
let size = if size.get() > self.chunk {
size.checked_next_power_of_two().unwrap_or(size)
} else {
size
};
self.belt.allocate(size, alignment)
}
/// Copy tightly packed RGBA8 `pixels` (`width` by `height`) into one mip
/// level of one array layer, padding rows to the copy alignment.
pub fn texture(
@@ -41,7 +58,7 @@ impl Uploads {
};
let alignment = NonZeroU64::new(u64::from(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT))
.expect("non-zero alignment");
let slice = self.belt.allocate(size, alignment);
let slice = self.allocate(size, alignment);
{
let mut view = slice
.get_mapped_range_mut()
@@ -80,8 +97,20 @@ impl Uploads {
let Some(size) = NonZeroU64::new(bytes.len() as u64) else {
return;
};
self.belt
.write_buffer(encoder, target, offset, size)
// The belt's own `write_buffer` checks, kept now that a large write's
// slice is rounded up past them.
assert!(
offset.is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT)
&& size.get().is_multiple_of(wgpu::COPY_BUFFER_ALIGNMENT),
"buffer upload at {offset} of {size} bytes must be 4-byte aligned"
);
let alignment = NonZeroU64::new(wgpu::COPY_BUFFER_ALIGNMENT).expect("non-zero alignment");
let slice = self.allocate(size, alignment).slice(..size.get());
encoder.copy_buffer_to_buffer(slice.buffer(), slice.offset(), target, offset, size.get());
slice
.get_mapped_range_mut()
.expect("staging belt slices are mapped")
.slice(..)
.copy_from_slice(bytes);
}
/// Close this frame's staging buffers: they come back for reuse once the
@@ -217,6 +246,80 @@ mod tests {
}
}
/// Staging buffers the belt holds, once every submitted frame is done
/// with them. The belt only reports its buffers through `Debug`.
fn kept_buffers(device: &wgpu::Device, uploads: &mut Uploads) -> usize {
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Finishing an empty frame takes back every recalled buffer.
uploads.finish(&device.create_command_encoder(&Default::default()));
let belt = format!("{:?}", uploads.belt);
let count = |field: &str| -> usize {
let at = belt.find(&format!("{field}: ")).expect(&belt) + field.len() + 2;
let digits = belt[at..].split(|c: char| !c.is_ascii_digit()).next();
digits.and_then(|d| d.parse().ok()).expect(&belt)
};
count("active_chunks") + count("closed_chunks") + count("free_chunks")
}
/// #2826: a write larger than the chunk that grows a little every frame
/// (the entity batch while mobs appear) got a new exact-size buffer each
/// frame, all of them kept, until the game ran out of memory.
#[test]
fn growing_large_writes_reuse_the_belt_buffers() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
const CHUNK: u64 = 1 << 16;
const TARGET: usize = 4 * CHUNK as usize;
let target = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: TARGET as u64,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut uploads = Uploads::with_chunk(&device, CHUNK);
let mut expected = vec![0u8; TARGET];
let frames = 64;
for frame in 0..frames {
let mut encoder = device.create_command_encoder(&Default::default());
// One small write, then the large one, 1.5 to 3.5 chunks long.
uploads.buffer(&mut encoder, &target, TARGET as u64 - 4, &[9; 4]);
expected[TARGET - 4..].fill(9);
let len = (3 * CHUNK as usize / 2 + frame * 2048).next_multiple_of(4);
let bytes: Vec<u8> = (0..len).map(|i| (i + frame) as u8).collect();
uploads.buffer(&mut encoder, &target, 0, &bytes);
expected[..len].copy_from_slice(&bytes);
encoder.copy_buffer_to_buffer(&target, 0, &readback, 0, TARGET as u64);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
readback
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
// Only the written bytes land: the rest of the target is untouched.
assert!(
*readback.slice(..).get_mapped_range().unwrap() == expected[..],
"frame {frame}"
);
readback.unmap();
}
// A chunk for the small writes and a buffer per doubling of the large
// one (2 and 4 chunks), maybe one more where a small write took the
// start of a buffer the large one needed whole: not one per frame.
let kept = kept_buffers(&device, &mut uploads);
assert!(
kept <= 4,
"the belt kept {kept} buffers over {frames} frames"
);
}
#[test]
fn belt_buffer_writes_land_in_order_at_their_offsets() {
let Some((device, queue)) = device() else {
+1
View File
@@ -71,6 +71,7 @@ pub mod signs;
pub mod skins;
pub mod sound_engine;
pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
mod sprite_cache;
+81
View File
@@ -856,6 +856,23 @@ pub fn relight(
relight_region(&mut region, tables, sky_light, positions, force, changed);
region.finish();
}
/// The columns `changed` touched that border a loaded column not lit yet
/// (`unlit`). `relight` reads and writes only lit columns, so an edit's
/// darkening can take away light such a neighbour sends in and never put it
/// back, and nothing relights the column when that neighbour's own light
/// lands: an air block at 0 beside one at 6 (#2786). Vanilla's light engine
/// keeps one light storage for every chunk, so its edits and its chunk
/// lighting see each other. These columns need a full light job, which
/// lights them from the blocks of their whole 3x3, that neighbour's included.
pub fn beside_unlit(changed: &LightChanges, unlit: impl Fn(i32, i32) -> bool) -> Vec<(i32, i32)> {
let mut columns: Vec<(i32, i32)> = changed.sections().map(|(x, _, z)| (x, z)).collect();
columns.sort_unstable();
columns.dedup();
columns.retain(|&(x, z)| {
(-1..=1).any(|dz| (-1..=1).any(|dx| (dx, dz) != (0, 0) && unlit(x + dx, z + dz)))
});
columns
}
fn relight_region(
world: &mut Region,
tables: &LightTables,
@@ -1109,6 +1126,70 @@ mod tests {
// Without the shape test the room would be lit (slabs have no dampening).
assert_eq!(sky(&w, 8, 2, 8), 0, "the full bottom face stops sky light");
}
/// #2786: (1, 0) is loaded but its own light job hasn't landed; (0, 0)
/// was lit from the 3x3's blocks, its torch's light and the far torch's
/// in (1, 0) both. Taking its torch away darkens everything the torch
/// lit, the far torch's light at the border too, and `relight` cannot
/// bring that back from a column it doesn't read. Columns like that are
/// lit again whole; without it the border stays at 0 for good once
/// (1, 0)'s light lands beside it.
#[test]
fn an_edit_beside_a_column_not_lit_yet_lights_the_column_again() {
let tables = tables();
let mut w: HashMap<(i32, i32), Arc<Chunk>> = HashMap::new();
for z in -1..=1 {
for x in -1..=1 {
let mut c = Chunk::empty(x, z);
for y in MIN_Y..0 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
if (x, z) == (0, 0) {
c.set(14, 0, 8, TORCH);
} else if (x, z) == (1, 0) {
c.set(6, 0, 8, TORCH);
}
w.insert((x, z), Arc::new(c));
}
}
let light = |w: &HashMap<(i32, i32), Arc<Chunk>>, (x, z): (i32, i32)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
w.get(&(x + dx as i32 - 1, z + dz as i32 - 1)).map(|c| &**c)
})
});
light_chunk(&region, &tables, true)
};
let keys: Vec<_> = w.keys().copied().collect();
for key in &keys {
let lights = light(&w, *key);
let c = Arc::make_mut(w.get_mut(key).unwrap());
apply_lighting(c, lights);
c.lit = *key != (1, 0);
}
// The far torch at x 22 gives 7 at x 15; (0, 0)'s own torch gives 13.
assert_eq!(block(&w, 15, 0, 8), 13);
Arc::make_mut(w.get_mut(&(0, 0)).unwrap()).set(14, 0, 8, AIR);
let mut changed = LightChanges::default();
relight(&mut w, &tables, true, &[(14, 0, 8)], false, &mut changed);
let again = beside_unlit(&changed, |x, z| w.get(&(x, z)).is_some_and(|c| !c.lit));
for &key in &again {
let lights = light(&w, key);
let c = Arc::make_mut(w.get_mut(&key).unwrap());
apply_relighting(c, lights, &mut LightChanges::default());
}
// (1, 0)'s own job lands.
let lights = light(&w, (1, 0));
let c = Arc::make_mut(w.get_mut(&(1, 0)).unwrap());
apply_lighting(c, lights);
c.lit = true;
assert_eq!(block(&w, 16, 0, 8), 8);
assert_eq!(block(&w, 15, 0, 8), 7, "air beside 8 across the border");
assert_eq!(block(&w, 14, 0, 8), 6);
assert!(again.contains(&(0, 0)), "{again:?}");
}
#[test]
fn relight_keeps_unchanged_and_unlit_sections() {
let mut w = world(|_| {});
+192 -10
View File
@@ -186,8 +186,15 @@ impl Column {
self.push(span);
}
}
/// `kinds`: `state_kinds`, per block state.
fn from_chunk(chunk: &Chunk, kinds: &[u8], x: i32, z: i32) -> Self {
/// `kinds`: `state_kinds`, per block state; `water`: `water_source(kinds)`,
/// what blocks holding water (kelp, seagrass, waterlogged ones) are drawn as.
fn from_chunk(
chunk: &Chunk,
kinds: &[u8],
water: Option<BlockStateId>,
x: i32,
z: i32,
) -> Self {
let kind = |state: BlockStateId| kinds.get(state.0 as usize).copied().unwrap_or(0);
let face_light = |y: i32| {
let mut sky = 0;
@@ -222,11 +229,17 @@ impl Column {
let step = if uniform { 16 } else { 1 };
for offset in (0..16).step_by(step) {
let y = min + offset;
let state = BlockStateId(chunk.get(x, y, z));
let kind = kind(state);
let mut state = BlockStateId(chunk.get(x, y, z));
let mut kind = kind(state);
if kind & KEPT == 0 {
continue;
}
if kind & WATERY != 0
&& let Some(water) = water
{
state = water;
kind = kinds[water.0 as usize];
}
let light = face_light(y);
column.push(Span {
min: y as i16,
@@ -269,15 +282,28 @@ impl Column {
}
/// A chunk's 256 columns (`Column::from_chunk`), on the far-terrain worker.
fn summarize(chunk: &Chunk, kinds: &[u8]) -> Arc<[Column; 256]> {
let water = water_source(kinds);
Arc::new(std::array::from_fn(|i| {
Column::from_chunk(chunk, kinds, (i & 15) as i32, (i >> 4) as i32)
Column::from_chunk(chunk, kinds, water, (i & 15) as i32, (i >> 4) as i32)
}))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state; the light
/// emission is in the high nibble.
/// The water source state: the first plain kept fluid without light
/// (`minecraft:water` comes before lava, and its level 0 first).
fn water_source(kinds: &[u8]) -> Option<BlockStateId> {
kinds
.iter()
.position(|&kind| kind == KEPT | FLUID)
.map(|i| BlockStateId(i as u32))
}
/// `state_kinds` bits: kept in columns, a fluid, the air state, a block
/// holding water; the light emission is in the high nibble.
const KEPT: u8 = 1;
const FLUID: u8 = 2;
const AIR: u8 = 4;
/// A block holding water that draws as none (kelp, seagrass, bubble columns,
/// waterlogged blocks that aren't kept): far terrain draws water there, or
/// kelp reaching the surface leaves holes in the sea.
const WATERY: u8 = 8;
/// What `Column::from_chunk` needs of each block state, read from the facts
/// once: matching their names for every block made observing a chunk slow.
fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
@@ -296,14 +322,29 @@ fn state_kinds(facts: &BlockFacts) -> Vec<u8> {
|| f.full_collision
|| f.name.ends_with("_leaves")
|| chorus
|| fluid);
(u8::from(kept) * KEPT)
| (u8::from(fluid) * FLUID)
|| fluid
|| nearly_full(&f.name));
let watery = !kept && !facts.is_air(state) && f.fluid == crate::blocks::Fluid::Water;
(u8::from(kept || watery) * KEPT)
| (u8::from(fluid || watery) * FLUID)
| (u8::from(watery) * WATERY)
| (u8::from(state == facts.air) * AIR)
| (f.emission.min(15) << 4)
})
.collect()
}
/// Blocks that fill their cell but neither draw solid nor collide as a full
/// block: far terrain draws them as blocks, or patches of powder snow leave
/// holes in mountains (and paths and fields dips).
fn nearly_full(name: &str) -> bool {
matches!(
name,
"minecraft:powder_snow"
| "minecraft:dirt_path"
| "minecraft:farmland"
| "minecraft:honey_block"
) || name.ends_with("_stairs")
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TileKey {
pub x: i32,
@@ -1085,6 +1126,16 @@ impl Lod {
})
.copied()
.collect();
// Retiring fine columns are empty stand-ins, excluded from `active`
// while their normal meshes still cover the gap. Request their detail
// explicitly: neither the active-tile requests nor sampling an already
// ready stand-in can fill it, so its GPU sections would never retire.
keys.extend(retiring_detail(
&self.plan,
&self.retiring,
&self.saved,
&self.detail_masks,
));
keys.sort_unstable_by_key(|key| (key.distance(camera), *key));
let mut chunks = FxHashSet::default();
for key in keys {
@@ -1589,11 +1640,84 @@ impl Drop for Lod {
}
}
}
/// Fine retiring stand-ins need actual column detail before `Plan::replaces`
/// can release their normal meshes. The caller keeps the usual 32-column
/// request budget and waits for the replacement mesh before changing cutout.
fn retiring_detail<'a>(
plan: &'a Plan,
retiring: &'a FxHashSet<(i32, i32)>,
saved: &'a Saved,
masks: &'a FxHashMap<TileKey, [u64; 4]>,
) -> impl Iterator<Item = TileKey> + 'a {
retiring.iter().filter_map(move |&(x, z)| {
let key = TileKey { x, z, level: 0 };
(plan.nodes.contains(&key)
&& !saved.contains_key(&(x, z))
&& masks.get(&key) != Some(&[u64::MAX; 4]))
.then_some(key)
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn retiring_fine_stand_ins_request_detail_before_their_gpu_meshes_can_retire() {
let column = (12, 0);
let fine = TileKey {
x: 12,
z: 0,
level: 0,
};
let mut plan = Plan::default();
plan.nodes.insert(fine);
let ready: FxHashMap<TileKey, ()> = [(fine, ())].into_iter().collect();
let retiring = [column].into_iter().collect();
let saved = Saved::default();
let mut masks = FxHashMap::default();
// A covered stand-in is ready, but empty: ordinary sampling skips it,
// and selection leaves it out of `active`. It cannot yet replace the
// old full-detail column, even though the near world has unloaded it.
assert!(!plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
assert_eq!(
retiring_detail(&plan, &retiring, &saved, &masks).collect::<Vec<_>>(),
[fine]
);
// The existing RequestDetail -> apply_fine path marks complete detail;
// only then may selection retire it and publish the new mesh cutout.
masks.insert(fine, [u64::MAX; 4]);
assert!(
retiring_detail(&plan, &retiring, &saved, &masks)
.next()
.is_none()
);
assert!(plan.replaces(column, &ready, |key| masks.get(&key)
== Some(&[u64::MAX; 4])));
}
#[test]
fn retiring_detail_requests_only_planned_columns_still_missing_detail() {
let fine = |x| TileKey { x, z: 0, level: 0 };
let mut plan = Plan::default();
plan.nodes.extend((0..4).map(fine));
plan.nodes.insert(fine(5));
let retiring = (0..5).map(|x| (x, 0)).collect();
let saved = [((1, 0), Arc::new(std::array::from_fn(|_| Column::default())))]
.into_iter()
.collect();
let masks = [
(fine(2), [u64::MAX; 4]),
(fine(3), [u64::MAX, u64::MAX, u64::MAX, u64::MAX - 1]),
]
.into_iter()
.collect();
let mut requested: Vec<_> = retiring_detail(&plan, &retiring, &saved, &masks).collect();
requested.sort_unstable();
// Saved or complete detail needs no request; an unplanned retiree and
// a planned column that is not retiring must not consume the budget.
assert_eq!(requested, [fine(0), fine(3)]);
}
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
@@ -1840,6 +1964,40 @@ mod tests {
}
}
#[test]
fn nearly_full_blocks_are_kept() {
assert!(nearly_full("minecraft:powder_snow"));
assert!(nearly_full("minecraft:dirt_path"));
assert!(nearly_full("minecraft:oak_stairs"));
assert!(!nearly_full("minecraft:oak_fence"));
assert!(!nearly_full("minecraft:wheat"));
}
#[test]
fn kelp_to_the_surface_is_drawn_as_water() {
// 0 air, 1 stone, 2 water, 3 kelp (holds water, draws as none).
let kinds = [AIR, KEPT, KEPT | FLUID, KEPT | FLUID | WATERY];
let mut chunk = Chunk::empty(0, 0);
for x in [3, 4] {
for y in MIN_Y..40 {
chunk.set(x, y, 3, 1);
}
}
for y in 40..63 {
chunk.set(3, y, 3, 3);
chunk.set(4, y, 3, 2);
}
let water = water_source(&kinds);
assert_eq!(water, Some(BlockStateId(2)));
let kelp = Column::from_chunk(&chunk, &kinds, water, 3, 3);
let sea = Column::from_chunk(&chunk, &kinds, water, 4, 3);
assert_eq!(kelp.spans, sea.spans);
assert_eq!(
kelp.spans.last().map(|s| (s.min, s.max, s.state)),
Some((40, 63, BlockStateId(2)))
);
assert_eq!(kelp.surface.unwrap().water, Some((63, BlockStateId(2))));
assert_eq!(kelp.surface.unwrap().height, 40);
}
#[test]
fn coarse_voxels_reduce_the_area_instead_of_inflating_one_leaf() {
let span = |min, max, state| Span {
min,
@@ -2035,6 +2193,24 @@ mod tests {
chunk.set(8, 71, 8, chorus.0);
chunk.set(8, 72, 8, chorus.0);
chunk.set(8, 73, 8, flower.0);
// Kelp to the surface is water; powder snow and a waterlogged stair
// are kept as themselves.
let state = |name: &str| assets.registry.default_state(name).unwrap();
let (kelp, powder_snow) = (state("minecraft:kelp"), state("minecraft:powder_snow"));
let stair = facts
.states
.iter()
.position(|f| &*f.name == "minecraft:oak_stairs" && f.waterlogged)
.map(|i| BlockStateId(i as u32))
.unwrap();
chunk.set(2, 54, 2, stone.0);
for y in 55..64 {
chunk.set(2, y, 2, kelp.0);
}
chunk.set(6, 90, 6, stone.0);
chunk.set(6, 91, 6, powder_snow.0);
chunk.set(10, 54, 10, stone.0);
chunk.set(10, 55, 10, stair.0);
lod.observe(Arc::new(chunk.clone()), &facts);
receive_observed(&mut lod);
let observed = &lod.saved[&(-1, -1)];
@@ -2052,6 +2228,12 @@ mod tests {
.collect::<Vec<_>>(),
vec![(70, 71, end_stone), (71, 73, chorus), (73, 74, flower)]
);
let kelp_column = &observed[2 * 16 + 2];
assert_eq!(kelp_column.surface.unwrap().height, 55);
assert_eq!(kelp_column.surface.unwrap().water, Some((64, water)));
assert_eq!(observed[6 * 16 + 6].surface.unwrap().state, powder_snow);
assert_eq!(observed[6 * 16 + 6].surface.unwrap().height, 92);
assert_eq!(observed[10 * 16 + 10].surface.unwrap().state, stair);
let fine = TileKey {
x: -1,
+13 -2
View File
@@ -45,9 +45,20 @@ pub fn data_dir() -> PathBuf {
std::env::temp_dir()
}
/// Where the launcher runs the game and keeps its saves: `HORIZON_HOME`, else `horizon` in the
/// platform data folder (launcher/src/backend.rs `game_home`).
/// `--home DIR`, set once at start; it wins over `HORIZON_HOME`.
static HOME: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// Makes `game_home` return `home` for the rest of the run (`--home`).
pub fn set_game_home(home: PathBuf) {
let _ = HOME.set(home);
}
/// Where the launcher runs the game and keeps its saves: `--home`, else `HORIZON_HOME`, else
/// `horizon` in the platform data folder (launcher/src/backend.rs `game_home`).
pub fn game_home() -> PathBuf {
if let Some(home) = HOME.get() {
return home.clone();
}
match std::env::var_os("HORIZON_HOME").filter(|h| !h.is_empty()) {
Some(h) => PathBuf::from(h),
None => data_dir().join("horizon"),
@@ -45,6 +45,7 @@ pub struct ModelInstance {
pub layer: u32,
pub shade: f32,
}
#[derive(Default)]
pub struct PreparedModels {
pub catalog: StateModels,
pub placements: Vec<(BlockPos, BlockStateId)>,
@@ -42,6 +42,9 @@ pub struct BakedModel {
pub cube: Option<[super::cube::CubeStyle; 6]>,
}
type VariantKey = (String, [u8; 3], bool);
/// Empty by default: no state has a model (a dedicated server, which draws
/// nothing).
#[derive(Default)]
pub struct StateModels {
/// Each definition's choices, a variant standing as its index in `baked`.
choices: Vec<CompiledChoices<u32>>,
+63 -5
View File
@@ -143,6 +143,7 @@ impl Opt {
| Opt::ChunkBuilder
// Saved, but the creative tabs and the mouse don't read them yet.
| Opt::OperatorItemsTab
| Opt::QuitShortcuts
| Opt::CtrlClickEmulatesRightClick
)
}
@@ -950,6 +951,24 @@ pub fn set_slider(opt: Opt, s: &mut Settings, v: f32) {
apply_preset(s);
}
}
/// A slider pressed or dragged to `v`: apply it live, and tell the game when
/// a category volume changed (`Options.createSoundSliderOptionInstance`'s
/// callback, which `OptionInstance.set` runs only for a new value).
fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
let volume = |s: &Settings| match opt {
Opt::Volume(0) => Some(s.master_volume),
Opt::Volume(i) => Some(s.volumes[i]),
_ => None,
};
let before = volume(s);
set_slider(opt, s, v);
events.push(Event::SliderDrag);
if let (Opt::Volume(source), Some(after)) = (opt, volume(s))
&& before != Some(after)
{
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
/// filtering and anisotropy have no option here yet).
pub fn apply_preset(s: &mut Settings) {
@@ -1104,6 +1123,9 @@ pub enum Event {
Changed,
/// A slider is being dragged: apply settings live but defer saving.
SliderDrag,
/// A category volume slider (`SoundSource` index) moved to a new value,
/// after its `SliderDrag` (the sound slider's `onValueUpdate`).
Volume(usize, f32),
/// The language changed (reload translations).
Language(String),
/// The pack selection changed (reload resources).
@@ -2745,8 +2767,7 @@ impl OptionsUi {
{
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
return events;
}
@@ -2859,8 +2880,7 @@ impl OptionsUi {
self.dragging = Some(opt);
let v = ui::slider_value(wd.x, wd.w, x);
self.slider_value = Some((opt, v, Instant::now()));
set_slider(opt, s, v);
events.push(Event::SliderDrag);
drag_slider(opt, s, v, &mut events);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
@@ -3422,6 +3442,15 @@ mod tests {
Opt::QuitShortcuts.tooltip_key(&s),
"options.quitShortcuts.tooltip"
);
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
assert!(Opt::OperatorItemsTab.available());
let mut s = Settings::default();
assert!(!s.operator_items_tab);
Opt::OperatorItemsTab.cycle(&mut s, false, [854, 480]);
assert!(s.operator_items_tab);
}
#[test]
@@ -4102,7 +4131,7 @@ mod tests {
for cx in [-100.0, 250.0, 1000.0] {
assert_eq!(
ui.click(&mut s, &atlas, cx, -100.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
vec![Event::SliderDrag, Event::Volume(0, s.master_volume)]
);
}
assert_eq!(ui.release(), vec![Event::ButtonClick, Event::Changed]);
@@ -4112,6 +4141,35 @@ mod tests {
);
}
#[test]
fn a_moved_volume_slider_reports_its_category_and_value() {
let atlas = atlas();
let size = (480.0, 320.0);
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Sound);
let hostile = SOUND_SOURCES.iter().position(|n| *n == "hostile").unwrap();
let (widgets, _, _, _) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|wd| matches!(wd.kind, WKind::Slider { opt: Opt::Volume(i), .. } if i == hostile))
.unwrap();
let y = slider.y + 1.0;
let x = slider.x + 4.0 + (slider.w - 8.0) * 0.5;
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let volume = s.volumes[hostile];
assert!((volume - 0.5).abs() < 1e-3, "{volume}");
assert_eq!(
events,
vec![Event::SliderDrag, Event::Volume(hostile, volume)]
);
// OptionInstance.set: an unchanged value runs no callback.
assert_eq!(
ui.click(&mut s, &atlas, x, -50.0, size, [854, 480], true, false),
vec![Event::SliderDrag]
);
}
#[test]
fn classic_sliders_follow_the_pointer_and_snap_on_release() {
let atlas = atlas();
@@ -0,0 +1,179 @@
//! `SoundPreviewHandler`: the sound a category volume slider plays while it
//! moves on the title screen's options (`Options.createSoundSliderOptionInstance`
//! calls `preview` when there is no level).
use crate::settings::SOUND_SOURCES;
use crate::sound_engine::{InstanceId, SoundEngine, SoundInstance, Sounds};
/// The sound `SoundPreviewHandler.preview` plays for a `SoundSource` index,
/// or `None` (`SoundEvents.EMPTY`) for master, music and voice.
pub fn preview_event(source: usize) -> Option<&'static str> {
Some(match *SOUND_SOURCES.get(source)? {
"record" => "minecraft:block.note_block.guitar",
"weather" => "minecraft:entity.lightning_bolt.thunder",
"block" => "minecraft:block.grass.place",
"hostile" => "minecraft:entity.zombie.ambient",
// SoundEvents.COW_SOUNDS.get(CowSoundVariants.SoundSet.CLASSIC).ambientSound()
"neutral" => "minecraft:entity.cow.ambient",
"player" => "minecraft:entity.generic.eat",
"ambient" => "minecraft:ambient.cave",
"ui" => "minecraft:ui.button.click",
_ => return None,
})
}
/// `SoundPreviewHandler`'s `activePreview` and `previousCategory`.
#[derive(Debug, Default)]
pub struct SoundPreview {
active: Option<InstanceId>,
previous: Option<usize>,
}
impl SoundPreview {
/// `SoundPreviewHandler.preview(soundManager, category, volume)`: stop the
/// preview of another category, then, unless the last preview still
/// plays, play this category's sound (`SimpleSoundInstance.forUI` at
/// pitch 1 and the slider's volume).
pub fn preview(&mut self, sounds: &mut impl Sounds, source: usize, volume: f32) {
self.stop_other_category_preview(sounds, source);
if !self.can_play_sound(sounds) {
return;
}
let Some(event) = preview_event(source) else {
return;
};
if let Some(old) = self.active.take() {
sounds.release(old);
}
let instance = SoundInstance::for_ui(event, 1.0, volume, SoundEngine::unseeded());
self.active = Some(sounds.play_instance(instance, true).0);
}
/// The preview playing now, if any.
pub fn active(&self) -> Option<InstanceId> {
self.active
}
/// `stopOtherCategoryPreview`.
fn stop_other_category_preview(&mut self, sounds: &mut impl Sounds, source: usize) {
if self.previous != Some(source) {
self.previous = Some(source);
if let Some(active) = self.active {
sounds.stop_instance(active);
}
}
}
/// `canPlaySound`: no preview yet, or the last one has finished.
fn can_play_sound(&self, sounds: &mut impl Sounds) -> bool {
self.active.is_none_or(|active| !sounds.is_active(active))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, UI, test_backend::Null};
use crate::sound_events::SoundRegistry;
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"entity.zombie.ambient": {"sounds": ["mob/zombie/say1"]},
"entity.cow.ambient": {"sounds": ["mob/cow/say1"]},
"ui.button.click": {"sounds": ["random/click"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn source(name: &str) -> usize {
SOUND_SOURCES.iter().position(|s| *s == name).unwrap()
}
#[test]
fn each_category_previews_its_vanilla_sound() {
let expected = [
("master", None),
("music", None),
("record", Some("minecraft:block.note_block.guitar")),
("weather", Some("minecraft:entity.lightning_bolt.thunder")),
("block", Some("minecraft:block.grass.place")),
("hostile", Some("minecraft:entity.zombie.ambient")),
("neutral", Some("minecraft:entity.cow.ambient")),
("player", Some("minecraft:entity.generic.eat")),
("ambient", Some("minecraft:ambient.cave")),
("voice", None),
("ui", Some("minecraft:ui.button.click")),
];
for (name, event) in expected {
assert_eq!(preview_event(source(name)), event, "{name}");
}
}
fn slide(
preview: &mut SoundPreview,
engine: &mut SoundEngine,
backend: &mut Null,
name: &str,
volume: f32,
) {
preview.preview(&mut Bound { engine, backend }, source(name), volume);
}
/// Ticks enough for the engine to drop finished or stopped channels.
fn settle(engine: &mut SoundEngine, backend: &mut Null) {
for _ in 0..40 {
engine.tick(false, &TickContext::default(), backend);
}
}
#[test]
fn a_moving_slider_plays_one_preview_at_a_time_and_switching_stops_it() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut preview = SoundPreview::default();
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.4);
let first = preview
.active()
.expect("the hostile slider plays a preview");
let instance = engine.instance(first).unwrap();
assert_eq!(instance.identifier, "minecraft:entity.zombie.ambient");
assert_eq!(instance.source, UI);
assert_eq!((instance.volume, instance.pitch), (0.4, 1.0));
assert!(instance.relative);
assert!(engine.is_active(first));
// canPlaySound: still playing, so dragging further does not restart it.
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
assert_eq!(preview.active(), Some(first));
assert_eq!(engine.active_instances(), vec![first]);
// Once it has finished, the next move plays it again.
for stopped in backend.stopped.values_mut() {
*stopped = true;
}
settle(&mut engine, &mut backend);
assert!(!engine.is_active(first));
slide(&mut preview, &mut engine, &mut backend, "hostile", 0.6);
let second = preview.active().unwrap();
assert_ne!(second, first);
assert!(engine.is_active(second));
// Another category stops the playing preview (SoundManager.stop); the
// engine lets its channel go on the next ticks, then that category's
// own sound plays.
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
assert!(backend.stopped.values().all(|stopped| *stopped));
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "neutral", 1.0);
let third = preview.active().unwrap();
assert_eq!(
engine.instance(third).unwrap().identifier,
"minecraft:entity.cow.ambient"
);
// Master has no sound but still stops the other category's preview.
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
settle(&mut engine, &mut backend);
assert!(engine.active_instances().is_empty());
slide(&mut preview, &mut engine, &mut backend, "master", 1.0);
assert!(engine.active_instances().is_empty());
}
}
@@ -3,6 +3,7 @@
//! hands immutable `Arc` snapshots to workers; results carry a sequence number
//! so stale work is dropped.
use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
@@ -81,6 +82,12 @@ pub trait ChunkGenerator: Send + Sync + 'static {
fn light_view(&self, _proto: &Proto) -> Option<Chunk> {
None
}
/// A decorated proto chunk's column sections (`Chunk::sections`
/// indexes) holding a block other than air: what its neighbours' light
/// layers count once it is at INITIALIZE_LIGHT.
fn non_air_sections(&self, _proto: &Proto) -> Option<SectionMask> {
None
}
fn saved_proto(&self, _proto: &Proto) -> Option<crate::chunk::SavedProto> {
None
}
@@ -275,6 +282,7 @@ impl Mailbox {
pub struct Streaming {
shared: Arc<Shared>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
@@ -653,9 +661,14 @@ impl Streaming {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
scan_age: 0,
@@ -956,6 +969,15 @@ impl Streaming {
pub fn is_lit(&self, x: i32, z: i32) -> bool {
self.lit.contains(&(x, z))
}
/// The non-air sections of a column that is a decorated proto chunk
/// here (vanilla's INITIALIZE_LIGHT and on), if it is one: a level
/// chunk's light layers count them (#4294).
pub fn proto_sections(&self, x: i32, z: i32) -> Option<SectionMask> {
if !self.decorated.contains(&(x, z)) {
return None;
}
(self.non_air_sections)(self.protos.get(&(x, z))?.as_ref()?)
}
/// `ChunkMap.getLatestStatus` for the loading screen's chunk map: the
/// furthest step this column has reached, `None` before it is asked for.
pub fn latest_status(
@@ -1475,6 +1497,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
if !self.light_snapshots.is_empty() {
for p in positions {
self.light_edit_sequence += 1;
@@ -1504,6 +1527,7 @@ impl Streaming {
&mut changed,
);
Self::mark_light_changes(world, &changed);
Self::relight_beside_unlit(world, &changed);
}
let oldest = self.light_snapshots.values().copied().min();
match oldest {
@@ -1511,6 +1535,14 @@ impl Streaming {
None => self.light_log.clear(),
}
}
/// A column whose light an edit changed beside a column not lit yet gets
/// a full light job ([`crate::light::beside_unlit`]).
fn relight_beside_unlit(world: &mut World, changed: &LightChanges) {
let columns = crate::light::beside_unlit(changed, |x, z| {
world.chunks.get(&(x, z)).is_some_and(|c| !c.lit)
});
world.dirty_light.extend(columns);
}
/// Sections whose meshes sample a changed light value (faces and corners
/// read light from neighbouring blocks).
fn mark_light_changes(world: &mut World, changed: &LightChanges) {
@@ -262,6 +262,14 @@ impl World {
pub fn layout(&self) -> ColumnLayout {
self.shape.column()
}
/// The lowest block Y the level's chunks hold.
pub fn bottom_y(&self) -> i32 {
self.layout().min_y()
}
/// One above the highest block Y the level's chunks hold.
pub fn top_y(&self) -> i32 {
self.layout().max_y()
}
/// Resident storage write; marks the touched sections (and neighbours on
/// section borders) for remeshing and the surrounding chunks for relighting.
/// Neighbour updates, drops and block entities belong to the gameplay layer.
@@ -1,9 +1,10 @@
//! The creative inventory, for tools/test_creative_parity.py: loads an
//! exported `creative-inventory.json` into `horizon_client::creative::Creative`
//! and prints the tabs it shows (canonical stacks) and the search tab's
//! results for every query of the vanilla oracle's output.
//! results for every query of the vanilla oracle's output; with `permissions`,
//! as an operator with the Operator Items Tab option on.
//!
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT
//! usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]
use horizon_client::{
creative::{Creative, Names},
item_data::canon_stack,
@@ -13,11 +14,13 @@ use std::{fs, path::Path};
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let usage = "usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT";
let usage =
"usage: creative_search_fixture RESOURCES CREATIVE_JSON VANILLA_JSON OUTPUT [permissions]";
let names = Names::load(Path::new(args.get(1).ok_or(usage)?));
let creative = Creative::from_json(&serde_json::from_slice(&fs::read(
let mut creative = Creative::from_json(&serde_json::from_slice(&fs::read(
args.get(2).ok_or(usage)?,
)?)?)?;
creative.set_permissions(args.get(5).is_some_and(|a| a == "permissions"), &names);
let vanilla: Value = serde_json::from_slice(&fs::read(args.get(3).ok_or(usage)?)?)?;
let tabs: Vec<Value> = creative
.tabs
@@ -4,7 +4,12 @@
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
Mob, MobTypes, Species, cube, drops, enderman, gear, raider, render::DYE_NAMES, sulfur_cube,
},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
@@ -65,6 +70,13 @@ pub fn main() -> horizon_client::Result<()> {
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root, &PackOverlay::NONE)?;
// Block loot for an enderman's carried block (BlockState.getDrops).
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let block_items = Items::load(root, registry, &facts, &PackOverlay::NONE)?;
// The stand-in level's random (RandomSource.create(0)), never reset.
let mut level_random = LegacyRandom::new(0);
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
@@ -121,6 +133,13 @@ pub fn main() -> horizon_client::Result<()> {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if let Some(block) = c.get("carried") {
m.extra.carried = Some(
registry
.default_state(block)
.ok_or_else(|| format!("unknown block {block}"))?,
);
}
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
@@ -272,6 +291,23 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
registry,
state,
&mut level_random,
Some((&mut sequences, world_seed)),
);
for s in stacks {
let _ = write!(out, "drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
let _ = writeln!(out);
}
Ok(())
}
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
@@ -99,6 +99,7 @@ mod toggle_keys_probe;
mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -191,6 +192,7 @@ probes! {
text_display_fixture,
text_display_render_fixture,
trading_fixture,
trident_probe,
translucency_fixture,
weather_fixture,
world_border_render_fixture,
@@ -76,6 +76,7 @@ pub fn main() -> Result<()> {
raining_at: &|_| false,
height_no_leaves: &|_| 0,
firefly_bush_sounds: false,
creaking_active: false,
sky_darken: 0,
marker: target.map(|name| (name, &options as &dyn Fn(_) -> _)),
model_offset: &|_, _, _| None,
@@ -0,0 +1,130 @@
//! Thrown tridents over the client's resident scene (`arrows::clip_block_stop`),
//! for tools/test_trident_parity.py: `AbstractArrow.tick`'s block step and
//! `AbstractArrow.onHitBlock`, one line of state per fixture.
#![forbid(unsafe_code)]
use horizon_client::{
Result, arrows, assets::Assets, blocks::BlockFacts, collisions, world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, Write},
path::PathBuf,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn vector(v: &Value) -> Vec3 {
Vec3::new(number(&v[0]), number(&v[1]), number(&v[2]))
}
fn bits(v: Vec3) -> String {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: trident_probe RESOURCES FIXTURE_DIRECTORY --describe|REFERENCE".into());
}
let root = PathBuf::from(&args[0]);
let dir = PathBuf::from(&args[1]);
let assets = Assets::load(&root)?;
let render = assets.reload(&root)?;
let mut scene = World::test_scene(&assets, &render, 1)?;
fs::write(
dir.join("scene.json"),
serde_json::to_vec(
&serde_json::json!({"radius":1,"resident_chunks":scene.chunks.len(),"model_gallery":render.model_gallery}),
)?,
)?;
if args[2] == "--describe" {
return Ok(());
}
let binding = collisions::load(&root, assets.registry)?;
let facts = BlockFacts::load(&root, assets.registry)?;
let side = &args[2];
let mut out = std::io::BufWriter::new(fs::File::create(dir.join(format!("rust-{side}.txt")))?);
for line in std::io::BufReader::new(fs::File::open(dir.join("inputs.txt"))?).lines() {
let f: Value = serde_json::from_str(&line?)?;
let mut position = vector(&f["position"]);
let mut velocity = vector(&f["velocity"]);
let mut in_ground = false;
let mut shake = 0u32;
for op in f["ops"].as_array().unwrap() {
match op["mode"].as_str().unwrap() {
"block" => {
let p = &op["position"];
let mut state = assets
.registry
.default_state(op["name"].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = op["properties"].as_object() {
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
scene.set(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
state,
)?;
}
// Ops only the vanilla oracle needs.
"level_seed" | "owner" | "pickup" | "touch" | "target" | "velocity" | "ignite" => {}
"tick" => {
// AbstractArrow.tick: shakeTime down, then the block step.
shake = shake.saturating_sub(1);
if in_ground {
continue;
}
if let Some((stop, _)) =
arrows::clip_block_stop(&scene, &facts, &binding, position, velocity)
{
// onHitBlock: pulled back, stopped, stuck, shaking 7 ticks.
position = stop;
velocity = Vec3::ZERO;
in_ground = true;
shake = 7;
} else {
// A MISS moves the whole movement, then air drag and gravity.
position = Vec3::new(
position.x + velocity.x,
position.y + velocity.y,
position.z + velocity.z,
);
velocity = Vec3::new(
velocity.x * f64::from(0.99f32),
velocity.y * f64::from(0.99f32) - 0.05,
velocity.z * f64::from(0.99f32),
);
}
}
other => return Err(format!("unknown operation {other}").into()),
}
}
out.write_all(
format!(
"trident {} {} {in_ground} {shake}\n",
bits(position),
bits(velocity)
)
.as_bytes(),
)?;
}
Ok(())
}
+40
View File
@@ -101,6 +101,8 @@ enum Animate {
DeadBush,
/// `ShortDryGrassBlock` and `TallDryGrassBlock`.
DryGrass,
/// A creaking heart that is not uprooted (`CreakingHeartBlock.animateTick`).
CreakingHeart,
}
fn step(facing: &str) -> [i32; 3] {
match facing {
@@ -236,6 +238,9 @@ fn classify(name: &str, prop: &dyn Fn(&str) -> Option<String>) -> Animate {
"firefly_bush" => Animate::FireflyBush,
"open_eyeblossom" => Animate::OpenEyeblossom,
"pale_hanging_moss" => Animate::PaleHangingMoss,
"creaking_heart" if prop("creaking_heart_state").as_deref() != Some("uprooted") => {
Animate::CreakingHeart
}
"dead_bush" => Animate::DeadBush,
"short_dry_grass" | "tall_dry_grass" => Animate::DryGrass,
"sculk_sensor" | "calibrated_sculk_sensor"
@@ -442,6 +447,8 @@ pub struct AnimateContext<'a> {
pub height_no_leaves: &'a dyn Fn([i32; 3]) -> i32,
/// `EnvironmentAttributes.FIREFLY_BUSH_SOUNDS` (overworld day timeline).
pub firefly_bush_sounds: bool,
/// `EnvironmentAttributes.CREAKING_ACTIVE` (overworld day timeline).
pub creaking_active: bool,
/// `Level.getSkyDarken`, for `getMaxLocalRawBrightness`.
pub sky_darken: i32,
/// Creative main-hand marker block and a resolver for each placed state's sprite.
@@ -1259,6 +1266,39 @@ fn block_animate_tick(
}
}
}
Animate::CreakingHeart => {
// CreakingHeartBlock.animateTick: the idle creak is drawn only while
// the attribute is on, then needs pale oak logs on all six sides
// (`CreakingHeartBlock.isSurroundedByLogs`).
if ctx.creaking_active
&& next_int(engine.random(), 16) == 0
&& [
[-1, 0, 0],
[1, 0, 0],
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
]
.iter()
.all(|d| {
let p = [pos[0] + d[0], pos[1] + d[1], pos[2] + d[2]];
tables
.pale_oak_logs
.get(ctx.state(p).0 as usize)
.copied()
.unwrap_or(false)
})
{
sound(
engine,
"minecraft:block.creaking_heart.idle",
[fx, fy, fz],
1.0,
1.0,
);
}
}
Animate::DeadBush => {
// AmbientDesertBlockSoundsPlayer.playAmbientDeadBushSounds.
if next_int(engine.random(), 130) == 0 {
+64 -4
View File
@@ -186,6 +186,8 @@ pub struct ArrowState {
/// The arrow's own random (`Entity.random`).
pub random: LegacyRandom,
pub events: Vec<ArrowEvent>,
/// The speed (`deltaMovement.length()`) it hit a block at, before it stuck.
pub hit_speed: f64,
}
impl fmt::Debug for ArrowState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
@@ -230,6 +232,7 @@ impl ArrowState {
removed: false,
random,
events: Vec::new(),
hit_speed: 0.0,
}
}
/// The `AbstractArrow` constructor's `pickupItemStack`: the drawn ammo
@@ -375,6 +378,61 @@ impl ArrowScene<'_, '_> {
true
}
}
/// `AbstractArrow.tick`'s block phase for a thrown trident: the zero-width
/// `level().clip(new ClipContext(position(), position().add(movement),
/// ClipContext.Block.COLLIDER, ClipContext.Fluid.NONE, this))`, then
/// `AbstractArrow.onHitBlock`'s `position().subtract(signum * 0.05)`. Returns
/// the position to stop at (pull-back included) and the block that was hit.
pub fn clip_block_stop(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos)> {
let to = Vec3::new(
from.x + movement.x,
from.y + movement.y,
from.z + movement.z,
);
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let hit = horizon_core::geometry::traverse_blocks(from, to, 1024, |p: BlockPos| {
let state = world.block(p.x, p.y, p.z);
if facts.is_air(state) {
return None;
}
let shape = collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(from, to, p)
})
.ok()
.flatten()?;
Some((back_off(hit.location, movement), hit.block))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
pub fn back_off(location: Vec3, movement: Vec3) -> Vec3 {
fn signum(v: f64) -> f64 {
if v > 0.0 {
1.0
} else if v < 0.0 {
-1.0
} else {
0.0
}
}
let offset = f64::from(0.05f32);
Vec3::new(
location.x - signum(movement.x) * offset,
location.y - signum(movement.y) * offset,
location.z - signum(movement.z) * offset,
)
}
fn bounds(at: Vec3) -> Aabb {
// EntityDimensions.makeBoundingBox: half the float width.
let w = f64::from(WIDTH / 2.0);
@@ -664,7 +722,7 @@ impl Ticker<'_, '_, '_, '_> {
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
self.st.fall_distance *= 0.5;
}
if self.position().y < f64::from(crate::chunk::MIN_Y - 64) {
if self.position().y < f64::from(self.scene.world.bottom_y() - 64) {
self.st.removed = true;
}
self.st.first_tick = false;
@@ -848,6 +906,8 @@ impl Ticker<'_, '_, '_, '_> {
/// it moved on, stop, and stick.
fn on_hit_block(&mut self, pos: BlockPos, state: BlockStateId) {
self.st.last_state = Some(state);
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
self.st.hit_speed = self.p.velocity.length();
// Projectile.onHitBlock: BlockState.onProjectileHit.
if self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
@@ -1159,10 +1219,10 @@ impl FluidInteractionWorld for Ticker<'_, '_, '_, '_> {
Ok(self.scene.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.scene.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+239 -55
View File
@@ -24,6 +24,31 @@ pub struct Tab {
/// tab is selected (`set_saved_hotbars`).
pub hotbar: bool,
}
impl Tab {
/// The tab without its stacks.
fn header(&self) -> Self {
Self {
id: self.id.clone(),
title: self.title.clone(),
icon: self.icon.clone(),
items: Vec::new(),
top: self.top,
column: self.column,
search: self.search,
inventory: self.inventory,
hotbar: self.hotbar,
}
}
}
/// A tab as exported, built with permissions: `operator` marks the stacks a
/// build without them leaves out (the export's `permissions_only`).
#[derive(Clone, Debug)]
struct Exported {
tab: Tab,
/// `CreativeModeTab.Type.CATEGORY`: shown only with a stack (`shouldDisplay`).
category: bool,
operator: Vec<bool>,
}
/// What the client's creative search trees (SessionSearchTrees) index for one
/// stack of the search tab: its creative tooltip lines and its item tags.
#[derive(Clone, Debug, Default)]
@@ -37,6 +62,8 @@ struct SearchEntry {
tags: Vec<(String, String)>,
}
pub struct Creative {
/// The tabs shown (`CreativeModeTabs.tabs()`), with the stacks the current
/// permissions put in them.
pub tabs: Vec<Tab>,
pub selected: usize,
/// Scroll position 0..1 (scrollOffs).
@@ -48,6 +75,13 @@ pub struct Creative {
/// The search tab's stacks, in its display order.
search_items: Vec<ItemStack>,
search_index: Vec<SearchEntry>,
/// Every exported tab, and the search tab's stacks with their entries and
/// whether only permissions list them: `rebuild` keeps what is shown.
exported: Vec<Exported>,
exported_search: Vec<(ItemStack, SearchEntry, bool)>,
/// The `hasPermissions` the tabs were last built with
/// (`CreativeModeTabs.tryRebuildTabContents`).
permissions: bool,
/// ItemPickerMenu.items: what the grid shows, filled when the tab or the
/// search changes (selectTab, refreshSearchResults), not on every frame.
shown: Mutex<Option<Shown>>,
@@ -202,14 +236,22 @@ impl Creative {
}
pub fn from_json(raw: &Value) -> Result<Self> {
let mut tabs = Vec::new();
let mut search_items = Vec::new();
let mut search_index = Vec::new();
let mut search = Vec::new();
for t in raw["tabs"].as_array().into_iter().flatten() {
// The stacks only a build with permissions lists, by index.
let only: Vec<u64> = t["permissions_only"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_u64)
.collect();
let mut items = Vec::new();
for v in t["items"].as_array().into_iter().flatten() {
let mut operator = Vec::new();
for (i, v) in t["items"].as_array().into_iter().flatten().enumerate() {
let snbt = v.as_str().unwrap_or("-");
if let Some(stack) = crate::item_data::parse_stack(snbt)? {
items.push(stack);
operator.push(only.contains(&(i as u64)));
}
}
let kind = t["type"].as_str().unwrap_or("");
@@ -223,34 +265,35 @@ impl Creative {
};
for (i, stack) in items.iter().enumerate() {
let (namespace, path) = split_id(&java_lower(&stack.item));
search_index.push(SearchEntry {
let entry = SearchEntry {
namespace,
path,
lines: strings(&t["lines"][i]),
tags: strings(&t["tags"][i]).iter().map(|t| split_id(t)).collect(),
});
};
search.push((stack.clone(), entry, operator[i]));
}
search_items = items.clone();
}
if t["visible"] == false {
continue;
}
let icon = t["icon"]
.as_str()
.and_then(|s| crate::item_data::parse_stack(s).ok().flatten());
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
tabs.push(Exported {
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: icon.map_or_else(|| "minecraft:stone".to_owned(), |s| s.item.to_string()),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
},
category: kind == "CATEGORY",
operator,
});
}
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
/// The old export: item ids per tab, no components, tooltips or tags.
fn from_legacy(raw: &Value) -> Result<Self> {
@@ -272,34 +315,39 @@ impl Creative {
}
}
let kind = t["type"].as_str().unwrap_or("");
tabs.push(Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
tabs.push(Exported {
operator: vec![false; items.len()],
tab: Tab {
id: t["id"].as_str().unwrap_or("").to_owned(),
title: t["title"].as_str().unwrap_or("").to_owned(),
icon: t["icon"].as_str().unwrap_or("minecraft:stone").to_owned(),
items,
top: t["row"] == "TOP",
column: t["column"].as_u64().unwrap_or(0) as u32,
search: kind == "SEARCH",
inventory: kind == "INVENTORY",
hotbar: false,
},
category: false,
});
}
let search_index = search_items
.iter()
let search = search_items
.into_iter()
.map(|s| {
let (namespace, path) = split_id(&java_lower(&s.item));
SearchEntry {
let entry = SearchEntry {
namespace,
path,
..Default::default()
}
};
(s, entry, false)
})
.collect();
Ok(Self::new(tabs, search_items, search_index))
Ok(Self::new(tabs, search))
}
fn new(tabs: Vec<Tab>, search_items: Vec<ItemStack>, search_index: Vec<SearchEntry>) -> Self {
Self {
tabs,
fn new(exported: Vec<Exported>, exported_search: Vec<(ItemStack, SearchEntry, bool)>) -> Self {
let mut creative = Self {
tabs: Vec::new(),
selected: 0,
scroll: 0.0,
scrolling: false,
@@ -310,10 +358,81 @@ impl Creative {
search.text_color = u32::MAX;
search
},
search_items,
search_index,
search_items: Vec::new(),
search_index: Vec::new(),
exported,
exported_search,
permissions: false,
shown: Mutex::new(None),
};
creative.rebuild();
creative
}
/// `CreativeModeTabs.buildAllTabContents` with `permissions`: the operator
/// tab's display items generator lists its stacks only with them
/// (`hasPermissions`), and the search tab with it; a category tab is
/// shown with a stack (`CreativeModeTab.shouldDisplay`).
fn rebuild(&mut self) {
let permissions = self.permissions;
let keep = |operator: bool| permissions || !operator;
self.tabs = self
.exported
.iter()
.filter_map(|e| {
let items: Vec<ItemStack> = (e.tab.items.iter().zip(&e.operator))
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
items,
..e.tab.header()
})
})
.collect();
(self.search_items, self.search_index) = self
.exported_search
.iter()
.filter(|(_, _, o)| keep(*o))
.map(|(s, e, _)| (s.clone(), e.clone()))
.unzip();
self.forget_items();
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
/// when that changed, the tabs are built again. A selected tab left
/// without stacks (the operator tab) gives way to the default tab; any
/// other keeps its first row (`refreshCurrentTabContents`). True when the
/// tabs were rebuilt.
pub fn set_permissions(&mut self, permissions: bool, names: &Names) -> bool {
if permissions == self.permissions {
return false;
}
let selected = self.tab().id.clone();
let row = self.first_row(names);
self.permissions = permissions;
self.rebuild();
match self.tabs.iter().position(|t| t.id == selected) {
Some(tab) => {
self.selected = tab;
// ItemPickerMenu.getScrollForRowIndex.
let hidden = self.hidden_rows(names);
self.scroll = if hidden > 0 {
(row as f32 / hidden as f32).clamp(0.0, 1.0)
} else {
0.0
};
}
None => {
// CreativeModeTabs.getDefaultTab.
let default = self
.tabs
.iter()
.position(|t| t.id == "minecraft:building_blocks");
self.select(default.unwrap_or(0));
}
}
true
}
/// Where a stack comes in the creative tabs (the search tab lists them
/// all, in tab order): the same stack's place, else its item's first.
@@ -327,20 +446,7 @@ impl Creative {
}
/// The tabs without their stacks: what the screen draws of them each frame.
pub fn tab_headers(&self) -> Vec<Tab> {
self.tabs
.iter()
.map(|t| Tab {
id: t.id.clone(),
title: t.title.clone(),
icon: t.icon.clone(),
items: Vec::new(),
top: t.top,
column: t.column,
search: t.search,
inventory: t.inventory,
hotbar: t.hotbar,
})
.collect()
self.tabs.iter().map(Tab::header).collect()
}
pub fn tab(&self) -> &Tab {
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -601,7 +707,7 @@ impl Creative {
pub fn tab_position(tab: &Tab) -> (f32, f32) {
let mut x = tab.column as f32 * 27.0;
if tab.column >= 5 {
// Right-aligned tabs (search, inventory).
// Right-aligned tabs (search, operator, inventory).
x = Self::WIDTH - 27.0 * (7 - tab.column) as f32 + 1.0;
}
let y = if tab.top { -28.0 } else { Self::HEIGHT - 4.0 };
@@ -1083,6 +1189,84 @@ mod tests {
assert_eq!(c.search.value, "");
}
/// An export built with permissions: the operator tab's stacks, and their
/// place at the end of the search tab, only with them.
fn with_operator_tab() -> Creative {
let stack = |id: &str| format!("{{count:1,id:\"minecraft:{id}\"}}");
let raw = serde_json::json!({"schema": 3, "tabs": [
{"id": "minecraft:building_blocks", "type": "CATEGORY", "row": "TOP", "column": 0, "title": "Building Blocks",
"icon": stack("bricks"), "visible": true, "items": [stack("stone")]},
{"id": "minecraft:op_blocks", "type": "CATEGORY", "row": "BOTTOM", "column": 5, "title": "Operator Utilities",
"icon": stack("command_block"), "visible": false,
"items": [stack("command_block"), stack("barrier")], "permissions_only": [0, 1]},
{"id": "minecraft:search", "type": "SEARCH", "row": "TOP", "column": 6, "title": "Search Items",
"icon": stack("compass"), "visible": true,
"items": [stack("stone"), stack("command_block"), stack("barrier")],
"lines": [["Stone"], ["Command Block"], ["Barrier"]], "tags": [[], [], []],
"permissions_only": [1, 2]},
{"id": "minecraft:inventory", "type": "INVENTORY", "row": "BOTTOM", "column": 6, "title": "Survival Inventory",
"icon": stack("chest"), "visible": true, "items": []}
]});
Creative::from_json(&raw).unwrap()
}
fn tab_ids(c: &Creative) -> Vec<&str> {
c.tabs.iter().map(|t| t.id.as_str()).collect()
}
#[test]
fn operator_stacks_stay_out_without_permissions() {
let c = with_operator_tab();
let names = Names::default();
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
assert_eq!(c.search_items().len(), 1);
assert!(c.search_results("command", &names).is_empty());
assert_eq!(c.search_results("", &names), [0]);
}
#[test]
fn operator_tab_comes_and_goes_with_permissions() {
let mut c = with_operator_tab();
let names = Names::default();
assert!(!c.set_permissions(false, &names));
assert!(c.set_permissions(true, &names));
assert!(!c.set_permissions(true, &names));
assert_eq!(
tab_ids(&c),
[
"minecraft:building_blocks",
"minecraft:op_blocks",
"minecraft:search",
"minecraft:inventory"
]
);
let op: Vec<&str> = c.tabs[1].items.iter().map(|s| s.item.as_ref()).collect();
assert_eq!(op, ["minecraft:command_block", "minecraft:barrier"]);
assert_eq!(c.search_results("command", &names), [1]);
assert_eq!(c.search_results("", &names), [0, 1, 2]);
// The search tab keeps its place across the rebuild.
c.select(2);
c.search.set_value("block");
assert!(c.set_permissions(false, &names));
assert!(c.tab().search);
assert_eq!(c.search.value, "block");
assert!(c.items(&names).is_empty());
// The selected operator tab is left empty: the default tab instead.
c.set_permissions(true, &names);
c.select(1);
assert_eq!(c.items(&names).len(), 2);
c.set_permissions(false, &names);
assert_eq!(c.tab().id, "minecraft:building_blocks");
assert_eq!(c.items(&names)[0].item.as_ref(), "minecraft:stone");
}
#[test]
fn saved_hotbars_tab_shows_its_rows_and_locks_the_hint() {
let mut c = Creative::from_json(&serde_json::json!({"schema": 2, "tabs": [
+99
View File
@@ -4,6 +4,7 @@
//! (the entity goes through on the next tick), and
//! `TheEndGatewayBlockEntity.getPortalPosition` (where it lands).
use crate::nether_portal::{ENTITY_DELAY, PortalState};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// The End gateway travel of one entity.
@@ -65,6 +66,35 @@ pub fn swept_gateway(
found
}
/// The End gateway a thrown ender pearl passed through on its last move, from
/// where it started the tick to where it is (`Entity.checkInsideBlocks` from
/// `ThrowableProjectile.tick`'s `applyEffectsFromBlocks`). Other projectiles
/// are not looked at.
pub fn pearl_gateway(
pearl: &Projectile,
is_gateway: impl FnMut(BlockPos) -> bool,
) -> Option<BlockPos> {
if !matches!(pearl.kind, ProjectileKind::EnderPearl) {
return None;
}
let size = pearl.kind.size() as f32;
swept_gateway(pearl.previous, pearl.position, size, size, is_gateway)
}
/// A thrown ender pearl at its gateway's exit `at`
/// (`EndGatewayBlock.getPortalDestination` for a `ThrownEnderpearl`: a
/// `TeleportTransition` with no motion, rotation 0 and nothing relative).
/// It falls from there and takes its owner where it lands.
pub fn place_pearl(pearl: &mut Projectile, at: Vec3) {
pearl.position = at;
pearl.previous = at;
pearl.velocity = Vec3::ZERO;
pearl.yaw = 0.0;
pearl.pitch = 0.0;
// A server tells its clients of the jump at once.
pearl.needs_sync = true;
}
/// The bottom centre of a block (`Vec3.atBottomCenterOf`): where a traveller
/// lands at a gateway's exit.
pub fn bottom_center(p: [i32; 3]) -> Vec3 {
@@ -150,6 +180,75 @@ mod tests {
assert_eq!(traveller.travel(false), None);
}
/// A world with no blocks in the way and nothing to hit: the gateway
/// block has no collision shape, so a projectile flies into it.
struct Open;
impl crate::projectiles::ProjectileWorld for Open {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: horizon_core::geometry::Aabb) -> bool {
false
}
fn targets(&self) -> &[(crate::mobs::Who, horizon_core::geometry::Aabb)] {
&[]
}
}
/// The ticks a projectile flies, thrown east at the gateway, until its
/// move goes through one (`None` if it never does in 5 ticks).
fn thrown_at_gateway(kind: ProjectileKind) -> (Projectile, Option<BlockPos>) {
let mut shot = Projectile::new(kind, at(699.5, 100.3, 0.5), None);
shot.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
shot.tick(&Open);
if let Some(g) = pearl_gateway(&shot, is_gateway) {
return (shot, Some(g));
}
}
(shot, None)
}
#[test]
fn a_thrown_pearl_goes_through_the_gateway_and_falls_from_its_exit() {
let (mut pearl, found) = thrown_at_gateway(ProjectileKind::EnderPearl);
let gateway = BlockPos::new(702, 100, 0);
// ThrownEnderpearl in the gateway block: EndGatewayBlock.entityInside
// takes it in, and it goes through on the next tick.
assert_eq!(found, Some(gateway));
let mut traveller = Traveller::default();
traveller.enter(gateway);
assert_eq!(traveller.travel(true), Some(gateway));
// getPortalDestination: at the exit, no motion and rotation 0.
let exit = bottom_center([8, 100, 0]);
place_pearl(&mut pearl, exit);
assert_eq!(pearl.position, exit);
assert_eq!(pearl.previous, exit);
assert_eq!(pearl.velocity, Vec3::ZERO);
assert_eq!((pearl.yaw, pearl.pitch), (0.0, 0.0));
// It falls straight down from the exit (ThrowableProjectile gravity,
// then inertia), where it lands and takes its owner along.
pearl.tick(&Open);
assert_eq!((pearl.position.x, pearl.position.z), (8.5, 0.5));
assert!((pearl.position.y - (100.0 - 0.03 * 0.99)).abs() < 1e-9);
// The pearl is out of the gateway: it does not go back at once.
assert_eq!(traveller.travel(true), None);
assert_eq!(traveller.cooldown(), ENTITY_DELAY - 1);
}
#[test]
fn a_pearl_flying_past_the_gateway_stays_out_of_it() {
// Thrown one block higher, over the gateway block.
let mut pearl = Projectile::new(ProjectileKind::EnderPearl, at(699.5, 101.3, 0.5), None);
pearl.velocity = at(1.5, 0.0, 0.0);
for _ in 0..5 {
pearl.tick(&Open);
assert_eq!(pearl_gateway(&pearl, is_gateway), None);
}
// Only the pearl travels: a snowball on the same path is not taken in.
assert_eq!(thrown_at_gateway(ProjectileKind::Snowball).1, None);
}
#[test]
fn a_traveller_lands_at_the_bottom_centre_of_the_exit() {
// TheEndGatewayBlockEntity.getPortalPosition: the exit's bottom
+3 -3
View File
@@ -83,10 +83,10 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
}
fn chunk_sections(&mut self, _: ()) -> Result<FluidSections<'_>, Error> {
static SECTIONS: [bool; crate::chunk::SECTIONS] = [true; crate::chunk::SECTIONS];
let layout = self.world.layout();
Ok(FluidSections {
minimum_section_y: crate::chunk::MIN_Y >> 4,
has_fluid: &SECTIONS,
minimum_section_y: layout.min_y() >> 4,
has_fluid: crate::chunk::every_section(layout),
})
}
fn bounding_box_min_y(&self) -> f64 {
+16
View File
@@ -1986,6 +1986,8 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
power: number(tag.get("ExplosionPower")).unwrap_or(1) as i32,
},
"minecraft:small_fireball" => ProjectileKind::SmallFireball,
"minecraft:wind_charge" => ProjectileKind::WindCharge,
"minecraft:breeze_wind_charge" => ProjectileKind::BreezeWindCharge,
_ => return None,
};
let owner = uuid_from_tag(tag.get("Owner"));
@@ -3419,6 +3421,20 @@ mod fireball_nbt_tests {
(p.kind, p.acceleration),
(ProjectileKind::LargeFireball { power: 1 }, 0.1)
);
// `/summon` and a saved world give back both wind charges.
for (id, kind) in [
("minecraft:wind_charge", ProjectileKind::WindCharge),
(
"minecraft:breeze_wind_charge",
ProjectileKind::BreezeWindCharge,
),
] {
let tag = Tag::compound([
("id", Tag::String(id.into())),
("Pos", doubles(&[0.0, 0.0, 0.0])),
]);
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
}
+370 -129
View File
@@ -136,6 +136,7 @@ mod lan_stations;
mod lan_trading;
mod lan_vehicles;
mod lan_waypoints;
mod launch_api;
mod leads;
mod level_particles;
mod levels;
@@ -162,6 +163,7 @@ mod native_panel;
mod native_panel_recipes;
mod native_recipes;
mod native_shaders;
mod native_world_read;
mod natural_spawning;
mod nautili;
mod net_harness;
@@ -189,6 +191,7 @@ mod pumpkin;
mod raids;
mod recipe_book;
mod remote;
mod remote_camera;
mod remote_chunks;
mod remote_mob_data;
mod remote_mob_particles;
@@ -255,7 +258,6 @@ use horizon_client::{
audio::Audio,
biome::Biomes,
blocks::{BlockFacts, Fluid},
chunk::{HEIGHT, MIN_Y},
clock::FixedStep,
collisions,
containers::{BlockEntity, Providers},
@@ -374,6 +376,16 @@ struct Options {
server_thread: bool,
/// What this Game executes; independent of its current server connection.
execution_role: execution_role::ExecutionRole,
/// `--fullscreen` (vanilla's): the window opens fullscreen whatever options.txt says,
/// as vanilla's DisplayData does, without saving it there.
fullscreen: bool,
/// `--game-dir DIR`: the folder `read` makes the working directory (saves, options.txt...).
game_dir: Option<PathBuf>,
/// `--quickPlaySingleplayer`, checked once the game folder is known.
quick_play: Option<String>,
session_file: Option<String>,
/// `--session-stdin`: the session file's JSON comes on standard input instead.
session_stdin: bool,
}
/// `--in-frame`, `--no-server-thread` and `HORIZON_SERVER_THREAD=0` are deprecated:
/// the in-frame world is unmaintained, gets no fixes and will be removed soon.
@@ -394,7 +406,65 @@ impl Options {
)
})
}
/// The command line, then what it asks of the process: the game folder, the account.
/// A bad command line is a [`launch_api::UsageError`] (exit code 2, no crash report).
fn read() -> Result<Option<Self>> {
let Some(mut o) = Self::parse(std::env::args().skip(1))
.map_err(|e| launch_api::UsageError(e.to_string()))?
else {
return Ok(None);
};
// The launcher's account: online-mode servers need it. A bad file
// only leaves the game offline.
let session = if o.session_stdin {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.map_err(|e| e.to_string())
.and_then(|_| horizon_client::auth::AuthSession::parse(&text))
.map(Some)
} else {
o.session_file
.take()
.filter(|p| !p.is_empty())
.map(|path| horizon_client::auth::AuthSession::load(path.as_ref()))
.transpose()
};
match session {
Ok(Some(session)) => {
o.player_name = Some(session.name.clone());
horizon_client::auth::set_current(session);
}
Ok(None) => {}
Err(e) => eprintln!("Horizon: not signed in: {e}"),
}
if let Some(dir) = o.game_dir.take() {
// Paths given on the command line stay relative to where the game was started.
o.resources = std::path::absolute(&o.resources)?;
o.report = o.report.map(std::path::absolute).transpose()?;
o.control = o.control.map(std::path::absolute).transpose()?;
std::fs::create_dir_all(&dir)
.and_then(|()| std::env::set_current_dir(&dir))
.map_err(|e| format!("game folder {}: {e}", dir.display()))?;
}
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
// world that exists opens; else the title screen. --world also creates one.
if let Some(world) = o.quick_play.take() {
if std::env::current_dir()?
.join("saves")
.join(&world)
.join("level.dat")
.is_file()
{
o.world = Some(world);
} else {
eprintln!("Quick play: there is no world {world} in saves/");
}
}
Ok(Some(o))
}
/// The command line alone: no file is read and nothing changes. `None` for `--help` and
/// `--launch-api`, which print and end the run.
fn parse(mut args: impl Iterator<Item = String>) -> Result<Option<Self>> {
let sibling = std::env::current_exe()?
.parent()
.ok_or("missing executable directory")?
@@ -442,18 +512,27 @@ impl Options {
on
}),
execution_role: execution_role::ExecutionRole::Client,
fullscreen: false,
game_dir: None,
quick_play: None,
session_file: std::env::var("HORIZON_SESSION_FILE").ok(),
session_stdin: false,
};
let mut session_file = std::env::var("HORIZON_SESSION_FILE").ok();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..128 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --quickPlaySingleplayer WORLD Open this world of saves/ at start, only if it exists (vanilla's)\n --quickPlayMultiplayer HOST[:PORT] The same as --server (vanilla's name)\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --session-stdin The same account as JSON on standard input\n --game-dir DIR Game folder: saves, options.txt, packs, mods (default: the working directory)\n --home DIR Logs and crash reports (default $HORIZON_HOME, else the platform data folder)\n --fullscreen Open the window fullscreen\n --launch-api Print the Launch API version and options as JSON (docs/api/launch.md)\n --dev-mod PATH A content mod (.hmod or folder) to develop: always on\n --in-frame DEPRECATED, do not use: unsupported, will be removed soon (also --no-server-thread)\n --pregen Add Horizon's /pregen command (docs/pregeneration.md)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
}
"--launch-api" => {
println!("{}", launch_api::reply(&horizon_client::crash::version()));
return Ok(None);
}
"--fullscreen" => o.fullscreen = true,
"--session-stdin" => o.session_stdin = true,
"--dedicated" => {
o.execution_role = execution_role::ExecutionRole::DedicatedServer;
o.dedicated = Some(args.by_ref().collect());
@@ -475,92 +554,92 @@ impl Options {
let value = args
.next()
.ok_or_else(|| format!("missing value after {arg}"))?;
match arg.as_str() {
"--resources" => o.resources = value.into(),
"--render-distance" | "--radius" => {
o.distance = value.parse()?;
o.distance_override = Some(o.distance);
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&o.distance) {
return Err(format!("render distance must be 2..{max}").into());
// A launcher shows the error to the player: it names the option.
let read = || -> Result<()> {
match arg.as_str() {
"--resources" => o.resources = value.into(),
"--render-distance" | "--radius" => {
o.distance = value.parse()?;
o.distance_override = Some(o.distance);
let max = horizon_client::options_file::MAX_RENDER_DISTANCE;
if !(2..=max).contains(&o.distance) {
return Err(format!("render distance must be 2..{max}").into());
}
}
}
"--seed" => {
o.seed = value.parse().unwrap_or_else(|_| {
// Text seeds hash like String.hashCode.
value
.encode_utf16()
.fold(0i32, |h, c| {
h.wrapping_mul(31).wrapping_add(i32::from(c))
})
.into()
})
}
"--frames" => o.frames = Some(value.parse()?),
"--world-frames" => {
o.world_frames = Some(value.parse()?);
// A frame-limited run in every other respect; --timeout-seconds still applies.
o.frames.get_or_insert(u64::MAX);
}
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
"--smoke-report" => o.report = Some(value.into()),
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
// Vanilla's quick play (Main, QuickPlay.joinSingleplayerWorld): only a
// world that exists opens; else the title screen. --world also creates one.
"--quickPlaySingleplayer" => {
let folder = std::env::current_dir()?.join("saves").join(&value);
if folder.join("level.dat").is_file() {
o.world = Some(value);
} else {
eprintln!("Quick play: there is no world {value} in saves/");
"--seed" => {
o.seed = value.parse().unwrap_or_else(|_| {
// Text seeds hash like String.hashCode.
value
.encode_utf16()
.fold(0i32, |h, c| {
h.wrapping_mul(31).wrapping_add(i32::from(c))
})
.into()
})
}
}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
return Err(format!(
"--frames" => o.frames = Some(value.parse()?),
"--world-frames" => {
o.world_frames = Some(value.parse()?);
// A frame-limited run in every other respect; --timeout-seconds still applies.
o.frames.get_or_insert(u64::MAX);
}
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
"--smoke-report" => o.report = Some(value.into()),
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
// Vanilla opens only a level id that is one folder of saves/. --world, for
// harnesses, also takes a path (tools/bundle_tooltip_e2e.py).
"--world" => o.world = Some(value),
"--quickPlaySingleplayer" => {
let mut parts = std::path::Path::new(&value).components();
if !matches!(
(parts.next(), parts.next()),
(Some(std::path::Component::Normal(_)), None)
) {
return Err("not a folder name of saves/".into());
}
o.quick_play = Some(value);
}
"--game-dir" | "--gameDir" => o.game_dir = Some(value.into()),
// main has made it the game home already (logging::set_game_home).
"--home" => {}
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
if horizon_client::benchmark::scenes(&value).is_none() {
return Err(format!(
"unknown benchmark {value}: standard, village, forest, ocean or mountain"
)
.into());
}
o.benchmark = Some(value);
}
o.benchmark = Some(value);
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
"--name" => o.player_name = Some(value),
"--session-file" => o.session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
"--time" => o.time = Some(value.parse()?),
"--weather" => o.weather = Some(value),
"--dimension" => {
o.dimension = Some(if value.contains(':') {
value
} else {
format!("minecraft:{value}")
})
}
"--look" => {
let (y, p) =
value.split_once(',').ok_or("--look takes YAW,PITCH")?;
o.look = Some((y.parse()?, p.parse()?));
}
_ => return Err("unknown option".into()),
}
"--server" | "--quickPlayMultiplayer" => o.server = Some(value),
"--name" => o.player_name = Some(value),
"--session-file" => session_file = Some(value),
"--control-socket" => o.control = Some(value.into()),
"--time" => o.time = Some(value.parse()?),
"--weather" => o.weather = Some(value),
"--dimension" => {
o.dimension = Some(if value.contains(':') {
value
} else {
format!("minecraft:{value}")
})
}
"--look" => {
let (y, p) = value.split_once(',').ok_or("--look takes YAW,PITCH")?;
o.look = Some((y.parse()?, p.parse()?));
}
_ => return Err(format!("unknown option {arg}").into()),
}
Ok(())
};
read().map_err(|e| format!("{arg}: {e}"))?;
}
}
}
// The launcher's account: online-mode servers need it. A bad file
// only leaves the game offline.
if let Some(path) = session_file.filter(|p| !p.is_empty()) {
match horizon_client::auth::AuthSession::load(path.as_ref()) {
Ok(session) => {
o.player_name = Some(session.name.clone());
horizon_client::auth::set_current(session);
}
Err(e) => eprintln!("Horizon: not signed in: {e}"),
}
}
Ok(Some(o))
}
}
@@ -685,6 +764,8 @@ struct GameLevel<'w> {
/// `Level.isClientSide`: a client's copy of the level (a guest of a LAN
/// world, a window on its server thread, a dedicated server's client).
client_side: bool,
/// Only the integrated or dedicated server dispatches native observations.
native_authoritative: bool,
}
struct PlayerCollision {
y: f64,
@@ -871,6 +952,22 @@ impl GameLevel<'_> {
}
}
impl LevelAccess for GameLevel<'_> {
fn observe_block_changed(&self, pos: BlockPos, old: BlockStateId, new: BlockStateId) {
if self.native_authoritative {
self.native_block_changed(self.dimension, pos, old, new);
}
}
fn observe_neighbor_changed(
&self,
pos: BlockPos,
state: BlockStateId,
changed: horizon_world::level::BlockId,
moved: bool,
) {
if self.native_authoritative {
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
}
}
fn is_client_side(&self) -> bool {
self.client_side
}
@@ -998,7 +1095,10 @@ impl LevelAccess for GameLevel<'_> {
self.world.block(pos.x, pos.y, pos.z)
}
fn set_raw(&mut self, pos: BlockPos, state: BlockStateId) -> Option<BlockStateId> {
if !self.world.loaded(pos.x, pos.z) || pos.y < MIN_Y || pos.y >= MIN_Y + HEIGHT {
if !self.world.loaded(pos.x, pos.z)
|| pos.y < self.world.bottom_y()
|| pos.y >= self.world.top_y()
{
return None;
}
let previous = self.world.block(pos.x, pos.y, pos.z);
@@ -1009,19 +1109,11 @@ impl LevelAccess for GameLevel<'_> {
self.world.loaded(pos.x, pos.z)
}
fn min_y(&self) -> i32 {
if self.dimension != LevelDimension::Overworld {
0
} else {
MIN_Y
}
self.world.shape.min_y
}
fn max_y(&self) -> i32 {
// Exclusive, as LevelAccess::max_y documents.
if self.dimension != LevelDimension::Overworld {
256
} else {
MIN_Y + HEIGHT
}
self.world.shape.max_y()
}
fn random(&mut self) -> &mut horizon_core::random::LegacyRandom {
&mut self.world.random
@@ -1161,6 +1253,13 @@ impl LevelAccess for GameLevel<'_> {
// player is this client's own: the block's `playSound(null, ...)`
// (a piston's move, a note) reaches it from the server instead.
LevelEvent::Sound { .. } if self.client_side => {}
// NoteBlock.triggerEvent plays the head's sound with a null
// player, so only the server's level plays it; a guest or a
// remote client hears it from the sound packet.
LevelEvent::BlockEvent {
kind: "note_custom_sound",
..
} if self.client_side => {}
// Block.popResource of the block's own item (tall flowers, flower beds).
LevelEvent::BlockEvent {
pos,
@@ -1522,6 +1621,9 @@ struct Game<'a> {
pending_entry: Option<PendingWorldEntry>,
world_loader: Option<world_loading::WorldLoader>,
pending_pack_reload: bool,
/// A pack reload staged on a worker (`LoadingOverlay`): the frame draws
/// its progress until `drive_pack_reload` applies it.
pack_reload: Option<packs::PackReload>,
/// A server's resource packs (game/server_packs.rs).
server_packs: server_packs::ServerPackState,
/// The link a chat click asks to open (`ConfirmLinkScreen`).
@@ -1663,6 +1765,9 @@ struct Game<'a> {
/// Controllers (Horizon's own).
pad: pad::Pads,
frames: u64,
/// The glint clock and options, read once at the start of each frame
/// (game/item_draw.rs).
glint_frame: horizon_client::item_render::GlintFrame,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
@@ -1794,6 +1899,9 @@ struct Game<'a> {
/// LocalPlayer (joining, respawning, changing dimension) starts at 0.
sway: horizon_client::first_person::HandSway,
visible_hands: [(usize, Option<ItemStack>); 2],
/// Whether the main and off hand's slots were set to a new stack that
/// the hand doesn't show yet (`Game::hand_stack_replaced`).
hands_replaced: [bool; 2],
/// `Minecraft`'s attack handling, attack clicks since the last tick and
/// the attack strength tickers the hand reads.
clicks: horizon_client::first_person::Clicks,
@@ -2170,25 +2278,15 @@ impl Dim {
}
/// `Level.getMinY`: the dimension type's minimum y.
fn min_y(self) -> i32 {
match self {
Self::Overworld => MIN_Y,
Self::Nether | Self::End => 0,
}
self.shape().min_y
}
/// `DimensionType.logicalHeight`.
fn logical_height(self) -> i32 {
match self {
Self::Overworld => HEIGHT,
Self::Nether => 128,
Self::End => 256,
}
self.shape().logical_height
}
/// `Level.getMaxY`: the dimension type's minimum y plus height, less one.
fn max_y(self) -> i32 {
match self {
Self::Overworld => MIN_Y + HEIGHT - 1,
Self::Nether | Self::End => 255,
}
self.shape().max_y() - 1
}
}
fn facing_offset(facing: &str) -> [i32; 3] {
@@ -2254,6 +2352,14 @@ impl Game<'_> {
/// crosshair target (blocks, fluids, entities, mod raycasts) is found
/// along this ray, so a tracked controller can stand in for it.
fn aim(&self) -> Option<([f64; 3], [f64; 3])> {
// Minecraft.pick: from the camera entity's eyes along its view
// (LivingEntity.getViewYRot: the head's yaw).
if let Some(m) = self.camera_mob() {
return Some((
[m.position.x, m.eye_y(), m.position.z],
view_vector(m.head_yaw, m.head_pitch),
));
}
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
}
fn reach(&self) -> f64 {
@@ -3193,6 +3299,7 @@ impl Game<'_> {
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -3221,6 +3328,73 @@ impl Game<'_> {
}
self.process_level_events()
}
/// `AbstractCandleBlock.extinguish`: the smoke from each wick, at
/// CandleBlock.PARTICLE_OFFSETS (sixteenths of a block), rising 0.1.
fn candle_smoke(&mut self, pos: [i32; 3], state: BlockStateId) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let candles = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("candles")?.parse::<usize>().ok())
.unwrap_or(1);
const OFFSETS: [&[[u8; 3]]; 4] = [
&[[8, 8, 8]],
&[[6, 7, 8], [10, 8, 7]],
&[[8, 5, 10], [6, 7, 8], [9, 8, 7]],
&[[7, 5, 9], [10, 7, 9], [6, 7, 6], [9, 8, 6]],
];
for o in OFFSETS[candles.clamp(1, 4) - 1] {
self.particles.add(
&ParticleOptions::simple(ParticleType::Smoke),
[
f64::from(pos[0]) + f64::from(o[0]) / 16.0,
f64::from(pos[1]) + f64::from(o[1]) / 16.0,
f64::from(pos[2]) + f64::from(o[2]) / 16.0,
],
[0.0, 0.1, 0.0],
);
}
}
/// `LeverBlock.makeParticle` at full scale: a powered lever puffs one red
/// dust on the clicking client (`LeverBlock.useWithoutItem`).
fn lever_puff(&mut self, pos: [i32; 3]) {
// The server's side of a guest's pull draws nothing.
if self.lan_acting.is_some() {
return;
}
let state = self.world.block(pos[0], pos[1], pos[2]);
let Some(registry_state) = self.pack.assets.registry.state(state) else {
return;
};
let prop = |key: &str| registry_state.properties.get(key).map(String::as_str);
if prop("powered") != Some("true") {
return;
}
// FACING is horizontal; getConnectedDirection is the face's direction.
let facing = match prop("facing") {
Some("north") => [0, 0, -1],
Some("south") => [0, 0, 1],
Some("west") => [-1, 0, 0],
Some("east") => [1, 0, 0],
_ => return,
};
let connected = match prop("face") {
Some("ceiling") => [0, -1, 0],
Some("floor") => [0, 1, 0],
_ => facing,
};
let at = |base: i32, i: usize| {
f64::from(base) + 0.5 + 0.1 * f64::from(-facing[i]) + 0.2 * f64::from(-connected[i])
};
let p = [at(pos[0], 0), at(pos[1], 1), at(pos[2], 2)];
self.particles.add(
&horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0),
p,
[0.0; 3],
);
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
fn sync_tick_chunks(&mut self) {
if !self.tick_chunks.synchronize(&self.world.chunks) {
@@ -3958,6 +4132,35 @@ impl Game<'_> {
[note / 24.0, 0.0, 0.0],
);
}
// NoteBlock.getCustomSoundId: the skull above's note block sound,
// played at volume 3 (vanilla's pitch for a head is 1).
LevelEvent::BlockEvent {
pos,
kind: "note_custom_sound",
..
} => {
if let Some(BlockEntity::Skull(skull)) =
self.world.block_entities.get(&(pos.x, pos.y + 1, pos.z))
&& let Some(sound) = skull.note_block_sound.as_deref()
{
let at = [
f64::from(pos.x) + 0.5,
f64::from(pos.y) + 0.5,
f64::from(pos.z) + 0.5,
];
self.audio.play(sound, Some(at), 3.0, 1.0);
}
}
// `level.addParticle`: only the local player's own click draws it.
LevelEvent::BlockEvent {
kind: "candle_smoke",
..
} if self.lan_acting.is_some() => {}
LevelEvent::BlockEvent {
pos,
state,
kind: "candle_smoke",
} => self.candle_smoke([pos.x, pos.y, pos.z], state),
LevelEvent::GrowTree { .. }
| LevelEvent::PlaceFeature { .. }
| LevelEvent::BlockEvent { .. } => {}
@@ -3983,10 +4186,6 @@ impl Game<'_> {
return;
}
if horizon_client::command_block::Mode::of_block(&name).is_some() {
// `ServerPlayer.openCommandBlock` sends the block entity's data
// (`ClientboundBlockEntityDataPacket`) so the opening client shows
// the stored command, not its stale copy.
self.mark_block_entity_updated(key);
self.open_command_block(key);
return;
}
@@ -4151,7 +4350,7 @@ impl Game<'_> {
// FallingBlockEntity.tick: off the ground, it gives up after 600
// ticks (100 outside the world) and drops itself as an item.
let y = next.y.floor() as i32;
let outside = y <= horizon_client::chunk::MIN_Y || y >= horizon_client::chunk::MIN_Y + 384;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
@@ -4444,6 +4643,16 @@ impl Game<'_> {
xp,
);
}
// TurtleEggBlock.playerDestroy: after the drops, one egg of a
// mined stack is taken and the rest stay.
self.with_level(|b, level, ticks| {
b.turtle_egg_destroyed_by_player(
level,
ticks,
BlockPos::new(pos[0], pos[1], pos[2]),
broken,
);
});
}
// IceBlock.playerDestroy: the ice melts back into water (the
// #prevents_ice_melting tool tag holds only silk touch).
@@ -4693,6 +4902,7 @@ impl Game<'_> {
match &*name {
"minecraft:note_block" => self.stats.custom("tune_noteblock", 1),
"minecraft:bell" => self.stats.custom("bell_ring", 1),
"minecraft:lever" => self.lever_puff(hit.pos),
_ => {}
}
}
@@ -5218,6 +5428,7 @@ impl Game<'_> {
ticks: 0,
});
}
let block_break_speed = self.block_break_speed();
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness < 0.0 {
@@ -5232,6 +5443,7 @@ impl Game<'_> {
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
let player = self.player.as_ref().ok_or("no player")?;
if !player.ground.on_ground {
speed /= 5.0;
@@ -5295,8 +5507,8 @@ impl Game<'_> {
}
return None;
}
let mut y = MIN_Y + HEIGHT - 1;
while y > MIN_Y {
let mut y = self.world.top_y() - 1;
while y > self.world.bottom_y() {
let s = self.world.block(x, y, z);
if !self.res.facts().is_air(s) {
let f = self.res.facts().get(s);
@@ -5469,6 +5681,9 @@ impl Game<'_> {
self.tick_debug_keys()?;
self.tick_debug_charts();
self.tick_tutorial();
if self.screen == Screen::Creative {
self.refresh_creative_tabs();
}
if self.remote.is_some() {
return self.tick_remote();
}
@@ -5484,6 +5699,9 @@ impl Game<'_> {
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_lifecycle_server_tick_start(running);
}
// GameRenderer.tick: a wither's bar darkens the world, then fades;
// the activated item counts down.
self.boss_overlay.tick_darken(running);
@@ -5791,7 +6009,7 @@ impl Game<'_> {
let Some(player) = self.player.as_mut() else {
return Ok(());
};
if player.position.y < f64::from(MIN_Y - 64) {
if player.position.y < f64::from(self.world.bottom_y() - 64) {
// Out of the world.
let _ = player.set_health(0.0, false);
}
@@ -6679,6 +6897,14 @@ impl Game<'_> {
}
batch
}
/// `CreativeModeInventoryScreen.hasPermissions` (`canUseGameMasterBlocks`
/// and the Operator Items Tab option) given to `tryRefreshInvalidatedTabs`,
/// as the screen's constructor and `containerTick` do.
fn refresh_creative_tabs(&mut self) {
let permissions = self.can_use_game_master_blocks() && self.settings.operator_items_tab;
self.creative_screen
.set_permissions(permissions, &self.pack.names);
}
fn creative_inventory_open(&self) -> bool {
self.screen == Screen::Creative && self.creative_screen.tab().inventory
}
@@ -8911,8 +9137,9 @@ impl Game<'_> {
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit.
if !m.alive() || Some(m.id) == self.riding {
// shares its root vehicle and is never the hit, nor is the
// camera entity the pick starts from.
if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -9731,6 +9958,7 @@ impl Game<'_> {
let (streaming, save) =
self.make_streaming(&self.world_root.clone(), target, self.level.seed)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.shape = target.shape();
world.fast_lava = target == Dim::Nether;
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
@@ -11161,7 +11389,7 @@ impl Game<'_> {
// renderArmWithItem: only the main hand's empty arm shows,
// on the main arm (the left one with the main hand set to
// left), and not while the player is invisible.
if hand == Hand::Off || self.potions.effects.get("invisibility").is_some() {
if hand == Hand::Off || self.own_invisible() {
continue;
}
// renderPlayerArm
@@ -11175,7 +11403,7 @@ impl Game<'_> {
let hold = MapHold::of(hand, off_hand_empty);
// renderOneHandedMap and renderTwoHandedMap: the arms under
// the map, unless the player is invisible.
if self.potions.effects.get("invisibility").is_none() {
if !self.own_invisible() {
for (arm, pose) in map_arm_poses(hold, left, self.pitch, inverse_height, attack)
{
self.first_person_arm(arm, to_world * pose, packed, &mut out);
@@ -11351,7 +11579,7 @@ impl Game<'_> {
/// `LevelExtractor.allChanged`: every loaded section is rebuilt.
fn all_sections_changed(&mut self) {
for &(x, z) in self.world.chunks.keys() {
for sy in 0..horizon_client::chunk::HEIGHT / 16 {
for sy in 0..self.world.layout().height() / 16 {
self.world.dirty_sections.insert((x, sy, z));
}
}
@@ -12224,6 +12452,7 @@ impl Game<'_> {
});
}
fn render(&mut self) -> Result<bool> {
self.glint_frame = self.capture_glint_frame();
if self.renderer.as_mut().is_some_and(|r| r.display_changed()) {
self.apply_ui_settings();
}
@@ -12278,9 +12507,9 @@ impl Game<'_> {
}
return Ok(false);
}
// A server pack's reload covers the frame with its progress
// (`LoadingOverlay`) until `tick_server_packs` applies it.
if let Some(reload) = &self.server_packs.reload {
// A pack reload covers the frame with its progress
// (`LoadingOverlay`) until `drive_pack_reload` applies it.
if let Some(reload) = &self.pack_reload {
if let Some(renderer) = &mut self.renderer {
let mut hud = HudBatch::new([renderer.config.width, renderer.config.height]);
horizon_client::level_loading::draw_progress(
@@ -12634,8 +12863,8 @@ impl Game<'_> {
debug_lines_on_top: &debug_lines_on_top,
debug_crosshair: &debug_crosshair,
hud: &hud,
glint_alpha: self.settings.file.double("glintStrength") as f32,
glint_ticks: self.glint_ticks(),
glint_alpha: self.glint_frame.alpha,
glint_ticks: self.glint_frame.scroll_ticks(),
clouds: (!title
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
@@ -12911,6 +13140,7 @@ impl Game<'_> {
}
if self.creative {
self.screen = Screen::Creative;
self.refresh_creative_tabs();
self.creative_screen.reopen();
self.refresh_saved_hotbars();
} else {
@@ -12946,6 +13176,8 @@ impl Game<'_> {
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
// the off hand, then the main hand.
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
self.hand_stack_replaced(self.selected);
self.hand_stack_replaced(shield::OFFHAND_SLOT);
let (offhand, main) = (self.offhand.clone(), self.inventory[self.selected].clone());
self.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
self.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
@@ -13313,8 +13545,8 @@ impl ApplicationHandler for Game<'_> {
if self.window.is_some() {
return;
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = Window::default_attributes()
@@ -13461,8 +13693,8 @@ impl ApplicationHandler for Game<'_> {
return;
}
// The pack reload's progress overlay takes no input
// (`LoadingOverlay`): `start_pack_reload` released what was held.
if self.server_packs.reload.is_some()
// (`LoadingOverlay`): `spawn_pack_reload` released what was held.
if self.pack_reload.is_some()
&& !matches!(
&event,
WindowEvent::CloseRequested
@@ -14501,6 +14733,7 @@ impl<'a> Game<'a> {
pending_entry: None,
world_loader: None,
pending_pack_reload: false,
pack_reload: None,
server_packs: Default::default(),
link_prompt: None,
code_of_conduct: None,
@@ -14596,6 +14829,7 @@ impl<'a> Game<'a> {
last_input: now,
pad: Default::default(),
frames: 0,
glint_frame: Default::default(),
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
@@ -14678,6 +14912,7 @@ impl<'a> Game<'a> {
heights: Default::default(),
sway: Default::default(),
visible_hands: [(0, None), (0, None)],
hands_replaced: [false; 2],
clicks: Default::default(),
attack_clicks: 0,
hand_strength: Default::default(),
@@ -15983,8 +16218,9 @@ impl Loading {
if self.window.is_some() {
return;
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
let fullscreen =
self.settings.fullscreen || self.options.as_ref().is_some_and(|o| o.fullscreen);
let size = self
.options
.as_ref()
@@ -16353,6 +16589,8 @@ fn run_loading(
Ok(())
}
pub use launch_api::{UsageError, exit_code};
pub fn run() -> Result<()> {
let Some(mut options) = Options::read()? else {
return Ok(());
@@ -16752,7 +16990,10 @@ mod tests {
fn selecting_creative_tabs_does_not_drop_the_carried_stack_on_release() {
let mut creative = Creative::from_json(&serde_json::json!({
"schema": 2,
"tabs": [{"type": "CATEGORY"}, {"type": "INVENTORY"}]
"tabs": [
{"type": "CATEGORY", "items": ["{count:1,id:\"minecraft:stone\"}"]},
{"type": "INVENTORY"}
]
}))
.unwrap();
for button in [Button::Left, Button::Right] {
+15 -3
View File
@@ -7,7 +7,6 @@ use super::{Game, Screen};
use horizon_client::{
Result,
block_entity_anim::Ticker,
chunk::{MIN_Y, SECTIONS},
items::ItemStack,
mobs::{
Species,
@@ -549,7 +548,7 @@ impl Game<'_> {
selected,
} => json!({
"mode": "select-world",
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1 })).collect::<Vec<_>>(),
"worlds": list.iter().map(|w| json!({ "folder": w.folder, "name": w.name, "creative": w.game_mode == 1, "corrupted": w.corrupted })).collect::<Vec<_>>(),
"selected": selected,
}),
Mode::Create {
@@ -603,6 +602,17 @@ impl Game<'_> {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::Recover { world, issues } => {
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
None => json!(null),
Some(horizon_client::save::DatIssue::Missing) => json!("missing file"),
Some(horizon_client::save::DatIssue::Unreadable(why)) => json!(why),
};
json!({ "mode": "recover-world", "folder": world.folder, "level_dat": issue(&issues[0]), "level_dat_old": issue(&issues[1]) })
}
Mode::Recovered { world, success } => {
json!({ "mode": "recovery-done", "folder": world.folder, "success": success })
}
Mode::Error { message, .. } => {
json!({ "mode": "world-operation-error", "message": message })
}
@@ -1088,7 +1098,9 @@ impl Game<'_> {
.world
.chunks
.get(&(x >> 4, z >> 4))
.filter(|_| (MIN_Y..MIN_Y + SECTIONS as i32 * 16).contains(&y))
.filter(|_| {
(self.world.bottom_y()..self.world.top_y()).contains(&y)
})
.map(|c| c.light(x & 15, y, z & 15));
let digit = |v: u8| char::from_digit(u32::from(v), 16).unwrap_or('.');
sky.push(packed.map_or('.', |v| digit(v >> 4)));
+35 -11
View File
@@ -635,17 +635,23 @@ impl Game<'_> {
}
}
for i in dead.into_iter().rev() {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = burnt_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
self.break_armor_stand_by_anything(i);
}
}
/// `ArmorStand.brokenByAnything`, then `kill`, as burning out or a blast
/// breaks a stand: its equipment pops above its block, the stand item does
/// not drop.
pub(super) fn break_armor_stand_by_anything(&mut self, i: usize) {
let a = self.armor_stands.remove(i);
let at = [a.position.x, a.position.y, a.position.z];
let (pos, drops) = broken_armor_stand_equipment(a);
for stack in drops {
self.pop_resource(pos, stack);
}
self.audio
.play("minecraft:entity.armor_stand.break", Some(at), 1.0, 1.0);
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// ArmorStandRenderer: the stand with its default pose, then armor layers.
pub(super) fn armor_stand_geometry(
&self,
@@ -1055,7 +1061,15 @@ pub(super) fn skull_kind(item: &str) -> Option<&'static str> {
})
}
fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
/// `ArmorStand.ignoreExplosion` (an invisible stand), and `hurtServer`
/// refusing an invisible or marker stand: such a stand outlasts any blast.
pub(super) fn spared_by_blast(a: &ArmorStand) -> bool {
a.invisible || a.marker
}
/// `ArmorStand.brokenByAnything`: the equipment, less what curse of vanishing
/// keeps (`PREVENT_EQUIPMENT_DROP`), popped at `blockPosition().above()`.
fn broken_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
let pos = [
a.position.x.floor() as i32,
a.position.y.floor() as i32 + 1,
@@ -1073,6 +1087,16 @@ fn burnt_armor_stand_equipment(a: ArmorStand) -> ([i32; 3], Vec<ItemStack>) {
mod burn_drop_tests {
use super::*;
#[test]
fn only_visible_non_marker_stands_are_broken_by_blasts() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 64.0, 0.5), 0.0);
assert!(!spared_by_blast(&stand));
stand.invisible = true;
assert!(spared_by_blast(&stand));
stand.invisible = false;
stand.marker = true;
assert!(spared_by_blast(&stand));
}
#[test]
fn burnt_equipment_drops_above_the_block_and_vanishing_items_do_not_drop() {
let mut stand = ArmorStand::new(1, Vec3::new(-0.1, 4.8, 0.0), 0.0);
let kept = ItemStack::new("minecraft:diamond_boots", 1);
@@ -1083,7 +1107,7 @@ mod burn_drop_tests {
.push(("minecraft:vanishing_curse".into(), 1));
stand.equipment[0] = Some(kept.clone());
stand.equipment[1] = Some(vanished);
let (pos, drops) = burnt_armor_stand_equipment(stand);
let (pos, drops) = broken_armor_stand_equipment(stand);
assert_eq!(pos, [-1, 5, 0]);
assert_eq!(drops, vec![kept]);
}
+2 -2
View File
@@ -177,7 +177,7 @@ impl Game<'_> {
}
}
if b.last_check_y >= surface {
b.last_check_y = super::MIN_Y - 1;
b.last_check_y = self.world.bottom_y() - 1;
b.sections = std::mem::take(&mut b.checking);
let (was, is) = (previous > 0, b.levels > 0);
if !was && is {
@@ -231,7 +231,7 @@ impl Game<'_> {
let mut levels = 0;
for step in 1..=4 {
let ly = y - step;
if ly < super::MIN_Y {
if ly < self.world.bottom_y() {
break;
}
let ok = (x - step..=x + step).all(|lx| {
@@ -461,7 +461,7 @@ impl Game<'_> {
if is_conduit(section.get(i)) {
found.push([
cx * 16 + (i & 15) as i32,
horizon_client::chunk::MIN_Y + si as i32 * 16 + (i >> 8) as i32,
self.world.bottom_y() + si as i32 * 16 + (i >> 8) as i32,
cz * 16 + ((i >> 4) & 15) as i32,
]);
}
+52 -4
View File
@@ -8,7 +8,8 @@ use horizon_client::{
items::ItemStack, save,
};
use horizon_world::{
nbt::{Compound, Tag},
level::classes::Class,
nbt::{Compound, JavaString, Tag},
registry::BlockStateId,
};
@@ -45,7 +46,17 @@ impl Game<'_> {
if &*facts.item == "minecraft:air" {
return None;
}
let mut stack = ItemStack::new(&facts.item, 1);
// FlowerPotBlock.getCloneItemStack: a potted plant picks as that plant
// (`new ItemStack(potted)`), an empty pot as the pot itself.
let behaviours = self.res.behaviours();
let plant = behaviours.data(state);
let plant_item = plant
.is(Class::FlowerPot)
.then_some(plant.link)
.flatten()
.map(|block| behaviours.block_data(block).item.clone())
.filter(|item| &**item != "minecraft:air");
let mut stack = ItemStack::new(plant_item.as_ref().unwrap_or(&facts.item), 1);
let mut patch = Compound::new();
// BeehiveBlock.getCloneItemStack: the honey level as the item's block state.
if include_data
@@ -62,16 +73,53 @@ impl Game<'_> {
Tag::compound([("honey_level", Tag::String(level.as_str().into()))]),
);
}
// CopperGolemStatueBlock.getCloneItemStack: the statue's pose as the item's
// block_state (`CopperGolemStatueBlockEntity.getItem`), data or not.
if let Some(pose) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.ends_with("copper_golem_statue"))
.and_then(|s| s.properties.get("copper_golem_pose"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("copper_golem_pose", Tag::String(pose.as_str().into()))]),
);
}
// LightBlock.getCloneItemStack, setLightOnStack: the light's level as
// the item's block state, whether or not the block data is picked.
if let Some(level) = self
.pack
.assets
.registry
.state(state)
.filter(|s| s.name.as_str() == "minecraft:light")
.and_then(|s| s.properties.get("level"))
{
patch.insert(
"minecraft:block_state".into(),
Tag::compound([("level", Tag::String(level.as_str().into()))]),
);
}
// AbstractBannerBlock.getCloneItemStack: the banner's own item
// (BannerBlockEntity.getItem), data or not.
let banner = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Banner(_))
);
if (include_data || banner)
// DecoratedPotBlock.getCloneItemStack: createDecoratedPotInstance, the
// sherds only; the pot's item comes with include_data.
let pot = &*facts.name == "minecraft:decorated_pot";
if (include_data || banner || pot)
&& let Some(saved) = self.saved_block_entity(key)
{
patch.extend(block_entity_item::pick(&saved));
let mut picked = block_entity_item::pick(&saved);
if pot && !include_data {
picked.retain(|k, _| *k == JavaString::from("minecraft:pot_decorations"));
}
patch.extend(picked);
}
if !patch.is_empty() {
let mut all = components(&stack);
+2 -1
View File
@@ -474,6 +474,7 @@ impl Game<'_> {
if let Some(names) = self.remote_online_names() {
self.ui.registries.players = names;
}
self.ui.registries.custom_completions = self.remote_custom_completions();
}
/// `ChatComponent.createScreen`: T or / opens the chat with the draft kept
/// last time, when this method can restore it (a command draft only for
@@ -1204,7 +1205,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return;
};
let below_world = p.position.y < f64::from(horizon_client::chunk::MIN_Y - 64);
let below_world = p.position.y < f64::from(self.world.bottom_y() - 64);
let name = self.player_display_text();
let message = match cause {
Some(key) => Text::translate(&key, &[name]),
@@ -144,7 +144,7 @@ impl Game<'_> {
.filter_map(|e| effect_particle(e.id, e.ambient))
.collect();
let ambient = effects.iter().filter(|e| e.visible).all(|e| e.ambient);
let invisible = effects.iter().any(|e| e.id == "invisibility");
let invisible = self.own_invisible();
let size = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
@@ -30,7 +30,7 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pub(super) pos: Pos,
pos: Pos,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
@@ -112,6 +112,7 @@ impl CommandBlockScreen {
self.previous.set_value(&shown);
}
}
fn state_of(registry: &BlockRegistry, state: BlockStateId) -> Option<&BlockState> {
registry.state(state)
}
@@ -240,21 +241,21 @@ impl Game<'_> {
let at = BlockPos::new(pos.0, pos.1, pos.2);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
}
/// `CommandBlockEditScreen.updateGui`: the server's block-entity data
/// arrived while the screen is open; refresh the fields from the entity.
/// The mode and conditional live in the block state here (vanilla's
/// `CommandBlockEntity.Mode`).
pub(super) fn update_command_block_screen(&mut self) {
let Some(screen_pos) = self.command_block_screen.as_ref().map(|s| s.pos) else {
/// `ClientPacketListener.handleBlockEntityData` then
/// `CommandBlockEditScreen.updateGui`: new data for the command block the
/// edit screen shows refreshes its fields. The mode and conditional live in
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
return;
};
let state = self.block_state_at(screen_pos).map(|s| {
}
let state = self.block_state_at(pos).map(|s| {
(
Mode::of_block(&s.name).unwrap_or(Mode::Redstone),
conditional(s),
)
});
let block = self.command_block(screen_pos).cloned();
let block = self.command_block(pos).cloned();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: super::chat::chat_tree(&self.remote),
@@ -825,18 +826,6 @@ impl Game<'_> {
}),
);
}
/// `ClientPacketListener.handleBlockEntityData`: new data for the command
/// block the edit screen shows refills it (`CommandBlockEditScreen.updateGui`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
let open = self.screen == Screen::CommandBlock
&& self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == pos);
if open {
self.open_command_block(pos);
}
}
/// What `handleSetCommandBlock` does once the player may edit the block.
fn apply_command_block(&mut self, s: &CommandBlockEdit) {
let pos = s.pos;
+38 -7
View File
@@ -301,9 +301,7 @@ impl Game<'_> {
if dimension != self.dim || !self.world.chunks.contains_key(&(p[0] >> 4, p[2] >> 4)) {
return Err(err("argument.pos.unloaded", &[]));
}
if p[1] < horizon_client::chunk::MIN_Y
|| p[1] >= horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT
{
if p[1] < self.world.bottom_y() || p[1] >= self.world.top_y() {
return Err(err("argument.pos.outofworld", &[]));
}
Ok(p)
@@ -689,7 +687,14 @@ impl Game<'_> {
.iter()
// The kinds /data can write (entity_nbt).
.filter(|p| {
p.is_arrow() || matches!(p.kind, K::LargeFireball { .. } | K::SmallFireball)
p.is_arrow()
|| matches!(
p.kind,
K::LargeFireball { .. }
| K::SmallFireball
| K::WindCharge
| K::BreezeWindCharge
)
})
.map(|p| Target::Projectile(p.id)),
);
@@ -719,6 +724,27 @@ impl Game<'_> {
});
dims
}
/// `Level.getEntity`: the entity of the level in place with a network
/// id, players aside.
pub(in crate::game) fn level_entity(&self, id: i32) -> Option<Target> {
let id = u32::try_from(id).ok()?;
[
Target::Mob(id),
Target::Item(id),
Target::Orb(id),
Target::Projectile(id),
Target::Stand(id),
Target::Frame(id),
Target::Painting(id),
Target::Boat(id),
Target::Cart(id),
Target::Other(id),
Target::Crystal(id),
Target::Dragon(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
}
/// Whether the level in place holds the entity a target names.
fn level_holds(&self, t: Target) -> bool {
match t {
@@ -1034,6 +1060,11 @@ impl Game<'_> {
let custom = s.data().and_then(|data| data.custom_name.as_ref());
let hover = if let Some(c) = custom {
horizon_client::component::name_text(c)
} else if let Some(name) = s.data().and_then(|d| d.other("minecraft:item_name")) {
// `ItemStack.getItemName`: the `item_name` component wins over the
// item's own name (`Item.getName`), so a datapack's or a server's
// renaming shows here as it does on the tooltip's first line.
horizon_client::component::name_text(name)
} else if let Some(title) = s
.data()
.and_then(|d| d.written.as_ref())
@@ -2966,7 +2997,7 @@ impl Game<'_> {
if !section.blocks.maybe_has(&mut wanted_state) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
let base_y = self.world.bottom_y() + 16 * i as i32;
for j in 0..4096 {
let state = section.get(j);
let Some(kind) = cache.get(&state).copied().flatten() else {
@@ -3028,8 +3059,8 @@ impl Game<'_> {
radius: 6400,
horizontal: 32,
vertical: 64,
min_y: horizon_client::chunk::MIN_Y,
max_y: horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1,
min_y: self.world.bottom_y(),
max_y: self.world.top_y() - 1,
};
let found = self
.end
@@ -318,6 +318,9 @@ impl Game<'_> {
self.take_loot_table(key, &tag, &mut new);
self.world.block_entities.insert(key, new);
self.update_spawner_display(key);
// BlockDataAccessor.setData: setChanged and sendBlockUpdated(3),
// even when the block state itself stays the same.
self.mark_block_entity_updated(key);
let state = self.world.block(p[0], p[1], p[2]);
// Redraw what shows the block entity (signs, banners).
let _ = self.world.set(p[0], p[1], p[2], state);
@@ -785,8 +785,8 @@ impl Game<'_> {
}
/// The highest block of a heightmap type at a column (`Level.getHeight`).
fn heightmap_at(&self, kind: &str, x: i32, z: i32) -> i32 {
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
for y in (horizon_client::chunk::MIN_Y..top).rev() {
let top = self.world.top_y();
for y in (self.world.bottom_y()..top).rev() {
let state = self.world.block(x, y, z);
if state == self.world.air {
continue;
@@ -809,7 +809,7 @@ impl Game<'_> {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
/// A `store` modifier: a new callback in the source's chain.
fn store_source(&mut self, c: &Context, s: Source) -> R<Source> {
@@ -1,7 +1,6 @@
//! `/forceload`, `/fillbiome`, `/worldborder` and `/loot`.
use super::{Arg, Context, Game, R, Target, err, items::Owner};
use horizon_client::{
chunk::{HEIGHT, MIN_Y},
command::CommandError,
items::ItemStack,
scoreboard_cmd::{Holder, Tracker},
@@ -213,7 +212,8 @@ impl Game<'_> {
if usize::from(current) == target {
continue;
}
let qy = ((y - MIN_Y) >> 2).clamp(0, HEIGHT / 4 - 1);
let qy = ((y - self.world.bottom_y()) >> 2)
.clamp(0, self.world.layout().height() / 4 - 1);
if let Some(chunk) = self.world.chunks.get_mut(&key) {
let chunk = std::sync::Arc::make_mut(chunk);
chunk.set_biome_cell((x & 15) >> 2, qy, (z & 15) >> 2, target as u16);
@@ -228,7 +228,7 @@ impl Game<'_> {
}
for key in &touched {
self.world.modified.insert(*key);
for sy in 0..horizon_client::chunk::SECTIONS as i32 {
for sy in 0..self.world.layout().sections() as i32 {
self.world.dirty_sections.insert((key.0, sy, key.1));
}
}
@@ -202,10 +202,15 @@ impl Game<'_> {
attacker,
from,
};
self.mobs
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, damage))
.is_some_and(|m| m.damage(amount, damage));
if hurt {
self.resolve_player_responsible(id, damage);
}
hurt
}
_ => false,
};
@@ -1278,12 +1283,12 @@ impl Game<'_> {
let respect = matches!(ctx.get("respectTeams"), Some(Arg::Bool(true)));
let max_y = match ctx.get("maxHeight") {
Some(Arg::Int(h)) => *h,
_ => horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT,
_ => self.world.top_y(),
};
if max_y < horizon_client::chunk::MIN_Y {
if max_y < self.world.bottom_y() {
return Err(err(
"commands.spreadplayers.failed.invalid.height",
&[&max_y, &horizon_client::chunk::MIN_Y],
&[&max_y, &self.world.bottom_y()],
));
}
let targets = self.entities(ctx, "targets", false)?;
@@ -1484,7 +1489,7 @@ impl Game<'_> {
let mut air2 = air(y);
y -= 1;
let mut air1 = air(y);
while y > horizon_client::chunk::MIN_Y {
while y > self.world.bottom_y() {
y -= 1;
let current = air(y);
if !current && air1 && air2 {
+1 -1
View File
@@ -342,7 +342,7 @@ impl Game<'_> {
let (lx, lz, ly) = ((i & 15) as i32, ((i >> 4) & 15) as i32, (i >> 8) as i32);
let pos = (
key.0 * 16 + lx,
horizon_client::chunk::MIN_Y + si as i32 * 16 + ly,
self.world.bottom_y() + si as i32 * 16 + ly,
key.1 * 16 + lz,
);
self.world
+401 -23
View File
@@ -107,12 +107,10 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
hit.direction.normal(),
))
}
fn in_water(&self, at: Vec3) -> bool {
self.w.fluid(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
) == Fluid::Water
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
@@ -130,21 +128,35 @@ impl ProjectileWorld for ProjWorld<'_, '_> {
/// `AbstractWindCharge` is pickable, so a thrown pearl's
/// `ProjectileUtil.getEntityHitResult` can strike it (`ThrownEnderpearl.onHit`).
const WIND_CHARGE_HIT: u32 = super::dragon::SPECIAL - 1;
/// `WIND_CHARGE_IDS + j` is `projectiles[j]` during `tick_projectiles`, below
/// the dragon's reserved ids.
const WIND_CHARGE_IDS: u32 = super::dragon::SPECIAL - 0x10000;
fn wind_charge_hitboxes(projectiles: &[Projectile], skip: usize) -> Vec<(Who, Aabb)> {
/// The index of the wind charge a `wind_charge_hitboxes` id stands for.
fn wind_charge_hit(id: u32) -> Option<usize> {
(WIND_CHARGE_IDS..super::dragon::SPECIAL)
.contains(&id)
.then(|| (id - WIND_CHARGE_IDS) as usize)
}
fn wind_charge_hitboxes(
projectiles: &[Projectile],
alive: &[bool],
skip: usize,
) -> Vec<(Who, Aabb)> {
projectiles
.iter()
.enumerate()
.filter(|(j, q)| {
*j != skip
&& alive[*j]
&& !q.stuck
&& matches!(
q.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
)
})
.map(|(_, q)| (Who::Mob(WIND_CHARGE_HIT), q.aabb()))
.map(|(j, q)| (Who::Mob(WIND_CHARGE_IDS + j as u32), q.aabb()))
.collect()
}
@@ -354,6 +366,41 @@ impl Game<'_> {
}
Some(id)
}
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
/// removed: a new mob of `kind`, made without spawn finalization, takes
/// its place with what `conversion::single` carries over, and the old
/// mob's riders move onto it. A zombie that comes out of it rolls
/// `Zombie.handleAttributes(special, CONVERSION)` (the
/// `postConversionStep` of its `ConversionTracker`).
pub(super) fn convert_to(
&mut self,
from: &Mob,
kind: &str,
params: horizon_client::mobs::conversion::Params,
) -> Option<u32> {
let t = self.res.mob_types().get(kind).cloned()?;
self.next_entity += 1;
let mut mob = Mob::new(self.next_entity, t, from.position, from.yaw);
horizon_client::mobs::conversion::single(from, &mut mob, params);
if matches!(from.species(), Species::Zombie | Species::Husk) {
let at = [from.position.x, from.position.y, from.position.z];
let special = self.local_difficulty(at).special();
horizon_client::mobs::finalize::zombie_attributes(
&mut mob,
special,
horizon_client::mobs::finalize::Reason::Conversion,
);
}
let id = mob.id;
// ConversionType.SINGLE: the passengers get off and board the new mob.
for rider in self.mobs.iter_mut() {
if rider.extra.mount == Some(from.id) {
rider.extra.mount = Some(id);
}
}
self.mobs.push(mob);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
pub(super) fn summon(&mut self, kind: &str, at: Option<[f64; 3]>, baby: bool) -> Result<u32> {
let at = match at {
@@ -454,13 +501,13 @@ impl Game<'_> {
/// Highest motion-blocking (no leaves) block + 1 (Heightmap MOTION_BLOCKING_NO_LEAVES).
pub(super) fn motion_top(&self, x: i32, z: i32) -> i32 {
let top = if self.dim == Dim::Nether { 127 } else { 319 };
for y in (horizon_client::chunk::MIN_Y..=top).rev() {
for y in (self.world.bottom_y()..=top).rev() {
let f = self.res.facts().get(self.world.block(x, y, z));
if (f.full_collision || f.fluid != Fluid::None) && !f.name.ends_with("leaves") {
return y + 1;
}
}
horizon_client::chunk::MIN_Y
self.world.bottom_y()
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
@@ -883,8 +930,12 @@ impl Game<'_> {
}
}
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player, killer.flatten());
// Raider.die's hero and Villager.tellWitnessesThatIWasMurdered go to
// the causing entity, not the credited player: a tame wolf's kill is
// neither its owner's heroism nor a murder by them.
let slain_by_player = by_player && !killed_by_mob(m);
self.raider_died(m, slain_by_player);
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
self.drop_death_loot(&mut dying, by_player, consumed, pos);
self.create_wither_rose(m);
@@ -919,7 +970,12 @@ impl Game<'_> {
/// The killer's side of a mob's death: `ServerPlayer.awardKillScore`'s
/// statistics and scores, and the `player_killed_entity` trigger.
fn credit_kill(&mut self, m: &Mob) {
self.stats.add("killed", &m.kind.id, 1);
// Player.killedEntity: the killed statistic goes to the causing
// entity, so a kill by a mob (a tame wolf credited to its owner)
// leaves it out; ServerPlayer.awardKillScore counts the rest.
if !killed_by_mob(m) {
self.stats.add("killed", &m.kind.id, 1);
}
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(m.id));
let view = self.mob_entity(m);
@@ -1056,6 +1112,20 @@ impl Game<'_> {
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
let stacks = horizon_client::mobs::enderman::carried_block_drops(
world_items!(self),
self.pack.assets.registry,
state,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
for item in stacks {
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
}
@@ -1088,6 +1158,9 @@ impl Game<'_> {
}
}
}
if hurt {
self.resolve_player_responsible(target, source);
}
if infested {
self.mob_infested(target);
}
@@ -1110,6 +1183,49 @@ impl Game<'_> {
}
hurt
}
/// `LivingEntity.resolvePlayerResponsibleForDamage` after `source` hurt a
/// mob: a tame wolf's hit is its owner's (`setLastHurtByPlayer(uuid, 100)`),
/// so the kill drops experience and player-only loot and the owner, when
/// in the world, gets the credit (`getKillCredit`); a tame wolf without an
/// owner clears it. A player's own hits are recorded where they land.
pub(super) fn resolve_player_responsible(&mut self, victim: u32, source: Damage) {
let Some(Who::Mob(attacker)) = source.attacker else {
return;
};
let Some(owner) = self
.mobs
.iter()
.find(|m| m.id == attacker && m.species() == Species::Wolf && m.extra.tame)
.map(|m| m.extra.owner)
else {
return;
};
let player = owner.and_then(|uuid| self.player_with_uuid(uuid));
let Some(m) = self.mobs.iter_mut().find(|m| m.id == victim) else {
return;
};
m.ai.last_hurt_by_player = if owner.is_some() { 100 } else { 0 };
match player {
Some(who) => self.last_hurt_by_player.insert(victim, who),
None => self.last_hurt_by_player.remove(&victim),
};
}
/// The player in the world with `uuid` (`EntityReference.getPlayer`), as
/// `last_hurt_by_player` keeps them: None for the player in place, else the
/// guest's connection.
fn player_with_uuid(&self, uuid: [i32; 4]) -> Option<Option<u32>> {
if self.player.is_some()
&& horizon_client::mobs::tame::owned_by(Some(uuid), self.acting_uuid())
{
return Some(self.lan_acting);
}
let uuid = horizon_client::lan::playerdata::ints_uuid(uuid);
self.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.uuid == uuid)
.map(|g| Some(g.conn))
}
/// `InfestedMobEffect.onMobHurt` for a mob: silverfish from half its height up.
pub(super) fn mob_infested(&mut self, mob: u32) {
if let Some(m) = self.mobs.iter().find(|m| m.id == mob) {
@@ -1361,6 +1477,31 @@ impl Game<'_> {
} => {
self.mob_hits_mob(target, amount, source);
}
MobEvent::CubeHurtMob {
cube,
target,
amount,
source,
} => {
// AbstractCubeMob.dealDamage: the attack sound (and its
// two pitch draws, made even for a silent cube, whose
// Entity.playSound is then skipped) only follows a hit
// that landed.
if self.mob_hits_mob(target, amount, source)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == cube)
{
let pitch = (m.random.next_float() - m.random.next_float()) * 0.2 + 1.0;
let at = m.center();
if !m.silent {
self.handle_mob_events(vec![MobEvent::Sound(
"entity.slime.attack",
at,
1.0,
pitch,
)])?;
}
}
}
MobEvent::SonicBoom {
target,
attacker,
@@ -1503,6 +1644,9 @@ impl Game<'_> {
self.apply_setup(id, &setup);
}
}
MobEvent::Convert { from, kind, params } => {
self.convert_to(&from, kind, params);
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
@@ -2113,6 +2257,8 @@ impl Game<'_> {
&& let Some(child) = self.mobs.iter_mut().find(|m| m.id == id)
{
child.extra.age = -24000;
// No Mob.finalizeSpawn either: no follow range bonus, right-handed.
spawning::bred_offspring(child);
// getBreedOffspring creates the baby without finalizeSpawn: it keeps
// the default (`classic`) sound variant.
child.extra.sound_variant = None;
@@ -2645,7 +2791,7 @@ impl Game<'_> {
let mut keep_flags = vec![true; projectiles.len()];
for i in 0..projectiles.len() {
let winds = if matches!(projectiles[i].kind, ProjectileKind::EnderPearl) {
wind_charge_hitboxes(&projectiles, i)
wind_charge_hitboxes(&projectiles, &keep_flags, i)
} else {
Vec::new()
};
@@ -2754,6 +2900,16 @@ impl Game<'_> {
hits.push((p.clone(), hit));
}
keep_flags[i] = keep;
// Projectile.onHit: the wind charge it hit turns before its own tick.
if let Hit::Entity {
who: Who::Mob(id), ..
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
}
}
let mut idx = 0;
projectiles.retain(|_| {
@@ -2863,11 +3019,12 @@ impl Game<'_> {
}
}
}
/// `onProjectileHit` of chorus flowers and decorated pots: an impact
/// projectile (`#impact_projectiles`) breaks the block when
/// `Projectile.mayInteract` and `Projectile.mayBreak` allow it, which takes
/// the `projectiles_can_break_blocks` rule and a shooter that may break
/// blocks (`canBreakBlockInAdventureMode`: never a mob, never an
/// `onProjectileHit` of chorus flowers, decorated pots and speleothems
/// (pointed dripstone, sulfur spikes): an impact projectile
/// (`#impact_projectiles`) breaks the block when `Projectile.mayInteract`
/// and `Projectile.mayBreak` allow it, which takes the
/// `projectiles_can_break_blocks` rule and a shooter that may break blocks
/// (`canBreakBlockInAdventureMode`: never a mob, never an
/// adventure player). A pot is cracked first (flags 260), so it shatters.
fn projectile_breaks_block(&mut self, p: &Projectile, pos: [i32; 3]) {
use horizon_client::command::args::GameMode;
@@ -2895,6 +3052,11 @@ impl Game<'_> {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
match &*self.res.facts().get(state).name {
"minecraft:chorus_flower" => {}
// SpeleothemBlock.onProjectileHit (pointed dripstone and sulfur spikes): a
// thrown trident faster than 0.6 breaks it.
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
if matches!(p.kind, ProjectileKind::Trident)
&& p.arrow.as_ref().is_some_and(|a| a.hit_speed > 0.6) => {}
"minecraft:decorated_pot" => {
let registry = &self.pack.assets.registry;
let Some(mut pot) = registry.state(state).cloned() else {
@@ -4801,6 +4963,11 @@ impl Game<'_> {
if mob.invisible && outline.is_none() {
continue;
}
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
@@ -5123,12 +5290,12 @@ impl Game<'_> {
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let s = 0.25 * a.sprite_scale();
for (k, uv) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
let v = c + right * k.0 * s + up * k.1 * s;
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
@@ -5158,6 +5325,36 @@ fn thrown_item_axes(
let orientation = super::camera_rotation(camera_type, yaw, pitch);
(orientation * glam::Vec3::X, orientation * glam::Vec3::Y)
}
/// The four corners of the item billboard, in the order its UVs are laid out.
/// `ThrownItemRenderer` only scales and billboards it; the lift is
/// `item/generated`'s ground display transform, `translation [0, 2, 0]` of 16
/// units = 0.125 blocks, which is `0.125 * scale` == `0.5 * s`.
fn thrown_item_quad(
at: glam::Vec3,
right: glam::Vec3,
up: glam::Vec3,
scale: f32,
lift: bool,
) -> [glam::Vec3; 4] {
let s = 0.25 * scale;
let rise = if lift { 0.5 * s } else { 0.0 };
let centre = at + up * rise;
[(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)]
.map(|k| centre + right * k.0 * s + up * k.1 * s)
}
/// Whether vanilla draws the kind as `ThrownItemRenderer`'s item billboard, the
/// only ones that take that lift. Llama spit shares this quad but
/// `LlamaSpitRenderer` is a model, with its own offset.
fn is_item_billboard(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::EnderPearl
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
)
}
/// `Bucketable.getBucketItemStack` and `getPickupSound` of a mob that a water
/// bucket picks up.
fn bucketable(species: Species) -> Option<(&'static str, &'static str)> {
@@ -5319,6 +5516,55 @@ impl Game<'_> {
mod tests {
use super::super::perspective::CameraType;
use super::thrown_item_axes;
use super::thrown_item_quad;
use super::{ProjectileKind, is_item_billboard};
/// The component-wise lowest and highest corner of the quad.
fn bounds(corners: &[glam::Vec3; 4]) -> (glam::Vec3, glam::Vec3) {
(
corners.iter().copied().reduce(glam::Vec3::min).unwrap(),
corners.iter().copied().reduce(glam::Vec3::max).unwrap(),
)
}
#[test]
fn thrown_item_billboard_lifts_by_the_generated_ground_transform() {
// The kinds vanilla draws with `ThrownItemRenderer`: the only lifted ones.
assert!(is_item_billboard(&ProjectileKind::Snowball));
assert!(is_item_billboard(&ProjectileKind::Egg));
assert!(is_item_billboard(&ProjectileKind::EnderPearl));
assert!(is_item_billboard(&ProjectileKind::SmallFireball));
let large = ProjectileKind::LargeFireball { power: 1 };
assert!(is_item_billboard(&large));
// A projectile at y = 100 with right = +X, up = +Y: the sprite sits
// 0.125 blocks above the entity (2 of `item/generated`'s 16 units) and
// stays 0.5 blocks wide, so the quad's y runs 99.875 .. 100.375.
let at = glam::Vec3::Y * 100.0;
let (right, up) = (glam::Vec3::X, glam::Vec3::Y);
let lifted = is_item_billboard(&ProjectileKind::Snowball);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.875, 100.375));
assert_eq!((lo.x, hi.x), (-0.25, 0.25));
// A small fireball's 0.75 scale: the same quad scaled down, lifted by
// 0.09375, half extent 0.1875.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 0.75, lifted));
assert_eq!((lo.y, hi.y), (99.90625, 100.28125));
// A large fireball's 3.0: lift 0.375, half extent 0.75.
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 3.0, lifted));
assert_eq!((lo.y, hi.y), (99.625, 101.125));
// Llama spit shares the quad here but is a model in vanilla
// (`LlamaSpitRenderer`): no lift at all.
let lifted = is_item_billboard(&ProjectileKind::LlamaSpit);
assert!(!lifted);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.y, hi.y), (99.75, 100.25));
// The rise follows the billboard's own up axis, not world up.
let (right, up) = (glam::Vec3::Z, glam::Vec3::X);
let lifted = is_item_billboard(&ProjectileKind::Egg);
let (lo, hi) = bounds(&thrown_item_quad(at, right, up, 1.0, lifted));
assert_eq!((lo.x, hi.x), (-0.125, 0.375));
assert_eq!((lo.y, hi.y), (100.0, 100.0));
}
#[test]
fn thrown_item_billboard_does_not_flip_at_straight_up_or_down() {
@@ -5349,6 +5595,13 @@ mod tests {
}
}
/// Whether the killing blow's causing entity (`DamageSource.getEntity`) was a
/// mob, such as a tame wolf whose owner gets the kill credit.
fn killed_by_mob(m: &Mob) -> bool {
m.last_damage
.is_some_and(|(d, _)| matches!(d.attacker, Some(Who::Mob(_))))
}
/// `Animal.finalizeSpawnChildFromBreeding`'s player: the one who fed the
/// parent, or else the one who fed the partner (`getLoveCause`). `Some(None)`
/// is the host, `Some(Some(conn))` a guest. Both animals are spent.
@@ -5379,3 +5632,128 @@ mod breeding_tests {
assert_eq!(breeding_credit(&mut love, 1, 2), None);
}
}
#[cfg(test)]
mod pearl_tests {
use super::*;
/// A pearl's water test is `Entity.isInWater`: `wasTouchingWater` from the
/// last `updateFluidInteraction`, water whose surface reaches its 0.25
/// box (#3072), not the block at its feet.
#[test]
fn a_pearl_feels_the_water_its_box_touches() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("entity-types.json").exists()
|| !root.join("movement-properties.nbt").exists()
{
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let registry = assets.registry;
let facts = horizon_client::blocks::BlockFacts::load(&root, registry).unwrap();
let motion = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
let data = horizon_client::mobs::MobData::load(&root, &facts);
let biomes = horizon_client::biome::Biomes::load(&root).unwrap();
let items = horizon_client::items::Items::load(
&root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = horizon_client::collisions::load(&root, registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
// A pool of still water one block deep at y = 4, from x = 20 on (clear of the
// scene's pyramid): its
// surface is at 4 + 8/9.
let water = registry.default_state("minecraft:water").unwrap();
for x in 20..=40 {
for z in 0..=2 {
world.set(x, 4, z, water).unwrap();
}
}
let lid_openers = std::collections::HashMap::new();
let w = MobWorld {
teams: Default::default(),
world: &world,
registry,
facts: &facts,
fluid_tables: &motion.fluids,
movement: &motion.blocks,
collision: &collision,
data: &data,
player: None,
nearest_player: None,
others: &[],
others_grid: None,
others_index: None,
daylight: true,
monsters_burn: false,
game_time: 1000,
day_time: 1000,
raining: false,
thundering: false,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
mob_drops: true,
max_entity_cramming: 24,
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
end: false,
world_seed: 0,
sea_level: 63,
items: Some(&items),
lid_openers: &lid_openers,
};
let pw = ProjWorld {
w: &w,
targets: Vec::new(),
};
let pearl = |x: f64, y: f64, velocity: Vec3| {
let mut p = Projectile::new(ProjectileKind::EnderPearl, Vec3::new(x, y, 1.5), None);
p.velocity = velocity;
p
};
// Skimming the pool: its feet are in the water block, its box above
// the surface. ThrowableProjectile.applyInertia keeps the air drag.
let mut p = pearl(25.5, 4.95, Vec3::new(0.3, 0.0, 0.0));
p.tick(&pw);
assert!(p.position.y > 4.9, "the pearl dipped: {p:?}");
p.tick(&pw);
assert!(
(p.velocity.x - 0.3 * 0.99 * 0.99).abs() < 1e-9,
"a pearl above the surface slowed as in water: {p:?}"
);
// At the pool's edge: its feet over the dry block at x = 19, a side
// of its box in the water at x = 20. The water drag after the first
// tick, which still reads `wasTouchingWater` as false.
let mut p = pearl(19.95, 4.3, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"a pearl whose box is in the water kept the air drag: {p:?}"
);
// Thrown under water, a pearl moves with the air drag on its first
// tick and the water drag after it.
let mut p = pearl(30.5, 4.2, Vec3::ZERO);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99)).abs() < 1e-9,
"the first tick under water has the air drag: {p:?}"
);
p.tick(&pw);
assert!(
(p.velocity.y - (-0.03 * 0.99 - 0.03) * 0.8).abs() < 1e-9,
"the second tick under water has the water drag: {p:?}"
);
}
}
+198 -30
View File
@@ -14,6 +14,8 @@ use horizon_client::{
text::Text,
};
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
use horizon_world::nbt::Tag;
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
@@ -217,7 +219,7 @@ impl Game<'_> {
self.debug_charts.log_packets([sent, received]);
}
if shown {
use horizon_protocol::packets::{play::Serverbound, status::Ping};
use horizon_protocol::packets::status::Ping;
let time = self.debug_millis();
self.send(Serverbound::PingRequest(Ping { time }));
}
@@ -682,7 +684,7 @@ impl Game<'_> {
/// `GameModeCommand.setGameMode` with its feedback.
pub(super) fn change_game_mode(&mut self, mode: GameMode) {
if self.remote.is_some() {
use horizon_protocol::packets::play::{ChangeGameMode, Serverbound};
use horizon_protocol::packets::play::ChangeGameMode;
let mode = match mode {
GameMode::Survival => 0,
GameMode::Creative => 1,
@@ -698,10 +700,11 @@ impl Game<'_> {
}
}
/// `KeyboardHandler.copyRecreateCommand`: the targeted block or mob as a
/// `/setblock` or `/summon` command. In a single-player world the
/// server's data (no Shift) is the game's own.
/// `/setblock` or `/summon` command. The server's data (no Shift) comes
/// from the server the game is connected to, the integrated one included
/// (`DebugQueryHandler`); without one, the game's own data is the server's.
fn copy_recreate_command(&mut self, add_nbt: bool, from_server: bool) {
use horizon_client::java_text::format_fixed2 as f2;
let query = add_nbt && from_server && self.remote.is_some();
let side = if add_nbt && from_server {
"server"
} else {
@@ -710,45 +713,135 @@ impl Game<'_> {
if self.entity_targeted()
&& let Some((index, _)) = self.mob_hit()
{
let (id, pos, mob) = {
let (kind, position, id) = {
let m = &self.mobs[index];
(m.kind.id.clone(), m.position, m.id)
};
let mut command = format!("/summon {id} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if add_nbt && let Some(mut tag) = self.entity_nbt(super::commands::Target::Mob(mob)) {
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "UUID", "Pos"] {
c.remove(&key.into());
}
if query {
// DebugQueryHandler.queryEntityTag: the answer copies it.
let reply = QueryReply::Entity { id, kind, position };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply))
{
self.send(Serverbound::EntityTagQuery(EntityTagQuery {
transaction,
entity: id as i32,
}));
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
return;
}
self.set_clipboard(&command);
// Entity.saveWithoutId.
let tag = if add_nbt {
self.entity_nbt(super::commands::Target::Mob(id))
.map(|mut tag| {
if let Tag::Compound(c) = &mut tag {
c.remove(&"id".into());
}
tag
})
} else {
None
};
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated(&format!("debug.inspect.{side}.entity"));
return;
}
let Some(hit) = self.pick() else {
return;
};
let p = hit.pos;
let mut description = self.state_string(self.world.block(p[0], p[1], p[2]));
if add_nbt && let Some(e) = self.world.block_entities.get(&(p[0], p[1], p[2])) {
// BlockEntity.saveWithoutMetadata: no id or position.
let name = self.data_block_name(p);
let mut tag = horizon_client::save::encode_block_entity((p[0], p[1], p[2]), e, &name);
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
let pos = hit.pos;
let state = self.state_string(self.world.block(pos[0], pos[1], pos[2]));
if query {
// DebugQueryHandler.queryBlockEntityTag: the answer copies it.
let reply = QueryReply::Block { pos, state };
if let Some(transaction) = self.remote.as_mut().map(|r| r.debug_query.start(reply)) {
self.send(Serverbound::BlockEntityTagQuery(BlockEntityTagQuery {
transaction,
pos,
}));
}
description.push_str(&horizon_client::snbt::to_snbt(&tag));
return;
}
let tag = if add_nbt {
self.block_entity_tag(pos)
} else {
None
};
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `BlockEntity.saveWithoutMetadata` of the block entity at `pos`, if
/// there is one: its data without the id or the position.
pub(super) fn block_entity_tag(&self, pos: [i32; 3]) -> Option<Tag> {
let e = self.world.block_entities.get(&(pos[0], pos[1], pos[2]))?;
let name = self.data_block_name(pos);
let mut tag = horizon_client::save::encode_block_entity((pos[0], pos[1], pos[2]), e, &name);
if let Tag::Compound(c) = &mut tag {
for key in ["id", "x", "y", "z", "keepPacked"] {
c.remove(&key.into());
}
}
Some(tag)
}
/// `ClientPacketListener.handleTagQueryPacket`: the server's answer to
/// the last debug query (`DebugQueryHandler.handleResponse`) completes
/// it; an answer to an older query is ignored.
pub(super) fn answer_tag_query(&mut self, transaction: i32, tag: Tag) {
let Some(reply) = self
.remote
.as_mut()
.and_then(|r| r.debug_query.answer(transaction))
else {
return;
};
// FriendlyByteBuf.readNbt: an End tag is no compound.
let tag = match tag {
Tag::End => None,
tag => Some(tag),
};
match reply {
QueryReply::Block { pos, state } => {
self.copy_create_block_command(pos, &state, tag.as_ref());
self.debug_translated("debug.inspect.server.block");
}
QueryReply::Entity { id, kind, position } => {
// Entity.position() when the answer comes: where it is now,
// else where it was last.
let position = self
.mobs
.iter()
.find(|m| m.id == id)
.map_or(position, |m| m.position);
self.copy_create_entity_command(&kind, position, tag);
self.debug_translated("debug.inspect.server.entity");
}
}
}
/// `KeyboardHandler.copyCreateBlockCommand`.
fn copy_create_block_command(&mut self, pos: [i32; 3], state: &str, tag: Option<&Tag>) {
let mut description = state.to_owned();
if let Some(tag) = tag {
description.push_str(&horizon_client::snbt::to_snbt(tag));
}
self.set_clipboard(&format!(
"/setblock {} {} {} {description}",
p[0], p[1], p[2]
pos[0], pos[1], pos[2]
));
self.debug_translated(&format!("debug.inspect.{side}.block"));
}
/// `KeyboardHandler.copyCreateEntityCommand`: the data without its UUID
/// or position.
fn copy_create_entity_command(&mut self, kind: &str, pos: Vec3, tag: Option<Tag>) {
use horizon_client::java_text::format_fixed2 as f2;
let mut command = format!("/summon {kind} {} {} {}", f2(pos.x), f2(pos.y), f2(pos.z));
if let Some(mut tag) = tag {
if let Tag::Compound(c) = &mut tag {
for key in ["UUID", "Pos"] {
c.remove(&key.into());
}
}
command.push(' ');
command.push_str(&horizon_client::snbt::pretty(&tag).plain());
}
self.set_clipboard(&command);
}
/// `KeyboardHandler.setClipboard`.
fn set_clipboard(&mut self, text: &str) {
@@ -766,8 +859,8 @@ impl Game<'_> {
p.position.y.floor() as i32,
p.position.z.floor() as i32,
];
let min_y = horizon_client::chunk::MIN_Y;
g.chunk_borders(block, min_y, min_y + horizon_client::chunk::HEIGHT - 1);
let min_y = self.world.bottom_y();
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
}
if !self.show_hitboxes {
return g;
@@ -1010,3 +1103,78 @@ fn step_view(step: [i32; 3]) -> (f32, f32) {
(-x.atan2(z)).to_degrees() as f32,
)
}
/// `DebugQueryHandler`: the last debug NBT query (F3+I) sent to the server
/// and what its answer completes. Only the newest query is answered.
#[derive(Debug)]
pub(super) struct DebugQuery {
transaction: i32,
pending: Option<QueryReply>,
}
impl Default for DebugQuery {
fn default() -> Self {
Self {
transaction: -1,
pending: None,
}
}
}
/// What a debug query's answer is copied into: the block command
/// (`copyCreateBlockCommand`) or the entity's (`copyCreateEntityCommand`).
#[derive(Clone, Debug, PartialEq)]
pub(super) enum QueryReply {
Block {
pos: [i32; 3],
state: String,
},
Entity {
id: u32,
kind: String,
position: Vec3,
},
}
impl DebugQuery {
/// `DebugQueryHandler.startTransaction`: the next transaction id, which
/// replaces any query still waiting.
fn start(&mut self, reply: QueryReply) -> i32 {
self.pending = Some(reply);
self.transaction = self.transaction.wrapping_add(1);
self.transaction
}
/// `DebugQueryHandler.handleResponse`: what the answer to `transaction`
/// completes, once, when it answers the newest query.
fn answer(&mut self, transaction: i32) -> Option<QueryReply> {
if transaction == self.transaction {
self.pending.take()
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn block(x: i32) -> QueryReply {
QueryReply::Block {
pos: [x, 64, 0],
state: "minecraft:chest[facing=north,type=single,waterlogged=false]".into(),
}
}
#[test]
fn a_debug_query_is_answered_once_and_only_the_newest() {
let mut q = DebugQuery::default();
// The first transaction is 0 (the handler starts at -1).
assert_eq!(q.start(block(1)), 0);
assert_eq!(q.answer(0), Some(block(1)));
assert_eq!(q.answer(0), None);
// A second press before the first answer: the older answer is ignored
// and the newer one copies the newer target.
assert_eq!(q.start(block(2)), 1);
assert_eq!(q.start(block(3)), 2);
assert_eq!(q.answer(1), None);
assert_eq!(q.answer(2), Some(block(3)));
}
}
+73
View File
@@ -25,6 +25,26 @@ pub(super) fn redirectable(p: &Projectile) -> bool {
)
}
/// `Projectile.onRedirectProjectile`: another projectile hit `p`, which turns
/// with `deflect(AIM_DEFLECT, hitter's owner, hitter's owner, true, 1.0)`.
/// It flies along the owner's look (`Entity.getLookAngle`; unchanged when the
/// owner is gone), is that owner's now, and its acceleration power is back
/// to 0.1 (`AbstractHurtingProjectile.onDeflection` by an attack).
pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
p.acceleration = 0.1;
}
/// Its box for the crosshair: the entity's (a fireball 1 x 1, a wind charge
/// 0.3125, from its feet up) grown by the pick radius.
fn pick_box(p: &Projectile) -> Aabb {
@@ -106,6 +126,27 @@ impl Game<'_> {
d2 < range * range
}
/// `Entity.getLookAngle` of a projectile's owner: the host, the guest who
/// threw it, or a mob; none when the owner is not here.
pub(super) fn owner_look(&self, owner: Option<Who>, guest: Option<u32>) -> Option<Vec3> {
let (pitch, yaw) = match (owner?, guest) {
(Who::Player, Some(conn)) => {
let lan = self.lan.as_ref()?;
let g = lan.hub.guests.iter().find(|g| g.conn == conn)?;
(g.pitch, g.yaw)
}
(Who::Player, None) => {
self.player.as_ref()?;
(self.pitch, self.yaw)
}
(Who::Mob(id), _) => {
let m = self.mobs.iter().find(|m| m.id == id)?;
(m.head_pitch, m.yaw)
}
};
Some(horizon_world::view_direction::view_vector(pitch, yaw))
}
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
@@ -156,4 +197,36 @@ mod tests {
assert_eq!(b.min_position().x, -1.5);
assert_eq!(b.max_position().y, 66.0);
}
fn wind_charge() -> Projectile {
let mut p = Projectile::new(
ProjectileKind::WindCharge,
Vec3::new(0.0, 70.0, 0.0),
Some(Who::Mob(7)),
);
p.velocity = Vec3::new(0.0, 1.5, 0.0);
p
}
#[test]
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
}
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
}
@@ -186,6 +186,8 @@ impl Game<'_> {
Some("end") => self.dim = super::Dim::End,
_ => {}
}
// The level's build range follows its dimension, as `enter_dimension` sets it.
self.world.shape = self.dim.shape();
// The oracle level's clock stands at day time 6000.
self.world.day_time = 6000;
if let Some(rules) = case["rules"].as_object() {
+1 -1
View File
@@ -1414,7 +1414,7 @@ impl Game<'_> {
) -> Result<Dispensed> {
let target = offset(pos, facing);
let free = self.res.facts().is_air(self.block_at(target))
&& target.1 >= horizon_client::chunk::MIN_Y + 2
&& target.1 >= self.world.bottom_y() + 2
&& self.ui.difficulty != 0
&& self.can_build_wither(target);
if free {
+1
View File
@@ -2887,6 +2887,7 @@ impl Game<'_> {
precise: false,
needs_sync: false,
leash: None,
spectator: false,
})
}
}
@@ -25,6 +25,12 @@ enum MendSlot {
Armor(usize),
}
/// `Player.getDestroySpeed` multiplies by `BLOCK_BREAK_SPEED`; a player
/// without the attribute keeps the vanilla default of 1.0.
fn block_break_speed_factor(value: Option<f64>) -> f32 {
value.map_or(1.0, |value| value as f32)
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -39,6 +45,15 @@ impl Game<'_> {
}
speed * self.potions.effects.mining_speed_factor()
}
/// `Player.getDestroySpeed`'s final `Attributes.BLOCK_BREAK_SPEED` factor:
/// 1.0 by default, so a missing player or attribute changes nothing.
pub(super) fn block_break_speed(&mut self) -> f32 {
block_break_speed_factor(
self.player
.as_mut()
.and_then(|p| p.attribute("minecraft:block_break_speed").ok()),
)
}
/// `Attributes.SUBMERGED_MINING_SPEED` (0.2, raised by aqua affinity).
pub(super) fn submerged_mining_speed(&self) -> f32 {
let mut total = 0.0;
@@ -1012,3 +1027,19 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::block_break_speed_factor;
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
#[test]
fn block_break_speed_is_a_destroy_speed_multiplier() {
assert_eq!(block_break_speed_factor(None), 1.0);
assert_eq!(block_break_speed_factor(Some(1.0)), 1.0);
assert_eq!(block_break_speed_factor(Some(0.5)), 0.5);
assert_eq!(block_break_speed_factor(Some(2.0)), 2.0);
assert_eq!(8.0 * block_break_speed_factor(Some(0.5)), 4.0);
assert_eq!(8.0 * block_break_speed_factor(Some(2.0)), 16.0);
}
}
+157 -50
View File
@@ -10,8 +10,10 @@ use horizon_client::{
environment::Environment,
items::ItemStack,
mesher::Vertex,
mobs::Who,
motion::PlayerMotion,
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
vanillagen::VanillaTerrain,
world_spawn::PlayerSpawnFinder,
};
@@ -29,6 +31,9 @@ fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
/// `TicketType.PORTAL`'s timeout, in ticks.
const PORTAL_TICKET: u32 = 300;
/// ServerLevel.END_SPAWN_POINT.
pub(super) const END_SPAWN: [i32; 3] = [100, 50, 0];
/// EnderDragonFight gateway ring radius and height.
@@ -47,18 +52,27 @@ pub(super) enum GatewayJob {
Known { exit: [i32; 3], exact: bool },
}
/// An entity travelling through an End gateway: its saved compound and the
/// job that finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
/// An entity travelling through an End gateway: the entity and the job that
/// finds where it lands (`TheEndGatewayBlockEntity.getPortalPosition`).
#[derive(Clone, Debug)]
struct GatewayArrival {
/// The entity's compound, as it waits for the exit's chunks.
tag: Tag,
/// The entity, as it waits for the exit's chunks.
traveller: Arriving,
/// The gateway's exit, or the search for one.
job: GatewayJob,
/// The spot to load before the job is resolved.
target: Vec3,
}
/// What waits at a gateway's exit.
#[derive(Clone, Debug)]
enum Arriving {
/// An item or a mob, as its saved compound.
Saved(Tag),
/// A thrown ender pearl (projectiles are not saved).
Pearl(Box<Projectile>),
}
impl GatewayJob {
/// TheEndGatewayBlockEntity.getPortalPosition, with legacy save fallbacks.
fn for_gateway(
@@ -120,6 +134,10 @@ pub(super) struct EndState {
arrivals: Vec<GatewayArrival>,
/// The chunks kept loaded for those arrivals.
areas: Vec<(i32, i32)>,
/// `Entity.placePortalTicket` of the pearls that came out of a gateway:
/// the chunk each landed in, kept loaded for the ticks left of its
/// `TicketType.PORTAL`, so that it can come down and take its owner there.
tickets: Vec<((i32, i32), u32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -1462,13 +1480,17 @@ impl Game<'_> {
// ---- End gateway travel for the entities ----
/// `EndGatewayBlock.entityInside` for the items and mobs in the End: the
/// gateway blocks their box swept through. A gateway that is cooling down
/// (`TheEndGatewayBlockEntity.triggerCooldown`) takes nobody in.
/// `EndGatewayBlock.entityInside` for the items, mobs and thrown ender
/// pearls in the End: the gateway blocks their box swept through. A
/// gateway that is cooling down (`TheEndGatewayBlockEntity.triggerCooldown`)
/// takes nobody in.
pub(super) fn find_end_gateway_portals(&mut self) {
if self.remote.is_some() || self.dim != Dim::End {
return;
}
let is_gateway = |game: &Self, p: BlockPos| {
&*game.res.facts().get(game.world.block(p.x, p.y, p.z)).name == "minecraft:end_gateway"
};
let items: Vec<(u32, Vec3, f32, f32)> = self
.dropped
.iter()
@@ -1484,13 +1506,28 @@ impl Game<'_> {
for (id, to, width, height) in items.into_iter().chain(mobs) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
let found = horizon_client::end_gateway::swept_gateway(from, to, width, height, |p| {
&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name
== "minecraft:end_gateway"
is_gateway(self, p)
});
if let Some(pos) = found {
inside.push((id, pos));
}
}
// A pearl's move this tick, from where it started it.
let pearls: Vec<(u32, Option<Who>, Option<u32>, BlockPos)> = self
.creatures
.projectiles
.iter()
.filter_map(|p| {
let pos = horizon_client::end_gateway::pearl_gateway(p, |b| is_gateway(self, b))?;
Some((p.id, p.owner, p.guest, pos))
})
.collect();
for (id, owner, guest, pos) in pearls {
if owner == Some(Who::Player) {
self.pearl_owner_in_gateway(guest, pos);
}
inside.push((id, pos));
}
for (id, pos) in inside {
// One entity at a time: the gateway cools down for 40 ticks.
if !self.gateway_enter([pos.x, pos.y, pos.z]) {
@@ -1504,6 +1541,22 @@ impl Game<'_> {
}
}
/// `ThrownEnderpearl.onInsideBlock` in an End gateway: its owner's
/// `ServerPlayer.onInsideBlock`, the enter_block trigger, as if they had
/// walked in. A guest's pearl triggers theirs; one whose guest has left
/// has no owner.
fn pearl_owner_in_gateway(&mut self, guest: Option<u32>, pos: BlockPos) {
let state = self.world.block(pos.x, pos.y, pos.z);
let Some(conn) = guest else {
self.trigger_enter_block(state);
return;
};
if let Some(mut host) = self.lan.take() {
self.as_guest(&mut host, conn, |g| g.trigger_enter_block(state));
self.lan = Some(host);
}
}
/// `Entity.handlePortal` for the entities in an End gateway: they go
/// through to the gateway's exit, where they wait for its chunks.
pub(super) fn handle_end_gateway_portals(&mut self) {
@@ -1515,8 +1568,20 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, true))
.chain(self.mobs.iter().map(|m| (m.id, self.mob_can_use_portal(m))))
.chain(
self.creatures
.projectiles
.iter()
.filter(|p| matches!(p.kind, ProjectileKind::EnderPearl))
.map(|p| (p.id, true)),
)
.collect();
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
let mut present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
// A pearl waiting for its exit keeps its portal cooldown.
present.extend(self.end.arrivals.iter().filter_map(|a| match &a.traveller {
Arriving::Pearl(p) => Some(p.id),
Arriving::Saved(_) => None,
}));
self.end.gateways.retain(|id, _| present.contains(id));
let mut leaving: Vec<(u32, BlockPos)> = Vec::new();
for (id, allowed) in ids {
@@ -1573,11 +1638,28 @@ impl Game<'_> {
self.forget_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
let pearl = Box::new(self.creatures.projectiles.remove(i));
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
let arrival = GatewayArrival {
traveller: Arriving::Pearl(pearl),
job,
target,
};
// It is at the exit before it moves this tick, once that has loaded.
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
return;
} else {
return;
};
let (job, target) = self.gateway_job([gateway.x, gateway.y, gateway.z]);
self.end.arrivals.push(GatewayArrival { tag, job, target });
self.end.arrivals.push(GatewayArrival {
traveller: Arriving::Saved(tag),
job,
target,
});
}
/// The entities that reached a gateway's exit: they land where the
@@ -1586,47 +1668,60 @@ impl Game<'_> {
if self.remote.is_some() || self.dim != Dim::End {
return Ok(());
}
let mut i = 0;
while i < self.end.arrivals.len() {
let (cx, cz) = (
(self.end.arrivals[i].target.x.floor() as i32) >> 4,
(self.end.arrivals[i].target.z.floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&(cx, cz)) {
i += 1;
continue;
}
let (job, target) = (self.end.arrivals[i].job, self.end.arrivals[i].target);
let at = match self.resolve_gateway(job, target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
i += 1;
continue;
}
};
let mut tag = self.end.arrivals.remove(i).tag;
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
let key = ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
for arrival in std::mem::take(&mut self.end.arrivals) {
if let Some(waiting) = self.arrive_through_gateway(arrival) {
self.end.arrivals.push(waiting);
}
}
Ok(())
}
/// `Entity.teleport` of a gateway traveller once its exit's chunk has
/// loaded: an item or a mob joins the chunk it lands in, a pearl flies
/// on from there. The traveller comes back while it still waits.
fn arrive_through_gateway(&mut self, arrival: GatewayArrival) -> Option<GatewayArrival> {
let chunk = |at: Vec3| ((at.x.floor() as i32) >> 4, (at.z.floor() as i32) >> 4);
if !self.world.chunks.contains_key(&chunk(arrival.target)) {
return Some(arrival);
}
let at = match self.resolve_gateway(arrival.job, arrival.target) {
Ok(at) => at,
Err(e) => {
// The traveller waits for the exit: the failure is retried.
log::warn!("End gateway: {e}");
return Some(arrival);
}
};
let key = chunk(at);
match arrival.traveller {
Arriving::Saved(mut tag) => {
if let Tag::Compound(c) = &mut tag {
c.insert("Pos".into(), doubles([at.x, at.y, at.z]));
c.insert("Motion".into(), doubles([0.0, 0.0, 0.0]));
// setPortalCooldown before the teleport.
c.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
}
match self
.world
.loaded_entities
.iter_mut()
.find(|(k, _)| *k == key)
{
Some((_, tags)) => tags.push(tag),
None => self.world.loaded_entities.push((key, vec![tag])),
}
}
Arriving::Pearl(mut pearl) => {
horizon_client::end_gateway::place_pearl(&mut pearl, at);
// ThrownEnderpearl.teleport: placePortalTicket where it came out.
self.end.tickets.retain(|(c, _)| *c != key);
self.end.tickets.push((key, PORTAL_TICKET));
self.add_projectile(*pearl);
}
}
None
}
/// The chunks the entities waiting to arrive need, kept loaded like the
/// areas a server keeps around its players.
pub(super) fn keep_gateway_destinations(&mut self) {
@@ -1634,12 +1729,24 @@ impl Game<'_> {
self.streaming
.extra
.retain(|&(c, r)| !(r == 1 && areas.contains(&c)));
// The pearls' portal tickets hold End chunks, and run out there
// (TicketType.PORTAL's timeout).
let in_end = self.dim == Dim::End;
if in_end {
self.end.tickets.retain_mut(|(_, ticks)| {
*ticks = ticks.saturating_sub(1);
*ticks > 0
});
}
let mut areas = Vec::new();
for a in &self.end.arrivals {
let c = (
let arrivals = self.end.arrivals.iter().map(|a| {
(
(a.target.x.floor() as i32) >> 4,
(a.target.z.floor() as i32) >> 4,
);
)
});
let tickets = self.end.tickets.iter().filter(|_| in_end);
for c in arrivals.chain(tickets.map(|&(c, _)| c)) {
if !areas.contains(&c) {
areas.push(c);
self.streaming.extra.push((c, 1));
@@ -1,6 +1,5 @@
//! Entity occlusion affects mesh submission, never entity ticking or interaction.
use super::{Game, Vertex};
use horizon_client::chunk::MIN_Y;
use horizon_client::{
blocks::{RenderShape, StateFacts},
entity_culling::Culler,
@@ -41,7 +40,7 @@ impl Game<'_> {
if !opaque(self.res.facts().get(state)) {
return false;
}
let Some(y) = pos[1].checked_sub(MIN_Y) else {
let Some(y) = pos[1].checked_sub(self.world.bottom_y()) else {
return false;
};
let section = (pos[0] >> 4, y.div_euclid(16), pos[2] >> 4);
+113 -1
View File
@@ -89,6 +89,29 @@ pub(super) enum BlockInteraction {
DestroyWithDecay,
}
/// `ServerExplosion.hurtEntities`: an entity is hurt when its position is
/// within the blast's diameter of the centre.
pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bool {
let d =
((at[0] - center[0]).powi(2) + (at[1] - center[1]).powi(2) + (at[2] - center[2]).powi(2))
.sqrt();
d / diameter <= 1.0
}
/// Whether a blast leaves item frames, paintings, cushions and armour stands
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
/// `ignoreExplosion`; no wind charge goes through `server_explode`), and one a
/// mob causes does no harm (`BlockAttachedEntity.hurtServer` and
/// `ArmorStand.hurtServer`: `source.getEntity() instanceof Mob`).
pub(super) fn blast_spares_decorations(
mob_griefing: bool,
interaction: BlockInteraction,
by_mob: bool,
) -> bool {
!mob_griefing && (interaction == BlockInteraction::Keep || by_mob)
}
thread_local! {
/// Client-side randomness (explosion sound pitch), separate from the level random.
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
@@ -446,7 +469,6 @@ impl Game<'_> {
);
}
self.mobs = mobs;
self.explosion_breaks_frames(center, diameter, interaction);
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
let mut items = std::mem::take(&mut self.dropped);
items.retain_mut(|item| {
@@ -525,6 +547,73 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// 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.
self.explosion_breaks_frames(center, diameter, interaction);
self.explosion_breaks_decorations(center, diameter, interaction);
}
/// `blast_spares_decorations` for the blast going off now: the mob that
/// owns its projectile or explodes itself is the source's entity.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self
.combat
.explosion_owner
.or(self.combat.explosion_source)
.is_some();
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
}
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
/// stands (item frames: `explosion_breaks_frames`). A painting or cushion
/// in reach is killed and dropped (`BlockAttachedEntity.hurtServer`,
/// `Painting.dropItem`, `Cushion.dropItem`). An armour stand that is
/// neither invisible nor a marker breaks (`ArmorStand.hurtServer` with
/// `IS_EXPLOSION`: `brokenByAnything`, then `kill`): its equipment drops,
/// the stand itself does not.
/// Not ported: `BlockAttachedEntity.ignoreExplosion` also spares paintings
/// and cushions when the exploding entity is in water.
fn explosion_breaks_decorations(
&mut self,
center: [f64; 3],
diameter: f64,
interaction: BlockInteraction,
) {
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
while i < self.paintings.len() {
// HangingEntity.recalculateBoundingBox: a painting stands at the
// centre of its box.
let (lo, hi) = self.paintings[i].bounds();
let at = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if blast_reaches(center, diameter, at) {
self.break_painting(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.cushions.len() {
let p = self.cushions[i].position;
if blast_reaches(center, diameter, [p.x, p.y, p.z]) {
self.break_cushion(i, false);
} else {
i += 1;
}
}
let mut i = 0;
while i < self.armor_stands.len() {
let a = &self.armor_stands[i];
let p = a.position;
if !super::armor_stands::spared_by_blast(a)
&& blast_reaches(center, diameter, [p.x, p.y, p.z])
{
self.break_armor_stand_by_anything(i);
} else {
i += 1;
}
}
}
/// `ServerLevel.getDestroyType`: a drop decay rule picks between decaying
/// and plain destruction.
@@ -758,6 +847,29 @@ fn add_or_append_stack(
mod tests {
use super::*;
#[test]
fn blasts_reach_entities_within_their_diameter() {
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
let center = [0.5, 64.0, 0.5];
assert!(blast_reaches(center, 8.0, [8.5, 64.0, 0.5]));
assert!(blast_reaches(center, 8.0, [-3.5, 68.0, 4.5]));
assert!(!blast_reaches(center, 8.0, [8.6, 64.0, 0.5]));
}
#[test]
fn mob_griefing_off_spares_decorations_from_kept_and_mob_blasts() {
use BlockInteraction::{Destroy, DestroyWithDecay, Keep};
// mobGriefing on: every blast breaks them, a creeper's included.
assert!(!blast_spares_decorations(true, DestroyWithDecay, true));
assert!(!blast_spares_decorations(true, Keep, true));
// Off: TNT still breaks them (ExplosionInteraction.TNT destroys blocks).
assert!(!blast_spares_decorations(false, Destroy, false));
assert!(!blast_spares_decorations(false, DestroyWithDecay, false));
// Off: a blast that keeps blocks, or one a mob causes, does not.
assert!(blast_spares_decorations(false, Keep, false));
assert!(blast_spares_decorations(false, Destroy, true));
}
#[test]
fn block_pos_hash_matches_vec3i() {
// Vec3i.hashCode: (y + z * 31) * 31 + x
+8
View File
@@ -79,6 +79,14 @@ impl Game<'_> {
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let target = self.zoomed_fov_target(target);
// A server's spectator camera on a mob: its eye height. Its modifier
// stays 1 (another player's own modifier is not modelled here); the
// zoom extra still narrows it.
if let Some(m) = self.camera_mob() {
let eye = m.eye_height() as f32;
self.view.tick(Some(eye), self.zoomed_fov_target(1.0));
return;
}
let eye = self.player.as_ref().map(|p| {
if self.sleeping.is_some() {
0.2
+3 -7
View File
@@ -298,9 +298,8 @@ impl Game<'_> {
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
/// is killed and dropped with its item (`BlockAttachedEntity.hurtServer`).
/// A fixed frame is not hurt by an explosion. Without `mobGriefing` a blast
/// that keeps blocks (`ServerExplosion.shouldAffectBlocklikeEntities` through
/// `BlockAttachedEntity.ignoreExplosion`) and a mob's blast spare frames.
/// A fixed frame is not hurt by an explosion, and `blast_spares_decorations`
/// keeps every frame without `mobGriefing`.
/// Not ported: `ignoreExplosion` also spares frames when the exploding
/// entity is in water.
pub(super) fn explosion_breaks_frames(
@@ -309,10 +308,7 @@ impl Game<'_> {
diameter: f64,
interaction: super::explosion::BlockInteraction,
) {
if !self.ui.rules.bool("mob_griefing")
&& (interaction == super::explosion::BlockInteraction::Keep
|| self.combat.explosion_source.is_some())
{
if self.blast_spares_decorations(interaction) {
return;
}
let mut i = 0;
+116 -14
View File
@@ -58,7 +58,53 @@ fn attack_range_contains(range: &serde_json::Value, distance: f64, creative: boo
distance >= f64::from(min - margin) && distance <= f64::from(max + margin)
}
/// `FirstPersonHandsAndItems.shouldInstantlyReplaceVisibleItem`'s
/// `ItemStack.matchesIgnoringComponents(visible, held,
/// DataComponentType::ignoreSwapAnimation)` for one hand (every item plays
/// the swap animation, `ItemModelResolver.shouldPlaySwapAnimation`): the same
/// stack object, or an equal one, whose damage (the one component built
/// with `ignoreSwapAnimation`) may differ.
///
/// A stack that stays in its slot with the same item is the same object,
/// whatever its count or damage (vanilla shrinks, grows and damages it in
/// place), unless the slot was `replaced` by one the hand doesn't show yet:
/// swapping the hands (`setItemInHand`, and the server's slot updates that
/// answer it) puts another object in both (#2866).
fn shows_held_stack(
visible: &(usize, Option<ItemStack>),
held: &(usize, Option<ItemStack>),
replaced: bool,
) -> bool {
let same_object = visible.0 == held.0 && !replaced;
match (&visible.1, &held.1) {
(Some(a), Some(b)) if same_object => a.item == b.item,
(a, b) => matches_for_swap(a.as_ref(), b.as_ref()),
}
}
/// `ItemStack.matchesIgnoringComponents(a, b,
/// DataComponentType::ignoreSwapAnimation)`: the same count, item and
/// components but damage.
pub(super) fn matches_for_swap(a: Option<&ItemStack>, b: Option<&ItemStack>) -> bool {
match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.count == b.count && a.item == b.item && a.extra == b.extra,
_ => false,
}
}
impl Game<'_> {
/// A slot of the inventory (`OFFHAND_SLOT` for the off hand) was set to
/// a new stack: the hand holding it compares it as another `ItemStack`
/// (`shows_held_stack`) until the hand shows the held stack again.
pub(super) fn hand_stack_replaced(&mut self, slot: usize) {
if slot == self.selected {
self.hands_replaced[0] = true;
}
if slot == super::shield::OFFHAND_SLOT {
self.hands_replaced[1] = true;
}
}
/// Opening a screen stops breaking before the next game tick (which may pause).
pub(super) fn stop_mining(&mut self) {
if self.clicks.destroying.active {
@@ -155,7 +201,8 @@ impl Game<'_> {
Ok(())
}
/// `BlockBehaviour.getDestroyProgress` with `Player.getDestroySpeed`.
pub(super) fn destroy_progress(&self, pos: [i32; 3]) -> f32 {
pub(super) fn destroy_progress(&mut self, pos: [i32; 3]) -> f32 {
let block_break_speed = self.block_break_speed();
let state = self.world.block(pos[0], pos[1], pos[2]);
let facts = self.res.facts().get(state);
if facts.hardness == -1.0 {
@@ -164,6 +211,7 @@ impl Game<'_> {
let held = self.held();
let mut speed = self.items().mining_speed(held, &facts.name);
speed = self.modify_destroy_speed(speed);
speed *= block_break_speed;
if let Some(eye) = self.eye_position()
&& self
.res
@@ -266,22 +314,13 @@ impl Game<'_> {
}
self.attack_item = main.as_ref().map(|s| s.item.clone());
let next = [(self.selected, main), (OFFHAND, self.offhand.clone())];
// shouldInstantlyReplaceVisibleItem: the same stack, or an equal one
// (matchesIgnoringComponents; every item plays the swap animation).
// A stack that stays in its slot with the same item is the same
// stack, whatever its count (vanilla shrinks and grows it in place).
let mut changed = [false; 2];
for h in 0..2 {
let same = match (&self.visible_hands[h], &next[h]) {
((_, None), (_, None)) => true,
((slot, Some(a)), (next_slot, Some(b))) => {
a.item == b.item
&& (slot == next_slot || (a.count == b.count && a.extra == b.extra))
}
_ => false,
};
let replaced = self.hands_replaced[h];
let same = shows_held_stack(&self.visible_hands[h], &next[h], replaced);
if same {
self.visible_hands[h] = next[h].clone();
self.hands_replaced[h] = false;
}
changed[h] = !same;
}
@@ -294,6 +333,7 @@ impl Game<'_> {
for h in 0..2 {
if replace[h] {
self.visible_hands[h] = next[h].clone();
self.hands_replaced[h] = false;
}
}
}
@@ -418,7 +458,21 @@ impl Game<'_> {
/// Haste and Mining Fatigue modifiers (`ADD_MULTIPLIED_TOTAL`), clamped to
/// the attribute's range.
pub(super) fn main_hand_attack_speed(&self) -> f64 {
let value = horizon_client::attack_strength::BASE_ATTACK_SPEED
// The base comes from the player's attribute map so `/attribute ... base set` applies.
let base = self
.player
.as_ref()
.and_then(|player| {
let key = player.attributes.key("minecraft:attack_speed")?;
let handle = player.attributes.lookup(Some(&key))?;
player
.attributes
.instance(&handle)
.ok()
.map(|value| value.base_value())
})
.unwrap_or(horizon_client::attack_strength::BASE_ATTACK_SPEED);
let value = base
+ self
.attack_item_info()
.map_or(0.0, |i| f64::from(i.attack_speed));
@@ -569,6 +623,8 @@ impl HandWorld for GameHands<'_, '_> {
true
}
fn attack_block(&mut self, pos: [i32; 3]) {
// RedStoneOreBlock.interact: the dust comes whether or not the ore was lit.
self.game.redstone_ore_particles(pos);
let result = self.game.attack_block(pos);
self.keep(result);
}
@@ -661,6 +717,52 @@ mod tests {
assert_eq!(reach_pick(&boxes, eye, look, Vec3::ZERO, reach, wall), None);
}
fn stack(item: &str, count: u32, damage: u32) -> Option<ItemStack> {
Some(ItemStack {
item: item.into(),
count,
damage,
extra: None,
})
}
#[test]
fn a_swapped_in_stack_of_the_same_item_re_equips() {
// #2866: F swaps 64 torches into the off hand that held 32. The off
// hand's slot stays, but the server's slot update puts another stack
// object there; a different count fails matchesIgnoringComponents,
// so the hand plays the swap animation.
let visible = (OFFHAND, stack("minecraft:torch", 32, 0));
let held = (OFFHAND, stack("minecraft:torch", 64, 0));
assert!(!shows_held_stack(&visible, &held, true));
// The same stack shrunk in place (a torch placed): no animation.
assert!(shows_held_stack(&visible, &held, false));
// An equal stack replaced it: shown at once.
assert!(shows_held_stack(&visible, &visible.clone(), true));
// DataComponents.DAMAGE ignores the swap animation: a sword with
// other damage swapped in shows at once.
let sword = (OFFHAND, stack("minecraft:iron_sword", 1, 3));
let worn = (OFFHAND, stack("minecraft:iron_sword", 1, 40));
assert!(shows_held_stack(&sword, &worn, true));
// Another component (an anvil's repair cost) differs.
let mut named = worn.clone();
named.1.as_mut().unwrap().extra = Some(Box::new(horizon_client::item_data::StackData {
repair_cost: 1,
..Default::default()
}));
assert!(!shows_held_stack(&sword, &named, true));
// The main hand changing hotbar slot to an equal stack: instant; to
// a different count: animation.
let (a, b) = (
(0, stack("minecraft:torch", 32, 0)),
(1, stack("minecraft:torch", 32, 0)),
);
assert!(shows_held_stack(&a, &b, false));
let c = (1, stack("minecraft:torch", 5, 0));
assert!(!shows_held_stack(&a, &c, false));
assert!(shows_held_stack(&(0, None), &(OFFHAND, None), true));
}
#[test]
fn attack_range_uses_inclusive_float_bounds() {
let range = serde_json::json!({
@@ -6,7 +6,6 @@ use super::{Game, dispenser::entity_random};
use horizon_client::{
Result,
assets::Properties,
chunk::{HEIGHT, MIN_Y},
collisions::{SceneCollisions, no_collision_or_liquid},
world::World,
world_spawn::{
@@ -32,13 +31,13 @@ struct Columns<'a, 'r> {
}
impl SpawnLevel for Columns<'_, '_> {
fn min_y(&self) -> i32 {
MIN_Y
self.world.bottom_y()
}
fn height(&self, kind: HeightmapType, x: i32, z: i32) -> i32 {
(MIN_Y..MIN_Y + HEIGHT)
(self.world.bottom_y()..self.world.top_y())
.rev()
.find(|&y| kind.is_opaque(self.properties.heightmap_state(self.world.block(x, y, z))))
.map_or(MIN_Y, |y| y + 1)
.map_or(self.world.bottom_y(), |y| y + 1)
}
fn has_fluid(&self, x: i32, y: i32, z: i32) -> bool {
use horizon_world::heightmap::HeightmapState;
@@ -347,11 +346,11 @@ impl Game<'_> {
/// down onto the ground.
fn fixup_spawn_height(&self, pos: [i32; 3]) -> Vec3 {
let [x, mut y, z] = pos;
while !self.player_fits([x, y, z]) && y < MIN_Y + HEIGHT - 1 {
while !self.player_fits([x, y, z]) && y < self.world.top_y() - 1 {
y += 1;
}
y -= 1;
while self.player_fits([x, y, z]) && y > MIN_Y {
while self.player_fits([x, y, z]) && y > self.world.bottom_y() {
y -= 1;
}
bottom_center([x, y + 1, z])
+19 -23
View File
@@ -309,32 +309,28 @@ impl Game<'_> {
batch,
x,
y,
self.started.elapsed().as_secs_f32(),
self.settings.file.double("glintSpeed") as f32,
self.settings.file.double("glintStrength") as f32,
self.glint_frame.millis / 1000.0,
self.glint_frame.speed,
self.glint_frame.alpha,
);
}
/// The `GLINT_TEXTURING` inputs the atlas and options provide.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.gui
.atlas
.sprites
.get("misc/enchanted_glint_item")
.copied()
.map(|_| item_render::Glint {
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
millis: self.started.elapsed().as_secs_f32() * 1000.0,
scale: 0.5,
})
/// The glint clock, options and sprite for this frame: read once per
/// frame, not per item (`GameRenderer.extractOptions`).
pub(super) fn capture_glint_frame(&self) -> item_render::GlintFrame {
item_render::GlintFrame {
millis: self.started.elapsed().as_secs_f32() * 1000.0,
speed: self.settings.file.double("glintSpeed") as f32,
alpha: self.settings.file.double("glintStrength") as f32,
sprite: self
.gui
.atlas
.sprites
.contains_key("misc/enchanted_glint_item"),
}
}
/// The glint scroll phase shared by world items and armour
/// (`item_render::Glint::scroll_ticks`).
pub(super) fn glint_ticks(&self) -> f32 {
item_render::Glint::scroll_ticks(
self.started.elapsed().as_secs_f32() * 1000.0,
self.settings.file.double("glintSpeed") as f32,
)
/// The `ENTITY_GLINT_TEXTURING` inputs of this frame.
pub(super) fn glint(&self) -> Option<item_render::Glint> {
self.glint_frame.glint(0.5)
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
@@ -3,9 +3,13 @@
use super::Game;
use horizon_assets::item_model::DisplayContext;
use horizon_client::{
item_render::stack_view,
items::ItemStack,
particles::{self, ParticleOptions, ParticleType},
};
use horizon_core::random::RandomSource;
use horizon_world::player_inventory::EquipmentSlot;
use serde_json::Value;
impl Game<'_> {
/// `ItemParticleOption(ITEM, stack)`: the layer particle sprites of the
@@ -36,6 +40,75 @@ impl Game<'_> {
self.particles.add(&options, p, v);
}
}
/// `LivingEntity.handleEntityEvent` 47 to 52, 65 and 68 from a server:
/// the entity's item in `slot` broke, so `breakItem` the stack this
/// client still has there (the slot is emptied by a later update).
pub(super) fn remote_equipment_break(&mut self, id: i32, slot: EquipmentSlot) {
if self.remote.as_ref().is_some_and(|r| r.entity_id == id) {
let stack = match slot {
EquipmentSlot::MainHand => self.held().cloned(),
EquipmentSlot::OffHand => self.offhand.clone(),
EquipmentSlot::Head => self.armor[0].clone(),
EquipmentSlot::Chest => self.armor[1].clone(),
EquipmentSlot::Legs => self.armor[2].clone(),
EquipmentSlot::Feet => self.armor[3].clone(),
EquipmentSlot::Body | EquipmentSlot::Saddle => None,
};
let Some(stack) = stack else {
return;
};
if let Some(p) = self.player.as_ref().map(|p| p.position) {
self.break_sound_at(&stack, [p.x, p.y, p.z]);
}
self.eat_particles(&stack, 5);
return;
}
let Some(m) = self.mobs.iter().find(|m| m.id == id as u32) else {
return;
};
let Some(stack) = m.item_by_slot(slot) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let (eye, pitch, yaw, silent) = (m.eye_height(), m.head_pitch, m.yaw, m.silent);
if !silent {
self.break_sound_at(&stack, at);
}
let options = self.item_particle(&stack);
let mut spawned = Vec::new();
particles::item_use_particles(
&mut self.world.random,
pitch,
yaw,
at,
at[1] + eye,
5,
|p, v| spawned.push((p, v)),
);
for (p, v) in spawned {
self.particles.add(&options, p, v);
}
}
/// `LivingEntity.breakItem`'s sound: the stack's `break_sound` (none when
/// the component is removed), heard by this client only
/// (`playLocalSound`) at the entity's feet, at 0.8 volume and a pitch of
/// 0.8 to 1.2.
fn break_sound_at(&mut self, stack: &ItemStack, at: [f64; 3]) {
let models = &self.pack.item_renderer.models;
let sound = match models
.components(&stack_view(stack))
.get("minecraft:break_sound")
{
Some(Value::String(id)) => id.clone(),
Some(Value::Object(inline)) => match inline.get("sound_id") {
Some(Value::String(id)) => id.clone(),
_ => return,
},
_ => return,
};
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play_unshared(&sound, Some(at), 0.8, pitch);
}
/// The slime ball, snowball and cobweb models behind `item_slime`,
/// `item_snowball` and `item_cobweb`, resolved on first use.
pub(super) fn ensure_item_particle_sprites(&mut self) {
+6 -2
View File
@@ -183,7 +183,8 @@ pub(super) fn baby_index(kind: &str) -> Option<u8> {
| "minecraft:axolotl"
| "minecraft:dolphin"
| "minecraft:squid"
| "minecraft:glow_squid" => Some(16),
| "minecraft:glow_squid"
| "minecraft:sulfur_cube" => Some(16),
_ => None,
}
}
@@ -627,10 +628,11 @@ fn mob_meta(
}
}
// AbstractCubeMob.ID_SIZE.
"minecraft:slime" | "minecraft:magma_cube" => {
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
if e.size > 1 {
meta.push((18, serializer::INT, Value::Int(e.size)));
}
defaults.push((18, serializer::INT, Value::Int(1)));
}
// Creeper.DATA_SWELL_DIR, DATA_IS_POWERED and DATA_IS_IGNITED: the
// guest swells the creeper from them. The direction always goes in,
@@ -1613,6 +1615,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
@@ -1869,6 +1872,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.place_new_player(conn, &mut ctx);
if elsewhere && let Err(e) = self.enter_level(Some(host), back) {
+58 -4
View File
@@ -1583,6 +1583,33 @@ impl Game<'_> {
self.send_admin_feedback(host, Some(conn));
}
}
// handleEntityTagQuery: a game master's F3+I on an entity of their
// level gets its Entity.saveWithoutId.
S::EntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
if let Some(tag) = self.level_entity_tag(host, p.entity) {
host.hub.send_to(
conn,
&C::TagQuery(play::TagQuery {
transaction: p.transaction,
tag,
}),
);
}
}
// handleBlockEntityTagQuery: BlockEntity.saveWithoutMetadata, or
// null where there is no block entity.
S::BlockEntityTagQuery(p) if host.hub.guests[gi].permission >= 2 => {
let tag = self
.block_entity_tag(p.pos)
.unwrap_or(horizon_world::nbt::Tag::End);
host.hub.send_to(
conn,
&C::TagQuery(play::TagQuery {
transaction: p.transaction,
tag,
}),
);
}
S::Unknown(id, payload)
if id == horizon_protocol::ids::play::serverbound::COMMAND_SUGGESTION =>
{
@@ -1638,6 +1665,31 @@ impl Game<'_> {
}
}
/// `ServerLevel.getEntity(id)`'s `Entity.saveWithoutId`: the player or
/// other entity of the level in place with that network id.
fn level_entity_tag(&mut self, host: &mut LanHost, id: i32) -> Option<horizon_world::nbt::Tag> {
use super::commands::Target;
let player = host
.hub
.guests
.iter()
.find(|g| g.entity_id == id && host.hub.here(g))
.map(|g| g.conn);
let mut tag = match player {
Some(player) => self
.as_guest(host, player, |g| g.entity_nbt(Target::Player))
.flatten()?,
None => {
let target = self.level_entity(id)?;
self.entity_nbt(target)?
}
};
if let horizon_world::nbt::Tag::Compound(c) = &mut tag {
c.remove(&"id".into());
}
Some(tag)
}
/// `ServerGamePacketListenerImpl.tryPickItem` for the guest: a matching stack
/// is selected, else a creative player gets the item; the held slot is sent
/// back and the changed slots follow with the tick's inventory changes.
@@ -2232,6 +2284,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.respawn(i, spawn, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
@@ -2450,7 +2503,7 @@ impl Game<'_> {
let me = self.player_text(&name, uuid);
let text = match cause {
Some(key) => horizon_client::text::Text::translate(&key, &[me]),
None if at[1] < f64::from(horizon_client::chunk::MIN_Y - 64) => {
None if at[1] < f64::from(self.world.bottom_y() - 64) => {
horizon_client::text::Text::translate("death.attack.outOfWorld", &[me])
}
None => self.guest_death_message(conn, me),
@@ -2719,9 +2772,10 @@ impl Game<'_> {
for (moved, on_ground) in moves {
let movement = Vec3::new(moved[0], moved[1], moved[2]);
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
let sprinting = w.player.sprinting;
w.player
.cause_food_exhaustion(if sprinting { 0.2 } else { 0.05 });
// ServerGamePacketListenerImpl.handlePlayerPositionChange:
// the server's copy jumps with the player, so the velocity
// it sends them keeps the jump.
let _ = w.jump_from_ground();
}
let landed = (|| -> horizon_client::Result<()> {
w.set_on_ground_with_movement(on_ground, false, movement)?;
@@ -8,7 +8,7 @@ use horizon_client::{
effects,
lan::attributes::{add_modifier, generic_syncable, instance, operation_named},
mobs::{
Mob,
Mob, Species,
finalize::{self, Attr, Op},
},
};
@@ -147,6 +147,25 @@ impl Game<'_> {
}
}
}
// AbstractHorse.randomizeAttributes rolls a horse's jump strength as
// its base (kept in `vector[0]`), so the instance is synced: its
// rider's client jumps it (`getCustomJump`).
let rolled = matches!(
m.species(),
Species::Horse | Species::SkeletonHorse | Species::ZombieHorse
);
let jump_base = m
.extra
.attributes
.as_deref()
.is_some_and(|s| s.bases.iter().any(|(a, _)| *a == Attr::JumpStrength));
if rolled
&& !jump_base
&& m.extra.vector[0] > 0.0
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
{
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
}
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
self.synced_attribute(kind, "minecraft:movement_speed"),
finalize::transient(m, Attr::MovementSpeed),
@@ -31,6 +31,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
precise: false,
needs_sync: false,
leash: None,
spectator: false,
}
}
+63 -19
View File
@@ -11,6 +11,7 @@
//! is teleported where the player lands (`ServerPlayer.dismountTo`).
use super::{Game, Keys, lan::LanHost, minecarts::SEAT};
use horizon_client::collisions::SceneCollisions;
use horizon_client::mobs::equine;
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::{
packets::play::{self, Clientbound as C},
@@ -225,9 +226,10 @@ impl Game<'_> {
}
}
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat its rider
/// drives goes where the client says, unless it moved too quickly or
/// wrongly; then it stays, and the client is told where it is.
/// `ServerGamePacketListenerImpl.handleMoveVehicle`: the boat or the
/// saddled horse its rider drives goes where the client says, unless it
/// moved too quickly or wrongly; then it stays, and the client is told
/// where it is.
pub(super) fn guest_move_vehicle(
&mut self,
host: &mut LanHost,
@@ -264,9 +266,41 @@ impl Game<'_> {
};
host.hub.send_to(conn, &C::MoveVehicle(packet));
};
// Only a boat is driven by its rider (getControllingPassenger); a
// minecart that is elsewhere on the client is put back.
let Some(i) = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v)) else {
// Only a boat, and a horse its rider's client moves, are driven by
// their rider (getControllingPassenger); a minecart that is elsewhere
// on the client is put back.
let boat = vehicle.and_then(|v| self.boats.iter().position(|b| b.id == v));
let mount = vehicle.and_then(|v| {
self.mobs
.iter()
.position(|m| m.id == v && equine::moved_by_client(m))
});
// The vehicle's id, position, rotation, motion, box, step height
// and ground.
let (id, old, yaw, velocity, bounds, step, on_ground) = if let Some(i) = boat {
let b = &self.boats[i];
(
b.id,
b.position,
b.yaw,
b.velocity,
b.bounds(),
0.0,
b.on_ground,
)
} else if let Some(i) = mount {
let m = &self.mobs[i];
let step = m.kind.step_height as f32;
(
m.id,
m.position,
m.yaw,
m.velocity,
m.bounds(),
step,
m.on_ground,
)
} else {
if let Some(c) = vehicle.and_then(|v| self.carts.iter().find(|c| c.id == v))
&& c.position.distance_squared(Vec3::new(x, y, z)) > 1.0
{
@@ -274,8 +308,6 @@ impl Game<'_> {
}
return;
};
let b = &self.boats[i];
let old = b.position;
let good = good.map_or(old, |g| Vec3::new(g[0], g[1], g[2]));
let target = Vec3::new(
x.clamp(-3.0E7, 3.0E7),
@@ -283,16 +315,15 @@ impl Game<'_> {
z.clamp(-3.0E7, 3.0E7),
);
// Moved too quickly: more than 10 blocks past its own motion.
let moved = target.distance_squared(old) - b.velocity.length_squared();
let moved = target.distance_squared(old) - velocity.length_squared();
if moved > 100.0 && !owner {
log::warn!("boat {} moved too quickly! {moved}", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved too quickly! {moved}");
back(host, old, yaw);
return;
}
// vehicle.move(PLAYER, target - lastGood) with the boat's collisions:
// where it can't follow the client (a quarter block off, the height
// aside), it moved wrongly.
let bounds = b.bounds();
// vehicle.move(PLAYER, target - lastGood) with the vehicle's
// collisions: where it can't follow the client (a quarter block off,
// the height aside), it moved wrongly.
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -300,7 +331,7 @@ impl Game<'_> {
};
let wanted = target.subtract(good);
let resolved = scene
.resolve(bounds, wanted, 0.0, b.on_ground)
.resolve(bounds, wanted, step, on_ground)
.unwrap_or(wanted);
let reached = old.add_vector(resolved);
let wrongly = (target.x - reached.x).powi(2) + (target.z - reached.z).powi(2) > 0.0625;
@@ -311,10 +342,24 @@ impl Game<'_> {
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
if free_before && (wrongly || !free_after) {
log::warn!("boat {} moved wrongly", b.id);
back(host, old, b.yaw);
log::warn!("vehicle {id} moved wrongly");
back(host, old, yaw);
return;
}
host.hub.guests[gi].vehicle_good = Some([target.x, target.y, target.z]);
if let Some(i) = mount {
// absSnapTo: where and how its rider's client turned it; the
// rider sits on it from its own ride tick.
let m = &mut self.mobs[i];
m.position = target;
m.yaw = p.yaw;
m.head_pitch = p.pitch;
m.on_ground = p.on_ground;
return;
}
let Some(i) = boat else {
return;
};
let b = &mut self.boats[i];
b.previous = old;
b.velocity = target.subtract(old);
@@ -324,7 +369,6 @@ impl Game<'_> {
b.on_ground = p.on_ground;
let seat = self.player_boat_seat(i);
let g = &mut host.hub.guests[gi];
g.vehicle_good = Some([target.x, target.y, target.z]);
g.previous_position = g.position;
g.position = [seat.x, seat.y, seat.z];
}
@@ -0,0 +1,266 @@
//! The game's side of the Launch API (docs/api/launch.md): the options another launcher may pass,
//! `--launch-api`'s reply, the exit codes, and docs/api/launch-reference.md, generated from them.
//! Update it with: `HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api`.
/// The Launch API's version (docs/api/stability.md): an addition makes the next patch.
pub const VERSION: &str = "0.1.0";
/// One option of the contract: its name, its value's placeholder (none for a flag), what it does.
pub struct LaunchOption {
pub name: &'static str,
pub value: Option<&'static str>,
/// For the reference only.
#[cfg_attr(not(test), allow(dead_code))]
pub what: &'static str,
}
const fn option(
name: &'static str,
value: Option<&'static str>,
what: &'static str,
) -> LaunchOption {
LaunchOption { name, value, what }
}
/// What a launcher may pass. The game takes more (`--help` lists them), but only these are
/// promised: they keep their meaning within a line of this API.
pub const OPTIONS: &[LaunchOption] = &[
option(
"--resources",
Some("DIR"),
"The prepared resources (horizon-prepare's output). Default: `resources` beside the executable, else `.local/horizon-client/26.3`.",
),
option(
"--game-dir",
Some("DIR"),
"The game folder: `saves/`, `options.txt`, `resourcepacks/`, `horizon-mods/`, `screenshots/`. Created when missing. Default: the working directory. `--gameDir` is the same.",
),
option(
"--home",
Some("DIR"),
"Logs (`logs/latest.log`) and crash reports (`crash-reports/`). Default: `HORIZON_HOME`, else `horizon` in the platform data folder.",
),
option(
"--width",
Some("N"),
"The window's starting width (default 1280).",
),
option(
"--height",
Some("N"),
"The window's starting height (default 720).",
),
option(
"--fullscreen",
None,
"Opens the window fullscreen, whatever `options.txt` says; `options.txt` is not changed.",
),
option(
"--session-file",
Some("PATH"),
"The signed-in account, as the session JSON below. Read once, then deleted.",
),
option(
"--session-stdin",
None,
"The same JSON on standard input, so it never touches the disk.",
),
option(
"--quickPlaySingleplayer",
Some("WORLD"),
"Opens this world of `saves/` at start, only if it exists (else the title screen). WORLD is one folder name: a path is refused.",
),
option(
"--quickPlayMultiplayer",
Some("HOST[:PORT]"),
"Joins this server at start.",
),
option(
"--launch-api",
None,
"Prints the reply below and exits 0, before anything else runs.",
),
];
/// How the game ends, for the launcher that started it.
pub mod exit_code {
/// The player quit, or `--help` and `--launch-api` printed.
pub const OK: i32 = 0;
/// The game stopped on an error; a crash report was saved.
pub const ERROR: i32 = 1;
/// The command line was refused (an unknown option, a missing or bad value); no crash report.
pub const USAGE: i32 = 2;
/// A panic; a crash report was saved.
pub const PANIC: i32 = 101;
}
const EXIT_CODES: &[(i32, &str)] = &[
(
exit_code::OK,
"The player quit, or `--help` or `--launch-api` printed.",
),
(
exit_code::ERROR,
"The game stopped on an error. A crash report was saved.",
),
(
exit_code::USAGE,
"The command line was refused: an unknown option, a missing or bad value. No crash report.",
),
(
exit_code::PANIC,
"The game panicked. A crash report was saved.",
),
];
/// A command line the game refuses: exit code 2, no crash report.
#[derive(Debug)]
pub struct UsageError(pub String);
impl std::fmt::Display for UsageError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for UsageError {}
/// `--launch-api`'s reply for a build of this `horizon` version.
pub fn reply(horizon: &str) -> String {
let options: Vec<_> = OPTIONS
.iter()
.map(|o| serde_json::json!({ "name": o.name, "value": o.value }))
.collect();
serde_json::to_string_pretty(&serde_json::json!({
"launch_api": VERSION,
"horizon": horizon,
"minecraft": horizon_core::VANILLA_VERSION,
"options": options,
"exit_codes": EXIT_CODES.iter().map(|(code, _)| code).collect::<Vec<_>>(),
"crash_report_line": horizon_client::crash::SAVED_LINE,
}))
.expect("a JSON value")
}
/// docs/api/launch-reference.md.
#[cfg(test)]
pub fn markdown() -> String {
use std::fmt::Write;
let mut out = String::new();
let w = &mut out;
let _ = writeln!(
w,
"<!-- Generated from crates/horizon-client/src/game/launch_api.rs; a test fails when this file is out of date. Update it with: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api -->"
);
let _ = writeln!(w, "# Launch API reference\n");
let _ = writeln!(
w,
"What `horizon-client` promises a launcher that starts it, Launch API {VERSION}. The guide: \
[launch.md](launch.md); stability and versions: [stability.md](stability.md).\n"
);
let _ = writeln!(w, "## Options\n");
let _ = writeln!(
w,
"Any other option is refused with exit code 2, so ask `--launch-api` before passing one \
a build may not know.\n"
);
let _ = writeln!(w, "| Option | What it does |\n| --- | --- |");
for o in OPTIONS {
let usage = match o.value {
Some(value) => format!("`{} {value}`", o.name),
None => format!("`{}`", o.name),
};
let _ = writeln!(w, "| {usage} | {} |", o.what);
}
let _ = writeln!(w, "\n## `--launch-api`\n");
let _ = writeln!(
w,
"Prints one JSON object on stdout and exits 0. `horizon` is the build's version; readers \
ignore fields they don't know.\n"
);
let _ = writeln!(w, "```json\n{}\n```\n", reply("<version>"));
let _ = writeln!(w, "## Exit codes\n\n| Code | Meaning |\n| --- | --- |");
for (code, what) in EXIT_CODES {
let _ = writeln!(w, "| {code} | {what} |");
}
let _ = writeln!(
w,
"\nWhen a crash report is saved, the last line on stderr is `{}` followed by the \
report's path.",
horizon_client::crash::SAVED_LINE.trim_end()
);
out
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> horizon_client::Result<Option<super::super::Options>> {
super::super::Options::parse(args.iter().map(|a| a.to_string()))
}
/// Every promised option is one the game accepts.
#[test]
fn launch_api_options_are_accepted() {
for o in OPTIONS {
let args: Vec<&str> = std::iter::once(o.name)
.chain(o.value.map(|v| if v == "N" { "100" } else { "x" }))
.collect();
assert!(parse(&args).is_ok(), "{} is refused", o.name);
}
}
#[test]
fn launch_api_flags_are_read() {
let o = parse(&["--fullscreen", "--game-dir", "g", "--session-stdin"])
.unwrap()
.unwrap();
assert!(o.fullscreen && o.session_stdin);
assert_eq!(o.game_dir.as_deref(), Some(std::path::Path::new("g")));
assert!(parse(&["--launch-api"]).unwrap().is_none());
assert!(parse(&["--no-such-option"]).is_err());
}
/// A launcher shows the error to the player, so it names the option.
#[test]
fn launch_api_bad_value_names_its_option() {
let error = |args: &[&str]| parse(args).err().unwrap().to_string();
assert_eq!(
error(&["--width", "wide"]),
"--width: invalid digit found in string"
);
assert_eq!(error(&["--bogus", "x"]), "--bogus: unknown option");
}
/// Quick play opens one folder of saves/, as on vanilla: nothing outside it.
#[test]
fn launch_api_quick_play_is_one_folder_of_saves() {
for name in ["../x", "a/b", "/abs", ".", ".."] {
assert!(
parse(&["--quickPlaySingleplayer", name]).is_err(),
"{name} is accepted"
);
}
assert!(parse(&["--quickPlaySingleplayer", "E2E World"]).is_ok());
}
/// docs/api/launch-reference.md is what `markdown` writes.
#[test]
fn launch_api_reference_is_current() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.join("docs/api/launch-reference.md");
let want = markdown();
if std::env::var_os("HORIZON_WRITE_DOCS").is_some() {
std::fs::write(&path, &want).unwrap();
}
let have = std::fs::read_to_string(&path)
.unwrap_or_default()
.replace("\r\n", "\n");
assert!(
have == want,
"docs/api/launch-reference.md is out of date: HORIZON_WRITE_DOCS=1 cargo test -p horizon-client --bin horizon-client launch_api"
);
}
}
@@ -249,10 +249,7 @@ impl Game<'_> {
let mut root = None;
for step in 1..11 {
let y = pos.y + step;
if !(horizon_client::chunk::MIN_Y
..horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT)
.contains(&y)
{
if !(self.world.bottom_y()..self.world.top_y()).contains(&y) {
break;
}
let state = self.world.block(pos.x, y, pos.z);
+17 -1
View File
@@ -112,6 +112,11 @@ pub(super) struct Levels {
}
impl Levels {
/// A parked level, without opening or loading it.
pub(super) fn parked_world(&self, dim: Dim) -> Option<&World> {
self.parked.get(&dim).map(|level| &level.world)
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
@@ -136,6 +141,7 @@ impl Game<'_> {
self.level.seed,
)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.shape = dim.shape();
world.fast_lava = dim == Dim::Nether;
Ok(Parked {
world,
@@ -365,8 +371,18 @@ impl Game<'_> {
}
}
self.level_pass = Pass::default();
if result.is_err() {
self.native_lifecycle.cancel_server_tick();
}
result?;
self.close_idle_levels()
if let Err(error) = self.close_idle_levels() {
self.native_lifecycle.cancel_server_tick();
return Err(error);
}
// `ServerTickEnd`: an errored tick or an idle-close failure (at
// the `?` above) never reaches this site.
self.native_lifecycle_server_tick_end(paused);
Ok(())
}
/// `tick_server` as a server thread runs it. With `HORIZON_SPIKES=MS`, a
+18 -1
View File
@@ -21,6 +21,9 @@ pub(super) struct Bolt {
converted: Vec<u32>,
/// The mobs it has struck (`hitEntities`).
hit: Vec<u32>,
/// Items a cushion dropped under it, spared for 20 ticks
/// (`Cushion.dropItem`: `setInvulnerableTime(20)`).
spared: Vec<u32>,
/// Blocks it set on fire (`blocksSetOnFire`).
fires: i32,
/// The player who channelled it (`LightningBolt.cause`): `Some(None)` for
@@ -119,7 +122,7 @@ impl Game<'_> {
if !section.blocks.maybe_has(&mut rod) {
continue;
}
let base_y = horizon_client::chunk::MIN_Y + 16 * i as i32;
let base_y = self.world.bottom_y() + 16 * i as i32;
for j in 0..4096 {
if !rod(section.get(j)) {
continue;
@@ -219,6 +222,7 @@ impl Game<'_> {
visual_only,
converted: Vec::new(),
hit: Vec::new(),
spared: Vec::new(),
fires: 0,
cause: None,
});
@@ -240,6 +244,7 @@ impl Game<'_> {
visual_only: true,
converted: Vec::new(),
hit: Vec::new(),
spared: Vec::new(),
fires: 0,
cause: None,
});
@@ -505,6 +510,17 @@ impl Game<'_> {
if !events.is_empty() {
let _ = self.handle_mob_events(events);
}
// Cushion.thunderHit: a cushion in the box breaks and drops its item
// (the bolt is no player, so no creative spare). Last first, since
// break_cushion removes the entry.
for i in (0..self.cushions.len()).rev() {
let p = self.cushions[i].position;
if inside([p.x, p.y, p.z]) {
let before = self.dropped.len();
self.break_cushion(i, false);
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
}
}
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
@@ -527,6 +543,7 @@ impl Game<'_> {
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
|| e.stack.item.contains("netherite")
|| (e.age < 20 && b.spared.contains(&e.id))
});
struck.into_iter().map(|(_, view)| view).collect()
}
+1 -1
View File
@@ -1074,7 +1074,7 @@ impl Game<'_> {
for dz in 0..scale {
let (x, z) = (min_x + dx, min_z + dz);
let mut y = self.world_surface(x, z);
let min_y = super::MIN_Y;
let min_y = self.world.bottom_y();
let mut color;
if y <= min_y {
color = 11;
+3
View File
@@ -398,6 +398,9 @@ impl Game<'_> {
steer,
sprint: keys.sprint || self.keys.contains(&winit::keyboard::KeyCode::ControlLeft),
jumping: keys.jump,
// A server's player (a LAN guest, single player's window) drives
// a horse from its own client (`handleMoveVehicle`).
client: self.lan_acting.is_some(),
});
true
}
@@ -58,7 +58,7 @@ impl Game<'_> {
/// Whether the chunk mesh doesn't show the blocks at `p` yet (edited,
/// meshing, or waiting for its upload).
pub(super) fn section_mesh_pending(&self, p: [i32; 3]) -> bool {
let Some(y) = p[1].checked_sub(horizon_client::chunk::MIN_Y) else {
let Some(y) = p[1].checked_sub(self.world.layout().min_y()) else {
return false;
};
let section = (p[0] >> 4, y.div_euclid(16), p[2] >> 4);
+1 -1
View File
@@ -77,7 +77,7 @@ impl Game<'_> {
|| self.camera_type.first_person()
|| self.sleeping.is_some()
|| self.spectator()
|| self.potions.effects.get("invisibility").is_some()
|| self.own_invisible()
{
return None;
}
+78 -4
View File
@@ -6,6 +6,14 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 7 adds block-change observations (`native_block_changed`).
//!
//! API 6 adds neighbour-change observations (`native_neighbor_changed`).
//!
//! API 5 adds read-only server block queries (`native_server_block`).
//!
//! API 4 adds authoritative server tick hooks (`native_server_tick`).
//!
//! API 3 adds the world lifecycle hook (`native_world_lifecycle`).
//!
//! API 2 adds the item panel beside container screens and its recipe pages
@@ -21,8 +29,11 @@
//! that would break such a bundle is not made.
//! Bodies have no `cfg` and no macros: horizon-mixins refuses a target with
//! either (`tests::hook_points_stay_hookable`).
use super::native_lifecycle::{NativeLifecycleContext, WorldPhase};
use super::{Game, Screen, execution_role::ExecutionRole, native_panel_recipes::RecipePage};
use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhase};
use super::{
Game, GameLevel, LevelDimension, Screen, execution_role::ExecutionRole,
native_panel_recipes::RecipePage,
};
use horizon_client::{
chat::ChatSource,
extras::Corner,
@@ -32,13 +43,44 @@ use horizon_client::{
mod_list::{self, Installed, NativePage},
text::Text,
};
use horizon_core::position::BlockPos;
use horizon_world::{level::BlockId, registry::BlockStateId};
use std::{fs, path::PathBuf, sync::Arc, time::Duration};
use winit::{event::MouseButton, keyboard::KeyCode};
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 3;
pub(crate) const NATIVE_HOOKS_API: u32 = 7;
impl GameLevel<'_> {
/// An authoritative server committed a changed state through `Level.setBlock`.
/// The world contains `new`; block side effects and neighbour updates follow.
#[inline(always)]
pub(super) fn native_block_changed(
&self,
dimension: LevelDimension,
pos: BlockPos,
old: BlockStateId,
new: BlockStateId,
) {
let _ = (dimension, pos, old, new);
}
/// An authoritative server is about to deliver `neighborChanged`.
/// State and block IDs are valid in this level's registries for this call.
#[inline(always)]
pub(super) fn native_neighbor_changed(
&self,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
changed: BlockId,
moved: bool,
) {
let _ = (dimension, pos, state, changed, moved);
}
}
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none),
/// eased through the field of view like the spyglass when `smooth`, and the
@@ -284,6 +326,16 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The authoritative server tick's phases, read-only. A paused pass
/// fires nothing; a frozen world carries `world_running` false except
/// during a requested step.
pub(super) fn native_server_tick(
&mut self,
context: &NativeLifecycleContext,
phase: ServerTickPhase,
) {
let _ = (context, phase);
}
/// The HUD (not drawn under F1), after the minimap: place a box with
/// `corners.place` (boxes stack away from their edge) and draw it with
/// `native_fill` and `native_text`. Skip a corner `native_corner_shown`
@@ -692,8 +744,25 @@ mod tests {
.split("/// Helpers for native mods")
.next()
.unwrap();
let level_section = source
.split("impl GameLevel<'_> {")
.nth(1)
.unwrap()
.split("\n}")
.next()
.unwrap();
let level_hooks: BTreeSet<_> = level_section
.lines()
.filter_map(|line| {
line.trim()
.strip_prefix("pub(super) fn native_")?
.split('(')
.next()
})
.collect();
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
.filter_map(|line| {
line.trim()
.strip_prefix("pub(super) fn native_")?
@@ -711,9 +780,14 @@ mod tests {
"invalid or duplicate hook: {name} v{version}"
);
names.insert(name);
let owner = if level_hooks.contains(name) {
"GameLevel"
} else {
"Game"
};
assert_eq!(
hook["target"].as_str().unwrap(),
format!("horizon_client::game::native_hooks::Game<'_>::native_{name}")
format!("horizon_client::game::native_hooks::{owner}<'_>::native_{name}")
);
}
assert_eq!(names, declared);
@@ -1,5 +1,6 @@
//! Read-only world lifecycle observations for native mods on authoritative
//! servers. The deprecated in-frame path delivers no lifecycle events.
//! Read-only lifecycle observations for native mods on authoritative servers.
//! The deprecated in-frame path delivers no lifecycle events. A paused pass
//! observes nothing; a frozen world reports `world_running: false` except during a step.
use std::sync::atomic::{AtomicU64, Ordering};
@@ -22,6 +23,10 @@ pub(super) enum NativeAuthority {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeWorldEpoch(u64);
/// Non-paused authoritative server ticks since open (not game or frame time).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeTickId(u64);
/// Internal state for deciding which events can fire.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OpenState {
@@ -44,14 +49,23 @@ pub(super) enum WorldPhase {
WorldCloseFailed,
}
/// The tick phases carry whether the world runs, including frozen steps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ServerTickPhase {
ServerTickStart { world_running: bool },
ServerTickEnd { world_running: bool },
}
/// Context for every world lifecycle phase.
/// `authority` is the server role. LocalAuthoritative and RemoteClient never
/// fire today. `epoch` identifies the open attempt, including failed opens;
/// the same epoch identifies its ready, final-close, or failed-close event.
/// `tick` counts non-paused server ticks since the world opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeLifecycleContext {
pub(super) authority: NativeAuthority,
pub(super) epoch: NativeWorldEpoch,
pub(super) tick: NativeTickId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -59,6 +73,10 @@ struct OpenAttempt {
authority: NativeAuthority,
epoch: u64,
session: OpenState,
ticks: u64,
/// A tick started and its end has not fired (or the tick errored).
tick_open: bool,
tick_world_running: bool,
}
impl OpenAttempt {
@@ -66,19 +84,30 @@ impl OpenAttempt {
NativeLifecycleContext {
authority: self.authority,
epoch: NativeWorldEpoch(self.epoch),
tick: NativeTickId(self.ticks),
}
}
}
/// The lifecycle bookkeeping: the current attempt's epoch and session phase.
/// It only decides whether a phase fires; every hook body is empty, so with no
/// native mods nothing changes.
/// The lifecycle bookkeeping: the current attempt's epoch, session phase
/// and non-paused tick count. It only decides whether a phase fires; every
/// hook body is empty, so with no native mods nothing changes.
#[derive(Default)]
pub(super) struct NativeLifecycle {
attempt: Option<OpenAttempt>,
read_scope: bool,
}
impl NativeLifecycle {
/// A read is valid only during a tick hook with its current tick context.
pub(super) fn permits_read(&self, context: &NativeLifecycleContext) -> bool {
self.read_scope
&& self
.attempt
.as_ref()
.is_some_and(|a| a.session == OpenState::Ready && a.context() == *context)
}
/// The open starts: a fresh process-wide epoch, never reused.
pub(super) fn begin_open(&mut self, authority: NativeAuthority) {
let epoch = NEXT_EPOCH.fetch_add(1, Ordering::Relaxed);
@@ -86,6 +115,9 @@ impl NativeLifecycle {
authority,
epoch,
session: OpenState::Opening,
ticks: 0,
tick_open: false,
tick_world_running: false,
});
}
@@ -136,6 +168,49 @@ impl NativeLifecycle {
self.attempt = None;
Some(context)
}
/// Non-paused ready ticks only; a paused pass observes nothing, does not count.
pub(super) fn server_tick_start(
&mut self,
world_running: bool,
) -> Option<(NativeLifecycleContext, ServerTickPhase)> {
let attempt = self.attempt.as_mut()?;
if attempt.session != OpenState::Ready {
return None;
}
attempt.ticks += 1;
attempt.tick_open = true;
attempt.tick_world_running = world_running;
Some((
attempt.context(),
ServerTickPhase::ServerTickStart { world_running },
))
}
/// Discard the pending end when a tick fails.
pub(super) fn cancel_server_tick(&mut self) {
if let Some(attempt) = &mut self.attempt {
attempt.tick_open = false;
}
}
/// Pairs the last start; an errored tick never reaches it.
pub(super) fn server_tick_end(
&mut self,
paused: bool,
) -> Option<(NativeLifecycleContext, ServerTickPhase)> {
let attempt = self.attempt.as_mut()?;
if paused || attempt.session != OpenState::Ready || !attempt.tick_open {
return None;
}
attempt.tick_open = false;
Some((
attempt.context(),
ServerTickPhase::ServerTickEnd {
world_running: attempt.tick_world_running,
},
))
}
}
impl Game<'_> {
@@ -185,12 +260,58 @@ impl Game<'_> {
self.native_world_lifecycle(&context, WorldPhase::WorldCloseFinal, "");
}
}
pub(super) fn native_lifecycle_server_tick_start(&mut self, world_running: bool) {
if let Some((context, phase)) = self.native_lifecycle.server_tick_start(world_running) {
self.native_lifecycle.read_scope = true;
self.native_server_tick(&context, phase);
self.native_lifecycle.read_scope = false;
}
}
/// An errored tick returned at `result?` before this site.
pub(super) fn native_lifecycle_server_tick_end(&mut self, paused: bool) {
if let Some((context, phase)) = self.native_lifecycle.server_tick_end(paused) {
self.native_lifecycle.read_scope = true;
self.native_server_tick(&context, phase);
self.native_lifecycle.read_scope = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frozen_steps_are_observed_without_consuming_them() {
let mut lifecycle = opening();
lifecycle.world_open_ready();
let mut clock = horizon_client::clock::FixedStep::default();
clock.set_frozen(true);
clock.set_frozen_ticks_to_run(4);
let mut observed = 0;
for _ in 0..6 {
let running = clock.world_runs();
let (_, start) = lifecycle.server_tick_start(running).unwrap();
let (_, end) = lifecycle.server_tick_end(false).unwrap();
assert_eq!(
start,
ServerTickPhase::ServerTickStart {
world_running: running
}
);
assert_eq!(
end,
ServerTickPhase::ServerTickEnd {
world_running: running
}
);
observed += usize::from(running);
}
assert_eq!(observed, 4);
}
/// A lifecycle mid-open on the server thread.
fn opening() -> NativeLifecycle {
let mut lifecycle = NativeLifecycle::default();
@@ -198,6 +319,27 @@ mod tests {
lifecycle
}
#[test]
fn block_reads_require_the_current_hook_scope() {
let mut life = NativeLifecycle::default();
life.begin_open(NativeAuthority::IntegratedServer);
let ready = life.world_open_ready().unwrap();
assert!(!life.permits_read(&ready));
let (tick, _) = life.server_tick_start(true).unwrap();
life.read_scope = true;
assert!(life.permits_read(&tick));
assert!(!life.permits_read(&ready));
life.read_scope = false;
assert!(!life.permits_read(&tick));
life.server_tick_end(false).unwrap();
life.begin_close();
life.world_close_final().unwrap();
life.begin_open(NativeAuthority::IntegratedServer);
life.world_open_ready().unwrap();
life.read_scope = true;
assert!(!life.permits_read(&tick));
}
#[test]
fn world_ready_fires_once_per_epoch() {
let mut lifecycle = opening();
@@ -213,6 +355,7 @@ mod tests {
let context = lifecycle.world_open_failed().expect("the failure fires");
let failed_epoch = context.epoch;
assert!(lifecycle.world_open_ready().is_none(), "the epoch is gone");
assert!(lifecycle.server_tick_start(true).is_none());
lifecycle.begin_open(NativeAuthority::IntegratedServer);
let context = lifecycle.world_open_ready().expect("the retry opens fresh");
assert!(context.epoch.0 > failed_epoch.0, "a fresh epoch");
@@ -229,6 +372,7 @@ mod tests {
lifecycle.world_close_final().is_none(),
"a repeated close request is a no-op"
);
assert!(lifecycle.server_tick_start(true).is_none());
assert!(lifecycle.world_open_ready().is_none());
}
@@ -261,8 +405,8 @@ mod tests {
.unwrap();
let load = source.find("self.load_world(WorldRequest::new(").unwrap();
let ready = source.find("self.native_lifecycle_world_ready();").unwrap();
let tick_loop = source.find("self.tick_server_timed(paused)?;").unwrap();
assert!(open < load && load < ready && ready < tick_loop);
let tick = source.find("self.tick_server_timed(paused)?;").unwrap();
assert!(open < load && load < ready && ready < tick);
let stop = source.split("fn stop_dedicated").nth(1).unwrap();
let begin = stop.find("self.native_lifecycle.begin_close();").unwrap();
@@ -272,6 +416,33 @@ mod tests {
assert!(begin < save && save < close && close < release);
}
#[test]
fn failed_server_ticks_cancel_before_end() {
let tick = include_str!("levels.rs")
.split("fn tick_server(&mut self, paused: bool)")
.nth(1)
.unwrap()
.split("fn tick_server_timed")
.next()
.unwrap();
let first_cancel = tick
.find("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let result = tick.find("result?;").unwrap();
let close = tick
.find("if let Err(error) = self.close_idle_levels()")
.unwrap();
let second_cancel = tick
.rfind("self.native_lifecycle.cancel_server_tick();")
.unwrap();
let end = tick
.find("self.native_lifecycle_server_tick_end(paused);")
.unwrap();
assert!(
first_cancel < result && result < close && close < second_cancel && second_cancel < end
);
}
#[test]
fn lifecycle_delivery_stays_off_the_deprecated_in_frame_path() {
let game = include_str!("../game.rs")
@@ -0,0 +1,115 @@
//! Read-only block queries for a native server tick hook.
use super::{Dim, Game, native_lifecycle::NativeLifecycleContext};
use horizon_client::world::World;
use horizon_world::{chunk::BlockState, registry::BlockRegistry};
/// Why a server block cannot be read.
#[allow(dead_code)]
#[derive(Debug, PartialEq, Eq)]
pub(super) enum NativeBlockReadError {
/// The context is stale or the call is outside its server tick hook.
InactiveContext,
/// The dimension is unknown or is not currently open on the server.
UnavailableDimension,
OutOfBounds,
Unloaded,
InvalidState,
}
impl Game<'_> {
/// Read a loaded block in a server tick hook, without loading a chunk.
/// The returned name and properties are owned and remain valid after the hook.
#[allow(dead_code)]
pub(super) fn native_server_block(
&self,
context: &NativeLifecycleContext,
dimension: &str,
pos: [i32; 3],
) -> Result<BlockState, NativeBlockReadError> {
if !self.native_lifecycle.permits_read(context) {
return Err(NativeBlockReadError::InactiveContext);
}
let dim = match dimension {
"minecraft:overworld" => Dim::Overworld,
"minecraft:the_nether" => Dim::Nether,
"minecraft:the_end" => Dim::End,
_ => return Err(NativeBlockReadError::UnavailableDimension),
};
let world = if dim == self.dim {
Some(&self.world)
} else {
self.levels.parked_world(dim)
}
.ok_or(NativeBlockReadError::UnavailableDimension)?;
read_block(world, dim, self.pack.assets.registry, pos)
}
}
/// Resolve an existing world state, preserving unloaded and invalid states.
fn read_block(
world: &World,
dimension: Dim,
registry: &BlockRegistry,
[x, y, z]: [i32; 3],
) -> Result<BlockState, NativeBlockReadError> {
if !(-30_000_000..30_000_000).contains(&x)
|| !(-30_000_000..30_000_000).contains(&z)
|| !(dimension.min_y()..=dimension.max_y()).contains(&y)
{
return Err(NativeBlockReadError::OutOfBounds);
}
if !world.loaded(x, z) {
return Err(NativeBlockReadError::Unloaded);
}
registry
.state(world.block(x, y, z))
.cloned()
.ok_or(NativeBlockReadError::InvalidState)
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
#[test]
fn reads_only_loaded_in_bounds_states() {
let facts = br#"{"defaults":{"minecraft:air":0,"test:pillar":1},"states":[
["air",true,false,"","minecraft:air"],
["pillar",false,false,"",{"id":"test:pillar","properties":{"axis":"x"}}]
]}"#;
let registry = BlockRegistry::from_facts_json(&facts[..]).unwrap();
let mut world = World::new(BlockStateId(0), 0);
world.chunks.insert((-1, 0), Arc::new(Chunk::empty(-1, 0)));
world.set(-1, 64, 1, BlockStateId(1)).unwrap();
let block = read_block(&world, Dim::Overworld, &registry, [-1, 64, 1]).unwrap();
assert_eq!(block.name, "test:pillar");
assert_eq!(block.properties["axis"], "x");
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [-1, 65, 1])
.unwrap()
.name,
"minecraft:air"
);
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [0, 64, 1]),
Err(NativeBlockReadError::Unloaded)
);
assert_eq!(
read_block(&world, Dim::Nether, &registry, [-1, -1, 1]),
Err(NativeBlockReadError::OutOfBounds)
);
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [30_000_000, 64, 1]),
Err(NativeBlockReadError::OutOfBounds)
);
world.set(-1, 64, 1, BlockStateId(99)).unwrap();
assert_eq!(
read_block(&world, Dim::Overworld, &registry, [-1, 64, 1]),
Err(NativeBlockReadError::InvalidState)
);
assert_eq!(world.chunks.len(), 1, "reads must not load chunks");
}
}
@@ -234,10 +234,10 @@ impl SpawnLevel for GameLevel<'_, '_> {
}
impl NaturalLevel for GameLevel<'_, '_> {
fn min_y(&self) -> i32 {
horizon_client::chunk::MIN_Y
self.w.world.bottom_y()
}
fn max_y(&self) -> i32 {
horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1
self.w.world.top_y() - 1
}
fn ambient_light(&self) -> f32 {
match self.game.dim {
@@ -356,7 +356,7 @@ impl NaturalLevel for GameLevel<'_, '_> {
spawn_selection::heightmap_blocks(self, kind, x, y, z)
})
})
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
.map_or(self.w.world.bottom_y(), |y| y + 1)
}
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0
@@ -339,7 +339,7 @@ impl Game<'_> {
}) {
continue;
}
let base = horizon_client::chunk::MIN_Y + 16 * s as i32;
let base = self.world.bottom_y() + 16 * s as i32;
for y in base..base + 16 {
for lz in 0..16 {
for lx in 0..16 {
@@ -869,21 +869,18 @@ impl Game<'_> {
reach: horizon_core::geometry::Aabb,
sections: &mut horizon_client::portal_sections::PortalSections,
) -> bool {
use horizon_client::{
chunk::{MIN_Y, SECTIONS},
section_mask::SectionMask,
};
use horizon_client::section_mask::SectionMask;
let (min, max) = (reach.min_position(), reach.max_position());
if !min.is_finite() || !max.is_finite() {
return true;
}
let (min, max) = (BlockPos::containing(min), BlockPos::containing(max));
let top = SECTIONS as i32 - 1;
let top = self.world.layout().sections() as i32 - 1;
let (x0, x1) = (min.x >> 4, max.x >> 4);
let (z0, z1) = (min.z >> 4, max.z >> 4);
let (y0, y1) = (
((min.y - MIN_Y) >> 4).clamp(0, top),
((max.y - MIN_Y) >> 4).clamp(0, top),
((min.y - self.world.bottom_y()) >> 4).clamp(0, top),
((max.y - self.world.bottom_y()) >> 4).clamp(0, top),
);
let count = (i64::from(x1) - i64::from(x0) + 1)
* (i64::from(y1) - i64::from(y0) + 1)
@@ -220,6 +220,12 @@ impl Game<'_> {
Event::SliderDrag => {
self.apply_settings();
}
// Options.createSoundSliderOptionInstance: the preview plays
// only without a level (on the title screen's options).
Event::Volume(source, volume) if self.title.is_some() => {
self.audio.preview(source, volume);
}
Event::Volume(..) => {}
Event::CopyAgentDetails => {
use horizon_client::clipboard::Clipboard;
let text = self.agent_details();
+202 -35
View File
@@ -136,45 +136,94 @@ impl Game<'_> {
/// models and sprites (every section is meshed again,
/// `LevelRenderer.allChanged`), the shared atlas (GUI, item, entity,
/// particle and font textures), item models, sounds, languages, GUI
/// metadata and the cloud and world border textures.
/// metadata and the cloud and world border textures. The loading runs on
/// a worker under the progress overlay (`LoadingOverlay` over
/// `ReloadableResourceManager.createReload`'s background executor), so
/// the window keeps drawing; `drive_pack_reload` applies it.
pub(super) fn reload_packs(&mut self) {
// Pack reload updates tail-owned mob metadata. Keep menus responsive
// until those resources exist, then apply the latest selection once.
if self.rest_loader.is_some() || self.world_loader.is_some() {
// A reload already under its overlay finishes first (vanilla's
// `pendingReload`, which `runTick` starts once the overlay is gone).
if reload_waits(
self.rest_loader.is_some() || self.world_loader.is_some(),
self.pack_reload.is_some(),
) {
self.pending_pack_reload = true;
return;
}
if let Err(e) = self.reload_selected_packs() {
log::warn!("Horizon: resource reload failed: {e}");
if self.screen != super::Screen::Options {
self.open_options_at(
self.title.is_some(),
horizon_client::options_ui::Page::Packs,
);
}
self.ui.options.resource_error = Some(e.to_string());
if let Err(e) = self.spawn_pack_reload(ReloadKind::Selection) {
self.selection_reload_failed(&format!("could not start the pack reload: {e}"));
}
}
/// The CPU half of the reload, safe on a worker (`PackReload` runs it):
/// `apply_staged_pack` finishes the GPU half and the swap.
pub(super) fn stage_selected_packs(&mut self) -> horizon_client::Result<StagedPack> {
/// `reloadResourcePacks`' failure: the pack screen again, with the error.
fn selection_reload_failed(&mut self, error: &str) {
log::warn!("Horizon: resource reload failed: {error}");
if self.screen != super::Screen::Options {
self.open_options_at(
self.title.is_some(),
horizon_client::options_ui::Page::Packs,
);
}
self.ui.options.resource_error = Some(error.to_string());
}
/// Stages the selection with the server's active packs on top on a
/// worker (`createReload`'s background executor); the frame draws its
/// progress meanwhile, and the overlay takes no input (`LoadingOverlay`),
/// so what is held is released (`KeyMapping.releaseAll`).
pub(super) fn spawn_pack_reload(&mut self, kind: ReloadKind) -> std::io::Result<()> {
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
stage_pack(
&vanilla,
&self.settings.resource_packs,
&self.server_packs.active,
&self.settings.mods.enabled,
&self.options.dev_mods,
&self.pack.assets,
&self.res,
&self.pack.context,
self.settings.force_unicode,
self.settings.japanese_glyphs,
&super::Progress::new(),
)
let (selected, server, mods, dev_mods) = (
self.settings.resource_packs.clone(),
self.server_packs.active.clone(),
self.settings.mods.enabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
self.pack.assets.clone(),
self.res.clone(),
self.pack.context.clone(),
);
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
self.pack_reload = Some(PackReload::spawn(kind, move |progress| {
stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode,
japanese, progress,
)
.map_err(|e| e.to_string())
})?);
Ok(())
}
/// `LoadingOverlay.tick` once the reload is done: the staged pack is
/// applied on the render thread, and its outcome goes to whoever asked
/// (`reloadResourcePacks`' callback, or `PackReloadConfig.Callbacks`).
pub(super) fn drive_pack_reload(&mut self) {
let Some((kind, staged)) = self.pack_reload.as_mut().and_then(PackReload::finished) else {
return;
};
self.pack_reload = None;
let result =
staged.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()));
match kind {
ReloadKind::Selection => {
if let Err(e) = result {
self.selection_reload_failed(&e);
}
}
ReloadKind::Server => {
if let Err(e) = &result {
log::warn!("Horizon: server resource pack reload failed: {e}");
}
self.finish_pack_reload(result.is_ok());
}
}
}
/// The render-thread half: the renderer on the new pack's textures, then
/// the swap (`ReloadInstance`'s application listeners).
@@ -257,16 +306,10 @@ impl Game<'_> {
self.creative_screen.forget_items();
Ok(())
}
/// `Minecraft.reloadResourcePacks`: the reload staged on the spot, then
/// applied (`PackReload` runs the same two phases across frames).
pub(super) fn reload_selected_packs(&mut self) -> horizon_client::Result<()> {
let staged = self.stage_selected_packs()?;
self.apply_staged_pack(staged)
}
}
/// The CPU half of `reload_selected_packs` (`PackReload` runs it on a
/// worker; `apply_staged_pack` does the GPU half and the swap): the overlay
/// The CPU half of a pack reload (`PackReload` runs it on a worker;
/// `apply_staged_pack` does the GPU half and the swap): the overlay
/// root, the models and sprites, the menu bundle, the pack's tables and its
/// CPU mip chains.
pub(super) fn stage_pack(
@@ -320,3 +363,127 @@ pub(super) struct StagedPack {
pub pack: PackResources,
pub textures: PreparedTextures,
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
"Loading assets",
"Loading pack tables",
"Applying resources",
];
/// Whether a reload asked for now waits: for the world's resources, or for
/// the reload already under its overlay (`Minecraft.reloadResourcePacks`
/// hands back `pendingReload` while a `LoadingOverlay` is up).
fn reload_waits(world_loading: bool, in_flight: bool) -> bool {
world_loading || in_flight
}
/// Who asked for a pack reload: its outcome goes back there.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ReloadKind {
/// The player's own selection (`Minecraft.reloadResourcePacks`).
Selection,
/// A server's packs (`DownloadedPackSource.startReload`).
Server,
}
type StageResult = std::result::Result<StagedPack, String>;
/// A pack reload whose CPU half runs on a worker while `progress` feeds the
/// loading overlay; `Game::drive_pack_reload` applies it on the render thread.
pub(super) struct PackReload {
pub(super) progress: super::Progress,
kind: ReloadKind,
handle: Option<std::thread::JoinHandle<StageResult>>,
}
impl PackReload {
/// Starts `stage` on its own thread and returns at once.
fn spawn(
kind: ReloadKind,
stage: impl FnOnce(&super::Progress) -> StageResult + Send + 'static,
) -> std::io::Result<Self> {
let progress = super::Progress::with(&RELOAD_LINES);
let worker = progress.clone();
let handle = std::thread::Builder::new()
.name("pack-reload".into())
.spawn(move || stage(&worker))?;
Ok(Self {
progress,
kind,
handle: Some(handle),
})
}
pub(super) fn kind(&self) -> ReloadKind {
self.kind
}
/// The staged pack once the worker is done (taken once); none while it runs.
fn finished(&mut self) -> Option<(ReloadKind, StageResult)> {
if !self.handle.as_ref()?.is_finished() {
return None;
}
let staged = self
.handle
.take()?
.join()
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
Some((self.kind, staged))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::{sync::mpsc, time::Duration};
fn wait(reload: &mut PackReload) -> (ReloadKind, StageResult) {
for _ in 0..500 {
if let Some(done) = reload.finished() {
return done;
}
std::thread::sleep(Duration::from_millis(10));
}
panic!("the reload worker never finished");
}
#[test]
fn a_reload_stages_on_a_worker_while_the_caller_goes_on() {
// The stage blocks until the caller lets it go: `spawn` returning at
// all means the window thread is free to draw the overlay.
let (release, blocked) = mpsc::channel::<()>();
let (thread, ran_on) = mpsc::channel();
let mut reload = PackReload::spawn(ReloadKind::Selection, move |progress| {
blocked.recv().unwrap();
progress.set(2);
thread
.send(std::thread::current().name().map(str::to_owned))
.unwrap();
Err("staged".into())
})
.unwrap();
assert!(reload.finished().is_none(), "still staging");
assert_eq!(reload.progress.line(), "Preparing resources");
release.send(()).unwrap();
let (kind, staged) = wait(&mut reload);
assert_eq!(kind, ReloadKind::Selection);
assert_eq!(staged.err().as_deref(), Some("staged"));
assert_eq!(reload.progress.line(), "Loading pack tables");
assert_eq!(ran_on.recv().unwrap().as_deref(), Some("pack-reload"));
assert!(reload.finished().is_none(), "the outcome is taken once");
}
#[test]
fn a_panicking_stage_fails_the_reload() {
let mut reload = PackReload::spawn(ReloadKind::Server, |_| panic!("bad pack")).unwrap();
let (kind, staged) = wait(&mut reload);
assert_eq!(kind, ReloadKind::Server);
assert_eq!(
staged.err().as_deref(),
Some("the pack reload worker panicked")
);
}
#[test]
fn a_reload_waits_for_world_resources_and_the_one_in_flight() {
assert!(!reload_waits(false, false));
assert!(reload_waits(true, false));
assert!(reload_waits(false, true));
assert!(reload_waits(true, true));
}
}
+6 -1
View File
@@ -255,6 +255,8 @@ impl Game<'_> {
| "minecraft:arrow"
| "minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:wind_charge"
| "minecraft:breeze_wind_charge"
| "minecraft:falling_block"
| "minecraft:end_crystal"
| "minecraft:ender_dragon"
@@ -294,7 +296,10 @@ impl Game<'_> {
self.add_projectile(p);
}
}
"minecraft:fireball" | "minecraft:small_fireball" => {
"minecraft:fireball"
| "minecraft:small_fireball"
| "minecraft:wind_charge"
| "minecraft:breeze_wind_charge" => {
if let Some(p) = entity_nbt::fireball_from_nbt(&tag, player_uuid) {
self.add_projectile(p);
}
+96 -56
View File
@@ -123,15 +123,18 @@ impl CameraType {
}
}
}
/// `Entity.isInvisible` for the window's player: shared flag 5 as its server
/// sets it (`shared_flag`), with the Invisibility effect or by a plugin's
/// `setInvisible` alone, as pose plugins hide the real body under the pose
/// (#3788); a world in the frame has only the effect.
fn own_invisible(effects: &horizon_client::effects::PlayerEffects, shared_flag: bool) -> bool {
shared_flag || effects.get("invisibility").is_some()
}
/// `LivingEntityRenderer.isBodyVisible` and `isInvisibleTo` for the player's
/// own body, seen by itself: the Invisibility effect (`Entity.isInvisible`)
/// hides it unless its team sees friendly invisibles, where vanilla draws it
/// own body, seen by itself: an invisible player (`own_invisible`) hides it
/// unless its team sees friendly invisibles, where vanilla draws it
/// translucent.
fn body_visible(
effects: &horizon_client::effects::PlayerEffects,
team: Option<&horizon_client::scoreboard::Team>,
) -> bool {
let invisible = effects.get("invisibility").is_some();
fn body_visible(invisible: bool, team: Option<&horizon_client::scoreboard::Team>) -> bool {
!horizon_client::scoreboard::invisible_to(team, team, false, invisible)
}
/// The player's model type (`PlayerModel` with the default skin).
@@ -213,6 +216,60 @@ impl Game<'_> {
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
// Minecraft.cameraEntity: a server's spectator camera on a mob or
// another player.
let (entity, descending) = match self.camera_mob() {
Some(m) => (super::remote_camera::mob_camera_entity(m), false),
None => (
self.player_camera_entity()?,
self.player.as_ref()?.shift_key_down,
),
};
let mut view = self.view;
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
};
let context = CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
y: entity.position[1],
descending,
main_hand: Some(CollisionItem::EMPTY),
},
false,
);
let mut rays = VisualRays {
scene: SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
},
facts: self.res.facts(),
context,
};
view.align_with_entity(&entity, partial, kind, |from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
});
self.view = view;
Some((
glam::DVec3::from_array(view.position),
view.y_rot,
view.x_rot,
))
}
/// The player as `Camera.alignWithEntity` sees it.
fn player_camera_entity(&mut self) -> Option<CameraEntity> {
let (scale, distance) = {
let p = self.player.as_mut()?;
(
@@ -282,48 +339,7 @@ impl Game<'_> {
}),
mount,
};
let mut view = self.view;
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
};
let context = CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
y: p.position.y,
descending: p.shift_key_down,
main_hand: Some(CollisionItem::EMPTY),
},
false,
);
let mut rays = VisualRays {
scene: SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
},
facts: self.res.facts(),
context,
};
view.align_with_entity(&entity, partial, kind, |from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
});
self.view = view;
Some((
glam::DVec3::from_array(view.position),
view.y_rot,
view.x_rot,
))
Some(entity)
}
/// Keep the player's model in step with the player (`LivingEntity.tick`
/// body turning, `WalkAnimationState`, the swing and the pose).
@@ -523,9 +539,16 @@ impl Game<'_> {
None => self.player_team(),
}
}
/// `Entity.isInvisible` for the player itself (`own_invisible`).
pub(super) fn own_invisible(&self) -> bool {
own_invisible(
&self.potions.effects,
self.remote.as_ref().is_some_and(|r| r.own_invisible),
)
}
/// Whether the player's own body is drawn (`body_visible`).
pub(super) fn own_body_visible(&self) -> bool {
body_visible(&self.potions.effects, self.own_team())
body_visible(self.own_invisible(), self.own_team())
}
/// The player's model for the entity pass in third person.
pub(super) fn player_model_geometry(
@@ -567,7 +590,7 @@ impl Game<'_> {
);
let chest = self.armor[1].as_ref().map(|s| &*s.item);
// CapeLayer: no cape on an invisible player (`isInvisible`, whoever looks).
if self.potions.effects.get("invisibility").is_none() {
if !self.own_invisible() {
self.player_cape_geometry(m, chest, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
@@ -658,7 +681,7 @@ impl Game<'_> {
&mut verts,
);
let chest = self.armor[1].as_ref().map(|s| &*s.item);
if self.potions.effects.get("invisibility").is_none() {
if !self.own_invisible() {
self.player_cape_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
}
self.player_elytra_geometry(&m, chest, camera, 1.0, 240 | 240 << 8, &mut verts);
@@ -1115,15 +1138,32 @@ mod preview_tests {
.unwrap()
.id;
let mut effects = horizon_client::effects::PlayerEffects::default();
assert!(body_visible(&effects, None));
assert!(body_visible(own_invisible(&effects, false), None));
effects.add(EffectInstance::new(id, 3600, 0));
assert!(!body_visible(&effects, None), "no team: the body is hidden");
let invisible = own_invisible(&effects, false);
assert!(
!body_visible(invisible, None),
"no team: the body is hidden"
);
// Entity.isInvisibleTo: a team that sees friendly invisibles sees it.
let mut board = Scoreboard::default();
let team = board.add_team("blue");
assert!(body_visible(&effects, Some(team)));
assert!(body_visible(invisible, Some(team)));
team.see_friendly_invisibles = false;
assert!(!body_visible(&effects, Some(team)));
assert!(!body_visible(invisible, Some(team)));
}
/// #3788: a pose plugin's `Player.setInvisible(true)` sets only the
/// shared flag (no effect); `Entity.isInvisible` reads that flag, so the
/// real body under the pose is not drawn.
#[test]
fn the_servers_invisible_flag_hides_the_own_body_without_the_effect() {
let effects = horizon_client::effects::PlayerEffects::default();
assert!(own_invisible(&effects, true));
assert!(!body_visible(own_invisible(&effects, true), None));
let mut board = horizon_client::scoreboard::Scoreboard::default();
let team = board.add_team("blue");
assert!(body_visible(own_invisible(&effects, true), Some(team)));
}
/// LivingEntityRenderer.submit: an invisible body is not drawn, its
+2 -2
View File
@@ -226,10 +226,10 @@ impl Game<'_> {
/// highest non-air one.
pub(super) fn surface_height(&self, x: i32, z: i32) -> i32 {
let top = if self.dim == Dim::Nether { 127 } else { 319 };
(horizon_client::chunk::MIN_Y..=top)
(self.world.bottom_y()..=top)
.rev()
.find(|&y| !self.res.facts().is_air(self.world.block(x, y, z)))
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
.map_or(self.world.bottom_y(), |y| y + 1)
}
/// Every claimed village point of interest (job sites, beds, bells).
fn village_pois(&self) -> Vec<[i32; 3]> {
+201 -15
View File
@@ -122,6 +122,9 @@ pub(super) struct Remote {
pub(super) logged_in: bool,
status: String,
pub(super) entity_id: i32,
/// `Minecraft.cameraEntity` when the server put the view on another
/// entity (ClientboundSetCameraPacket); None while it is the player.
pub(super) camera: Option<i32>,
/// The item the player's use began with (`LivingEntity.useItem`).
pub(super) use_item: Option<Arc<str>>,
pub(super) dimension: DimensionType,
@@ -166,6 +169,9 @@ pub(super) struct Remote {
/// The happy ghast the player just got off, until the server's teleport
/// puts the player on top of it (`place_ghast_under`).
pub(super) ghast_dismount: Option<u32>,
/// Whether the player is the first passenger of its vehicle, the one
/// `getControllingPassenger` names.
pub(super) first_passenger: bool,
/// Item entities waiting for their item data: where they are and their
/// motion.
pending_items: HashMap<i32, ([f64; 3], Vec3)>,
@@ -183,6 +189,9 @@ pub(super) struct Remote {
chat_previous: Option<Instant>,
/// The server's command tree (ClientboundCommandsPacket).
pub commands: Option<horizon_client::command::Dispatcher>,
/// `ClientSuggestionProvider.customCompletionSuggestions`: the extra chat
/// completions this server sent (ClientboundCustomChatCompletionsPacket).
custom_completions: std::collections::BTreeSet<String>,
players: HashMap<u128, String>,
/// `ClientPacketListener.playerInfoMap` as player-list entries, and
/// which of them are listed.
@@ -206,6 +215,9 @@ pub(super) struct Remote {
/// Entities with shared flag 6 (`Entity.isCurrentlyGlowing` on a client); mobs keep
/// theirs in `Mob::glowing`.
glowing: std::collections::HashSet<i32>,
/// The player's own shared flag 5 (`Entity.isInvisible`) as the server
/// last set it.
pub(super) own_invisible: bool,
/// The local player's profile UUID (always in the level).
profile: u128,
/// The local player's profile name: its score holder and team member.
@@ -219,6 +231,9 @@ pub(super) struct Remote {
/// inventory): like the click queue, they wait for the tick's
/// `handleKeybinds` phase rather than going out between window events.
pub(super) pending_keys: Vec<KeyCode>,
/// Whether the main and off hand still wait for the server's answer to
/// a SWAP_ITEM_WITH_OFFHAND (`remote_hand_slot_set`).
swap_answer: [bool; 2],
stop_mining: bool,
placing: Option<([i32; 3], u64)>,
/// `CommonPlayerSpawnInfo.isFlat`: the level is a superflat one.
@@ -250,6 +265,9 @@ pub(super) struct Remote {
/// `ClientCommonPacketListenerImpl.serverLinks`: the links "Server
/// Links..." in the pause menu lists (only `http`/`https` URIs kept).
pub(super) server_links: Vec<horizon_protocol::packets::configuration::ServerLink>,
/// `ClientPacketListener.getDebugQueryHandler`: the F3+I query waiting
/// for the server's data.
pub(super) debug_query: super::debug_keys::DebugQuery,
}
/// The time a frame spends on chunk packets before it leaves the rest of
@@ -483,6 +501,7 @@ impl Game<'_> {
logged_in: false,
status: "Connecting to the server...".into(),
entity_id: 0,
camera: None,
use_item: None,
dimension: DimensionType::vanilla("minecraft:overworld"),
chunk_format: Arc::new(ChunkFormat::new(
@@ -510,6 +529,7 @@ impl Game<'_> {
stepped: HashMap::new(),
head_lerps: HashMap::new(),
ghast_dismount: None,
first_passenger: false,
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
flash_on_set_health: false,
@@ -519,6 +539,7 @@ impl Game<'_> {
chat_queue: VecDeque::new(),
chat_previous: None,
commands: None,
custom_completions: Default::default(),
players: HashMap::new(),
tab_players: HashMap::new(),
skin_textures: HashMap::new(),
@@ -529,6 +550,7 @@ impl Game<'_> {
level_player_names: HashMap::new(),
level_player_textures: HashMap::new(),
glowing: Default::default(),
own_invisible: false,
profile: 0,
profile_name: String::new(),
destroying: None,
@@ -536,6 +558,7 @@ impl Game<'_> {
destroy_delay: 0,
input: Default::default(),
pending_keys: Vec::new(),
swap_answer: [false; 2],
stop_mining: false,
placing: None,
flat: false,
@@ -552,6 +575,7 @@ impl Game<'_> {
backlog: VecDeque::new(),
backlog_since: None,
server_links: Vec::new(),
debug_query: Default::default(),
transfer: None,
});
// The level's TickRateManager follows the server's ticking packets.
@@ -909,6 +933,14 @@ impl Game<'_> {
)
}
/// `ClientSuggestionProvider.customCompletionSuggestions` of the server
/// connection (none without one).
pub(super) fn remote_custom_completions(&self) -> std::collections::BTreeSet<String> {
self.remote
.as_ref()
.map(|r| r.custom_completions.clone())
.unwrap_or_default()
}
/// The names in the server's player list, sorted.
pub(super) fn remote_online_names(&self) -> Option<Vec<String>> {
let r = self.remote.as_ref()?;
@@ -1050,14 +1082,18 @@ impl Game<'_> {
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard.
// ClientPacketListener starts with no players or scoreboard,
// and its new ClientSuggestionProvider with no custom
// chat completions.
self.hud_disconnected();
self.score.board = Default::default();
if let Some(r) = self.remote.as_mut() {
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
@@ -1206,6 +1242,7 @@ impl Game<'_> {
return;
};
let dimension = r.registries.dimension(spawn.dimension_type);
let shape = super::Dim::from_id(&dimension.name).shape();
let same = r.dimension.name == dimension.name && keep_world;
r.chunk_format = Arc::new(r.chunk_format.with_dimension(&dimension));
r.dimension = dimension;
@@ -1243,6 +1280,7 @@ impl Game<'_> {
let count = old.len();
horizon_client::chunk::free_columns(old, count);
self.world = World::new(self.res.facts().air, spawn.seed);
self.world.shape = shape;
self.world.game_time = time;
self.world.day_time = day;
self.world.weather = weather;
@@ -1390,15 +1428,6 @@ impl Game<'_> {
self.command_block_data_arrived(key);
}
}
// `handleBlockEntityData`: a command block's data arriving while its
// edit screen is open refreshes the screen.
if self
.command_block_screen
.as_ref()
.is_some_and(|s| s.pos == key)
{
self.update_command_block_screen();
}
}
/// `handleAnimate`: a player wakes up, or an entity gets the critical
/// hit (`CRIT`) or magic critical hit (`ENCHANTED_HIT`) emitter.
@@ -1621,6 +1650,7 @@ impl Game<'_> {
// copy in the mob list draws nothing: given the move, it kept the
// dragon where it spawned (#2083).
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
let mob = self
.mobs
.iter_mut()
@@ -1644,6 +1674,13 @@ impl Game<'_> {
if let Some(t) = r.entities.get_mut(&id) {
t.position = position;
}
if driven {
// handleMoveEntity, handleEntityPositionSync: the mount this
// client moves (isLocalInstanceAuthoritative) keeps its own
// position, and no steps pile up for after the ride.
r.stepped.remove(&id);
return;
}
if let Some(m) = mob {
// A living entity: LivingEntity.createInterpolationHandler's
// SteppedInterpolationHandler, over its type's update interval.
@@ -1936,7 +1973,14 @@ impl Game<'_> {
if internal == 200 {
return; // the result slot is recomputed from the grid
}
*self.slot_mut(internal) = item;
let slot = self.slot_mut(internal);
let changed = !super::hand::matches_for_swap(slot.as_ref(), item.as_ref());
*slot = item;
if internal < 36 {
self.remote_hand_slot_set(internal, changed);
} else if internal == 504 {
self.remote_hand_slot_set(super::shield::OFFHAND_SLOT, changed);
}
if internal >= super::stations::INPUT && self.station.is_some() {
self.remote_station_slot_changed(internal);
}
@@ -1962,9 +2006,27 @@ impl Game<'_> {
)
};
let (max_health, scale) = (value("minecraft:max_health"), value("minecraft:scale"));
let (speed, jump) = (
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
// its own height (`getSpeed`, `getCustomJump`).
if horizon_client::mobs::equine::moved_by_rider(m.species()) {
if let Some(speed) = speed {
horizon_client::mobs::finalize::set_base(
m,
horizon_client::mobs::finalize::Attr::MovementSpeed,
speed,
);
}
if let Some(jump) = jump {
m.extra.vector[0] = jump;
}
}
if let Some(max_health) = max_health {
horizon_client::mobs::finalize::set_base(
m,
@@ -1995,8 +2057,11 @@ impl Game<'_> {
self.potions.effects.clear();
if let Some(r) = self.remote.as_mut() {
r.carried = Some(0);
r.own_invisible = false;
r.online_mode = p.online_mode;
r.entity_id = p.player_id;
// handleLogin: the new LocalPlayer is the camera.
r.camera = None;
r.logged_in = true;
r.view_distance = p.chunk_radius;
r.show_death_screen = p.show_death_screen;
@@ -2022,6 +2087,11 @@ impl Game<'_> {
// passengers. A dimension change keeps the vehicle it travels in.
if p.keep & 2 == 0 {
self.riding = None;
// KEEP_ENTITY_DATA copies the old player's synched data
// (its shared flags) to the new one; without it they reset.
if let Some(r) = self.remote.as_mut() {
r.own_invisible = false;
}
}
let dimension_changed = self.dim != Dim::from_id(&p.spawn.dimension);
self.enter_remote_dimension(&p.spawn, true);
@@ -2033,6 +2103,8 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.death_message = None;
r.health = (20.0, 20, 5.0);
// handleRespawn: the new LocalPlayer is the camera.
r.camera = None;
}
self.capture(true);
}
@@ -2052,6 +2124,7 @@ impl Game<'_> {
self.snap_player_model_pitch();
}
C::PlayerLookAt(p) => self.remote_look_at(&p),
C::SetCamera(p) => self.remote_set_camera(p.camera_id),
C::LevelChunkWithLight(p) => self.remote_chunk(&p, None),
C::LightUpdate(p) => self.remote_light_update(&p),
C::ForgetLevelChunk(p) => {
@@ -2270,6 +2343,11 @@ impl Game<'_> {
p.on_ground,
false,
);
// handleTeleportEntity: the mount this client moves is put
// there at once.
if self.driven_mount().is_some_and(|m| m.id as i32 == p.id) {
self.remote_move_vehicle(position, p.yaw);
}
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
@@ -2416,10 +2494,16 @@ impl Game<'_> {
// server stops the sprint after a hit's extra
// knockback (the flag only, as assignValues sets
// it: the speed modifier and wasSprinting stay).
// Its invisible bit (Entity.isInvisible): the
// Invisibility effect's, or a plugin's that hides
// the real body under a pose (#3788).
(0, Value::Byte(flags)) => {
if let Some(pl) = self.player.as_mut() {
pl.sprinting = flags & 0x08 != 0;
}
if let Some(r) = self.remote.as_mut() {
r.own_invisible = flags & 0x20 != 0;
}
}
// Entity.DATA_AIR_SUPPLY_ID and
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
@@ -2670,6 +2754,12 @@ impl Game<'_> {
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
// LivingEntity.handleEntityEvent 47 to 52, 65 and 68: an
// equipped item broke (onEquippedItemBroken).
if let Some(slot) = horizon_client::mobs::item_break::equipment_break_slot(p.event)
{
self.remote_equipment_break(p.id, slot);
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
@@ -2988,6 +3078,15 @@ impl Game<'_> {
let stack = self.to_stack(&p.item);
self.tutorial_get_item(stack.as_ref());
let before = self.creative_stacks();
let held = match p.slot {
0..=35 => self.inventory[p.slot as usize].as_ref(),
40 => self.offhand.as_ref(),
_ => None,
};
let changed = !super::hand::matches_for_swap(held, stack.as_ref());
if let Ok(slot) = usize::try_from(p.slot) {
self.remote_hand_slot_set(slot, changed);
}
match p.slot {
0..=35 => self.inventory[p.slot as usize] = stack,
36..=39 => self.armor[3 - (p.slot - 36) as usize] = stack,
@@ -3048,11 +3147,23 @@ impl Game<'_> {
self.capture(true);
}
}
C::TagQuery(p) => self.answer_tag_query(p.transaction, p.tag),
C::Unknown(id, data)
if id == horizon_protocol::ids::play::clientbound::COMMAND_SUGGESTIONS =>
{
self.handle_command_suggestions(&data);
}
C::CustomChatCompletions(p) => {
// ClientPacketListener.handleCustomChatCompletions.
if let Some(r) = self.remote.as_mut() {
horizon_client::command::modify_custom_completions(
&mut r.custom_completions,
horizon_client::command::CustomCompletionAction::from_id(p.action),
&p.entries,
);
self.ui.registries.custom_completions = r.custom_completions.clone();
}
}
C::Commands(p) => {
let tree = horizon_client::command::network::from_packet(
&p,
@@ -4164,6 +4275,14 @@ impl Game<'_> {
| u8::from(keys.jump) << 4
| u8::from(keys.shift) << 5
| u8::from(keys.sprint) << 6;
// LocalPlayer.applyInput and aiStep: watching through another
// entity, the player does not move itself; its keys still reach the
// server, whose sneak check lets go of the camera.
let keys = if self.controls_camera() {
keys
} else {
Keys::default()
};
// A passenger moves with its vehicle (Entity.rideTick), not by itself.
let riding = self.screen != Screen::Dead && self.remote_ride(keys);
// The server sends no passenger's position: they sit where their
@@ -4551,6 +4670,9 @@ impl Game<'_> {
} else {
Vec::new()
};
// The mount this client moves ticks in `remote_ride` instead, as its
// authoritative side (LivingEntity.tick with travelRidden).
let driven = self.driven_mount().map(|m| m.id);
let Some(r) = self.remote.as_mut() else {
return;
};
@@ -4562,7 +4684,7 @@ impl Game<'_> {
let mut mob_fx = Vec::new();
for m in &mut self.mobs {
let player = players.contains_key(&(m.id as i32));
if !running && !player {
if (!running && !player) || driven == Some(m.id) {
continue;
}
m.previous = m.position;
@@ -4859,6 +4981,7 @@ impl Game<'_> {
);
let (yaw, pitch, selected) = (self.yaw, self.pitch, self.selected);
let riding = self.riding.is_some();
let controls_camera = self.controls_camera();
let vehicle = self.remote_vehicle_packets();
let Some(r) = self.remote.as_mut() else {
return;
@@ -4891,9 +5014,12 @@ impl Game<'_> {
flags: u8::from(on_ground) | u8::from(collision) << 1,
}));
out.extend(vehicle);
} else if let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
} else if controls_camera
// LocalPlayer.sendPosition: no move while the view is another
// entity's (the server keeps the player on it).
&& let Some(packet) =
r.position_sender
.update(pos, (yaw, pitch), on_ground, collision)
{
out.push(packet);
}
@@ -5043,6 +5169,9 @@ impl Game<'_> {
sequence: seq,
}));
self.tutorial_destroy(state, 0.0);
// startDestroyBlock: BlockState.attack on the client, so a redstone
// ore's dust (RedStoneOreBlock.attack) comes before the break.
self.redstone_ore_particles(hit.pos);
if progress >= 1.0 {
// startDestroyBlock: instant survival breaks have no destroy delay.
if !debug_stick {
@@ -5296,6 +5425,24 @@ impl Game<'_> {
(result, changes)
};
let reacts = result.consumes_action();
// useItemOn on this client, unless a sneak with something in hand
// skips it: RedStoneOreBlock's dust, and the wick smoke of a lit
// candle an empty hand snuffs (CandleBlock.useItemOn).
if hand == Hand::Main && !spectator {
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
let hands_full = self.stack_in(self.selected).is_some() || self.offhand.is_some();
if !(sneaking && hands_full) {
self.redstone_ore_particles(hit.pos);
let state = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let lit_candle = self.block_name(hit.pos).ends_with("candle")
&& self.pack.assets.registry.state(state).is_some_and(|s| {
s.properties.get("lit").map(String::as_str) == Some("true")
});
if held.is_none() && self.may_build() && lit_candle {
self.candle_smoke(hit.pos, state);
}
}
}
// ArmorStandItem.useOn runs in startPrediction before the packet
// goes out (the server may spawn the stand at once).
let stand_fits = held
@@ -5334,6 +5481,10 @@ impl Game<'_> {
}
if reacts {
self.predict_use(&changes, seq);
// LeverBlock.useWithoutItem on the client: the predicted pull puffs.
if &*self.block_name(hit.pos) == "minecraft:lever" {
self.lever_puff(hit.pos);
}
self.open_command_block_if_editable((hit.pos[0], hit.pos[1], hit.pos[2]));
// MultiPlayerGameMode.useItemOn: SUCCESS SwingSource.CLIENT
// (a chest, barrel, furnace, …). CONSUME does not swing.
@@ -6108,6 +6259,11 @@ impl Game<'_> {
return false;
}
if self.spectator() {
// Minecraft.startAttack: a spectator's click on an entity asks
// to watch through it (MultiPlayerGameMode.spectate); a click on
// nothing still tells the server (spectatorNoAction).
let target = self.spectator_click_target();
self.send(S::SpectatorAction(play::SpectatorAction { target }));
return true;
}
// A spear jabs (`MultiPlayerGameMode.piercingAttack`): the server
@@ -6258,6 +6414,7 @@ impl Game<'_> {
self.recipe_ui.reset_screen();
if self.creative {
self.screen = Screen::Creative;
self.refresh_creative_tabs();
self.creative_screen.reopen();
self.refresh_saved_hotbars();
} else {
@@ -6324,6 +6481,35 @@ impl Game<'_> {
direction: 0,
sequence: 0,
}));
// Equal stacks swap with no slot update to wait for.
let (main, off) = (self.stack_in(self.selected), self.offhand.as_ref());
let moves = !super::hand::matches_for_swap(main, off);
if let Some(r) = self.remote.as_mut() {
r.swap_answer = [moves; 2];
}
}
/// The server set inventory `slot` (`shield::OFFHAND_SLOT` for the off
/// hand), `changed` from the stack this client held there. The stack
/// that answers a swap (`setItemInHand`) is another object, which the
/// hand shows after the swap animation (`hand_stack_replaced`); other
/// updates of a hand's slot count as the held stack changing in place,
/// as this client doesn't predict every use the way vanilla's does
/// (a thrown snowball's stack shrinks only when the server says so).
fn remote_hand_slot_set(&mut self, slot: usize, changed: bool) {
let hand = if slot == super::shield::OFFHAND_SLOT {
1
} else if slot == self.selected {
0
} else {
return;
};
let answer = self
.remote
.as_mut()
.is_some_and(|r| std::mem::take(&mut r.swap_answer[hand]));
if answer && changed {
self.hand_stack_replaced(slot);
}
}
/// A container click: the server answers with the authoritative slots.
pub(super) fn remote_click(&mut self, right: bool) {
@@ -0,0 +1,108 @@
//! A server's spectator camera (`ClientPacketListener.handleSetCamera`,
//! `Minecraft.cameraEntity`): `/spectate` or a spectator's click (sent as
//! `MultiPlayerGameMode.spectate`) attaches the view to another entity. The
//! view follows that entity while the player stands still and sends no moves
//! (`LocalPlayer.isControlledCamera`); the server keeps its player on the
//! camera and lets go when the player sneaks.
use super::Game;
use horizon_client::{
camera::{CameraEntity, LivingCamera},
mobs::Mob,
};
/// `Minecraft.setCameraEntity` from a ClientboundSetCameraPacket: the player's
/// own id puts the view back on the player; an id the level does not hold
/// changes nothing (`ClientboundSetCameraPacket.getEntity` is null).
pub(super) fn camera_after_packet(
current: Option<i32>,
player_id: i32,
camera_id: i32,
in_level: bool,
) -> Option<i32> {
if camera_id == player_id {
None
} else if in_level {
Some(camera_id)
} else {
current
}
}
/// `Camera.alignWithEntity`'s view of a mob or another player: its
/// interpolated position, `getViewYRot`/`getViewXRot` and eye height, with
/// its scale and camera distance for a detached view. A living entity's
/// `getViewYRot` is its head's yaw (`yHeadRot`), not its body's `yRot`.
pub(super) fn mob_camera_entity(m: &Mob) -> CameraEntity {
let attribute =
|name: &str, default: f64| m.kind.attributes.get(name).copied().unwrap_or(default);
CameraEntity {
position: [m.position.x, m.position.y, m.position.z],
previous: [m.previous.x, m.previous.y, m.previous.z],
x_rot: m.head_pitch,
y_rot: m.head_yaw,
x_rot_o: m.previous_head_pitch,
y_rot_o: m.previous_head_yaw,
eye_height: m.eye_height() as f32,
living: Some(LivingCamera {
scale: attribute("minecraft:scale", 1.0) as f32,
camera_distance: attribute("minecraft:camera_distance", 4.0),
sleeping: None,
dead: None,
}),
mount: None,
}
}
impl Game<'_> {
/// `ClientPacketListener.handleSetCamera`.
pub(super) fn remote_set_camera(&mut self, camera_id: i32) {
let in_level = self.mobs.iter().any(|m| m.id == camera_id as u32);
if let Some(r) = self.remote.as_mut() {
r.camera = camera_after_packet(r.camera, r.entity_id, camera_id, in_level);
}
}
/// The entity a server attached the view to, when it is not the player.
pub(super) fn remote_camera_id(&self) -> Option<u32> {
self.remote.as_ref()?.camera.map(|id| id as u32)
}
/// `LocalPlayer.isControlledCamera`: the view is the player's own.
pub(super) fn controls_camera(&self) -> bool {
self.remote_camera_id().is_none()
}
/// The mob the view follows, while the level still holds it.
pub(super) fn camera_mob(&self) -> Option<&Mob> {
let id = self.remote_camera_id()?;
self.mobs.iter().find(|m| m.id == id)
}
/// The entity under a spectator's crosshair (`Minecraft.hitResult` as an
/// `EntityHitResult`): the nearest mob or object before any block.
pub(super) fn spectator_click_target(&self) -> Option<i32> {
let mob = self.mob_hit().map(|(i, d)| (self.mobs[i].id as i32, d));
let object = self.remote_object_hit(false).map(|h| (h.id, h.distance));
[mob, object]
.into_iter()
.flatten()
.filter(|(_, d)| self.before_block(*d))
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(id, _)| id)
}
/// `LevelRenderer`: the camera entity is not drawn from its own eyes.
pub(super) fn hides_as_camera(&self, id: u32) -> bool {
self.camera_type.first_person() && self.remote_camera_id() == Some(id)
}
}
#[cfg(test)]
mod tests {
use super::camera_after_packet;
/// The camera goes to a known entity, back to the player on its own id,
/// and stays where it is for an entity the level does not hold.
#[test]
fn set_camera_follows_vanilla() {
assert_eq!(camera_after_packet(None, 5, 9, true), Some(9));
assert_eq!(camera_after_packet(Some(9), 5, 5, false), None);
assert_eq!(camera_after_packet(Some(9), 5, 12, false), Some(9));
assert_eq!(camera_after_packet(None, 5, 12, false), None);
}
}
@@ -50,6 +50,9 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.invisible = invisible;
}
}
// Entity.DATA_SILENT: Entity.isSilent, which keeps the sounds
// a client plays itself quiet (LivingEntity.breakItem's).
(4, Value::Bool(silent)) => m.silent = *silent,
// Entity.DATA_POSE: the pose the species animates by.
(6, Value::Id(pose)) => sync::set_pose(m, (*pose).clamp(0, 17) as u8),
// LivingEntity.DATA_LIVING_ENTITY_FLAGS: using an item 1, in the
@@ -148,7 +151,7 @@ fn apply(m: &mut Mob, values: &[(u8, i32, Value)], builtin: &Builtin, local_enti
m.extra.color = (*c & 15) as u8;
}
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
(Species::Slime | Species::MagmaCube | Species::SulfurCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
// Enderman.DATA_CREEPY (after DATA_CARRY_STATE): the open mouth
@@ -462,6 +465,10 @@ pub(super) fn client_defaults(m: &mut Mob) {
// Bat's constructor sets resting only on the server; the data default is 0.
bat::set_resting(m, false);
}
// A horse's jump strength until the server syncs its own: the type's.
if equine::moved_by_rider(m.species()) {
m.extra.vector[0] = m.kind.jump_strength;
}
}
#[cfg(test)]
@@ -494,6 +501,23 @@ mod tests {
Some(m)
}
/// #2920: a server's horse is moved by its rider's client, which jumps
/// it by the type's jump strength (`createBaseHorseAttributes`,
/// `createBaseChestedHorseAttributes`) until the server syncs its own.
#[test]
fn a_servers_horse_jumps_its_types_strength() {
for (kind, strength) in [("horse", 0.7), ("donkey", 0.5), ("mule", 0.5)] {
let Some(m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(
(m.extra.vector[0] - strength).abs() < 1e-6,
"{kind}: {}",
m.extra.vector[0]
);
}
}
#[test]
fn guardian_charge_follows_target_updates_and_caps_at_its_duration() {
use horizon_client::mobs::guardian::{beam, client_tick};
@@ -568,6 +592,21 @@ mod tests {
assert!(!calf.is_baby());
}
#[test]
fn cube_mobs_receive_size_from_metadata() {
for kind in ["slime", "magma_cube", "sulfur_cube"] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert_eq!(m.extra.size, 1);
apply(&mut m, &[(18, s::INT, Value::Int(2))]);
assert_eq!(m.extra.size, 2);
if kind == "sulfur_cube" {
assert!(!m.is_baby());
}
}
}
#[test]
fn bat_flags_select_continuous_client_clocks_without_changing_motion() {
use horizon_client::mobs::{MobTypes, anim, keyframes::Animation};
@@ -121,7 +121,7 @@ fn remote_arrow_tick(p: &mut Projectile, w: &impl ProjectileWorld) {
if p.stuck {
return;
}
let water = w.in_water(p.position);
let water = w.in_water(p.bounds());
if water {
p.velocity = p.velocity.scale(0.6);
}
@@ -714,7 +714,7 @@ mod tests {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Vec3) -> bool {
fn in_water(&self, _: Aabb) -> bool {
false
}
fn targets(&self) -> &[(Who, Aabb)] {
@@ -7,9 +7,12 @@
//! (`ClientPacketListener.handleSetEntityPassengersPacket`). A boat is
//! controlled by its rider's client: it ticks here and its moves go to the
//! server (`LocalPlayer.tick`: `ServerboundMoveVehiclePacket`,
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). A minecart moves on
//! the server with the rider's input, and the rider sits in it.
//! `AbstractBoat.tick`: `ServerboundPaddleBoatPacket`). So is a saddled horse,
//! donkey, mule or camel: its rider's client moves it and reports the moves,
//! and tells the server of a riding jump (`START_RIDING_JUMP`). A minecart
//! moves on the server with the rider's input, and the rider sits in it.
use super::{Game, Keys, ray_box};
use horizon_client::mobs::{Mob, equine};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -123,6 +126,11 @@ impl Game<'_> {
display.vehicle = None;
}
}
if passengers.contains(&me)
&& let Some(r) = self.remote.as_mut()
{
r.first_passenger = passengers.first() == Some(&me);
}
if passengers.contains(&me) {
if self.riding != Some(vehicle_id) {
self.riding = Some(vehicle_id);
@@ -134,6 +142,9 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
// STAYS_STILL confirms it), and the rider lands on top of the ghast
// (`place_ghast_under`).
@@ -155,10 +166,13 @@ impl Game<'_> {
return false;
};
if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
// An animal moves on the server with the rider's input
// (PlayerInput); here the jump bar charges for the HUD and the
// rider sits where `positionRider` puts it.
self.charge_mount_jump(i, keys);
// LocalPlayer.aiStep: the jump bar charges while jump is held.
let jump = self.charge_mount_jump(i, keys);
if self.drives(&self.mobs[i]) {
self.drive_mount(i, keys, jump);
}
// Another animal moves on the server with the rider's input
// (PlayerInput); the rider sits where `positionRider` puts it.
self.position_rider();
return true;
}
@@ -200,6 +214,9 @@ impl Game<'_> {
/// `LocalPlayer.tick` for a passenger: the boat it controls goes to the
/// server every tick with its paddles (the rider's own position does not).
pub(super) fn remote_vehicle_packets(&self) -> Vec<S> {
if let Some(m) = self.driven_mount() {
return vec![mount_move(m)];
}
let Some(b) = self
.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
@@ -220,20 +237,168 @@ impl Game<'_> {
]
}
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat the
/// player drives back.
/// `ClientPacketListener.handleMoveVehicle`: the server put the boat or
/// the mount the player drives back.
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.previous = at;
m.position = at;
m.yaw = yaw;
m.previous_yaw = yaw;
m.velocity = Vec3::ZERO;
return;
}
let Some(b) = self
.riding
.and_then(|id| self.boats.iter_mut().find(|b| b.id == id))
else {
return;
};
let at = Vec3::new(position[0], position[1], position[2]);
b.previous = at;
b.position = at;
b.yaw = yaw;
b.previous_yaw = yaw;
b.velocity = Vec3::ZERO;
}
/// Whether this client moves `m`, the mount the player rides: as its
/// first passenger (`AbstractHorse.getControllingPassenger`), on its
/// saddle (`isLocalInstanceAuthoritative`).
fn drives(&self, m: &Mob) -> bool {
self.riding == Some(m.id)
&& client_moves(m, self.remote.as_ref().is_some_and(|r| r.first_passenger))
}
/// The mount this client moves, if the player rides one.
pub(super) fn driven_mount(&self) -> Option<&Mob> {
let id = self.riding?;
self.mobs.iter().find(|m| m.id == id && self.drives(m))
}
/// `LocalPlayer.aiStep` and `LivingEntity.travelRidden` on the
/// controlling client: a released jump goes to the server
/// (`sendRidingJump`), and the mount takes the rider's input and moves
/// here (`tickRidden`, then `travel`).
fn drive_mount(&mut self, i: usize, keys: Keys, jump: f32) {
if jump > 0.0
&& let Some(id) = self.remote.as_ref().map(|r| r.entity_id)
{
self.send(riding_jump(id, self.mount.jump_scale));
}
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
let m = &mut self.mobs[i];
// playerJumpPendingScale waits for the ground.
let pending = m.rider.map_or(0.0, |r| r.jump);
m.rider = Some(equine::Rider {
strafe,
forward,
yaw: self.yaw,
pitch: self.pitch,
jump: if jump > 0.0 { jump } else { pending },
steer: false,
sprint: keys.sprint,
jumping: keys.jump,
client: false,
});
let mut mob = self.mobs.remove(i);
let mut random = self.world.random.clone();
let mut events = Vec::new();
{
let world = self.mob_world_base(&[]);
mob.tick(&world, &mut random, &mut events);
}
// Only the server's packets take the mount away.
mob.removed = false;
self.world.random = random;
self.mobs.insert(i, mob);
}
}
/// Whether a player's client moves `m`, the mount it rides
/// (`isLocalInstanceAuthoritative`): a saddled horse, donkey, mule or camel
/// it is the first passenger of (`AbstractHorse.getControllingPassenger`).
fn client_moves(m: &Mob, first_passenger: bool) -> bool {
first_passenger && equine::moved_by_rider(m.species()) && m.extra.saddled && m.alive()
}
/// `LocalPlayer.sendRidingJump`: START_RIDING_JUMP with
/// `floor(getJumpRidingScale() * 100)`, as `onPlayerJump` gets it.
fn riding_jump(player: i32, scale: f32) -> S {
S::PlayerCommand(play::PlayerCommand {
id: player,
action: 3,
data: (scale * 100.0).floor() as i32,
})
}
/// `ServerboundMoveVehiclePacket.fromEntity`: where the mount this client
/// moves is, and how it is turned.
fn mount_move(m: &Mob) -> S {
S::MoveVehicle(play::MoveVehicleRequest {
position: [m.position.x, m.position.y, m.position.z],
yaw: m.yaw,
pitch: m.head_pitch,
on_ground: m.on_ground,
})
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_client::mobs::MobTypes;
/// A mob of the prepared resources' type `kind` (None without them).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
Some(Mob::new(7, t, Vec3::new(1.5, 64.0, -2.5), 0.0))
}
/// #2920: on a vanilla server the rider's client moves a saddled horse,
/// donkey, mule or camel; a pig, a llama, an unsaddled horse or a second
/// passenger's ride is the server's.
#[test]
fn the_rider_moves_a_saddled_horse_on_its_client() {
for kind in ["horse", "donkey", "mule", "camel", "pig", "llama"] {
let Some(mut m) = mob(&format!("minecraft:{kind}")) else {
return;
};
assert!(!client_moves(&m, true), "{kind} without a saddle");
m.set_saddle(Some(horizon_client::items::ItemStack::new(
"minecraft:saddle",
1,
)));
let horse = matches!(kind, "horse" | "donkey" | "mule" | "camel");
assert_eq!(client_moves(&m, true), horse, "{kind}");
assert!(!client_moves(&m, false), "{kind} behind another rider");
}
}
/// #2920: the client reports where it moved the mount, and a released
/// jump with the bar's percentage (`sendRidingJump`).
#[test]
fn the_rider_sends_its_mounts_moves_and_jumps() {
let Some(mut m) = mob("minecraft:horse") else {
return;
};
(m.yaw, m.head_pitch, m.on_ground) = (90.0, 10.0, true);
match mount_move(&m) {
S::MoveVehicle(p) => {
assert_eq!(p.position, [1.5, 64.0, -2.5]);
assert_eq!((p.yaw, p.pitch, p.on_ground), (90.0, 10.0, true));
}
other => panic!("{other:?}"),
}
match riding_jump(42, 0.75) {
S::PlayerCommand(p) => assert_eq!((p.id, p.action, p.data), (42, 3, 75)),
other => panic!("{other:?}"),
}
}
}
@@ -7,7 +7,6 @@ use horizon_client::{
animate_tick::{AnimateContext, animate_tick, campfire_particles, marker_particle_target},
biome::Biomes,
blocks::{BlockFacts, Fluid},
chunk::{HEIGHT, MIN_Y},
containers::BlockEntity,
particles::{ParticleLevel, ParticleOptions, ParticleType},
world::World,
@@ -37,7 +36,7 @@ pub(super) fn motion_blocking_height(
/// that is above the world's bottom and within 10 blocks of the camera.
fn splash_height(world: &World, properties: &Properties, x: i32, y: i32, z: i32) -> Option<i32> {
let top = motion_blocking_height(world, properties, x, z);
(top > MIN_Y && (y - 10..=y + 10).contains(&top)).then_some(top)
(top > world.bottom_y() && (y - 10..=y + 10).contains(&top)).then_some(top)
}
/// The first block at or below `from` that blocks motion (leaves only when
/// `leaves`, as MOTION_BLOCKING_NO_LEAVES skips them) or holds a fluid, plus one.
@@ -49,8 +48,8 @@ fn motion_blocking_leaves(
from: i32,
leaves: bool,
) -> i32 {
let mut y = from.min(MIN_Y + HEIGHT - 1);
while y > MIN_Y {
let mut y = from.min(world.top_y() - 1);
while y > world.bottom_y() {
let f = facts.get(world.block(x, y, z));
// #minecraft:leaves block motion, but MOTION_BLOCKING_NO_LEAVES skips them
// (their water still counts).
@@ -64,7 +63,7 @@ fn motion_blocking_leaves(
}
y -= 1;
}
MIN_Y
world.bottom_y()
}
/// `Biome.getPrecipitationAt` is rain (not snow) at this position.
fn rains_at(world: &World, biomes: &Biomes, x: i32, y: i32, z: i32) -> bool {
@@ -363,6 +362,9 @@ impl Game<'_> {
// The day timeline: firefly bush sounds from 12600 to 23400.
let firefly_bush_sounds = self.dim == Dim::Overworld
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
// EnvironmentAttributes.CREAKING_ACTIVE: the same overworld night.
let creaking_active = self.dim == Dim::Overworld
&& (12600..23401).contains(&self.world.day_time.rem_euclid(24000));
let tables = &self.pack.context.tables;
let model_offset = |state, x, z| tables.offset(state, x, z);
let gateway_particles = |state, p| {
@@ -382,6 +384,7 @@ impl Game<'_> {
raining_at: &raining_at,
height_no_leaves: &height_no_leaves,
firefly_bush_sounds,
creaking_active,
sky_darken: self.sky_darken(),
marker: marker.map(|name| (name, &marker_options as &dyn Fn(_) -> _)),
model_offset: &model_offset,
@@ -54,6 +54,7 @@ impl Game<'_> {
"last_played": w.modified.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64),
"commands": w.commands,
"version": w.version,
"corrupted": w.corrupted,
})
})
.collect();
+58 -6
View File
@@ -411,13 +411,12 @@ impl Game<'_> {
}
}
fn sculk_scan_chunk(&mut self, key: (i32, i32)) {
use horizon_client::chunk::MIN_Y;
let Some(chunk) = self.world.chunks.get(&key).cloned() else {
return;
};
let mut found = Vec::new();
for (s, section) in chunk.sections.iter().enumerate() {
let y0 = MIN_Y + s as i32 * 16;
let y0 = self.world.bottom_y() + s as i32 * 16;
// Without a sculk state in its palette, no block of the section
// is one: most sections are passed over without reading a block.
if !section.blocks.maybe_has(|v| self.kind_of_state(v) != 0) {
@@ -1555,7 +1554,7 @@ impl Game<'_> {
.map(|i| [i.position.x, i.position.y, i.position.z])
.collect();
for at in items {
self.step_on_redstone_ore(at, false);
self.step_on_redstone_ore(at, false, false);
}
}
@@ -1573,7 +1572,7 @@ impl Game<'_> {
if let Some((at, on_ground, health, flying)) = player {
let crouch = self.keys.contains(&KeyCode::ShiftLeft);
if on_ground && !self.spectator() {
self.step_on_redstone_ore(at, crouch);
self.step_on_redstone_ore(at, crouch, true);
}
self.mount_events(Who::Player, self.riding, true);
let in_water = self.in_water_at(at);
@@ -1594,9 +1593,57 @@ impl Game<'_> {
}
}
/// `RedStoneOreBlock.spawnParticles`: a red dust puff for each face of the
/// ore that is not against a solid block. Non-axis coordinates draw from
/// the client level random, x then y then z. It is `level.addParticle`, so
/// only the local player's own click or step draws it, not a guest's turn.
pub(super) fn redstone_ore_particles(&mut self, pos: [i32; 3]) {
if self.lan_acting.is_some()
|| !matches!(
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
)
{
return;
}
// Direction.values(): down, up, north, south, west, east.
const FACES: [[i32; 3]; 6] = [
[0, -1, 0],
[0, 1, 0],
[0, 0, -1],
[0, 0, 1],
[-1, 0, 0],
[1, 0, 0],
];
for step in FACES {
let next = [pos[0] + step[0], pos[1] + step[1], pos[2] + step[2]];
let state = self.world.block(next[0], next[1], next[2]);
if self.res.facts().get(state).solid_render {
continue;
}
let mut at = [0.0f64; 3];
for axis in 0..3 {
at[axis] = if step[axis] != 0 {
0.5 + 0.5625 * f64::from(step[axis])
} else {
f64::from(self.particles.level_random().next_float())
};
}
self.particles.add(
&horizon_client::particles::ParticleOptions::dust([1.0, 0.0, 0.0], 1.0),
[
f64::from(pos[0]) + at[0],
f64::from(pos[1]) + at[1],
f64::from(pos[2]) + at[2],
],
[0.0; 3],
);
}
}
/// `RedStoneOreBlock.stepOn`: standing on the ore lights it, unless the
/// entity is stepping carefully (`attack_block` only lights an unlit ore).
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool) {
fn step_on_redstone_ore(&mut self, at: [f64; 3], carefully: bool, burst: bool) {
if carefully {
return;
}
@@ -1610,6 +1657,10 @@ impl Game<'_> {
&*self.block_name(pos),
"minecraft:redstone_ore" | "minecraft:deepslate_redstone_ore"
) {
// The dust is the local player's client effect, not a mob's or an item's.
if burst {
self.redstone_ore_particles(pos);
}
let _ = self.attack_block(pos);
}
}
@@ -1628,6 +1679,7 @@ impl Game<'_> {
) {
return Ok(false);
}
self.redstone_ore_particles(pos);
self.attack_block(pos)?;
let block_item = held.is_some_and(|s| {
self.res
@@ -1667,7 +1719,7 @@ impl Game<'_> {
.collect();
for (id, at, on_ground, in_water, species, damaged, vehicle, age) in snapshot {
if on_ground {
self.step_on_redstone_ore(at, false);
self.step_on_redstone_ore(at, false, false);
}
// A mob first seen already riding raised its event when it spawned.
let seen = self.sculk.steps.contains_key(&Who::Mob(id));
+14 -86
View File
@@ -3,7 +3,7 @@
//! and `DownloadedPackSource` as `Minecraft` drives it): the prompt, the
//! server list's setting, downloads into `downloads/`, and a resource reload
//! with the server's packs on top of the player's own.
use super::{Game, Progress};
use super::{Game, packs::ReloadKind};
use horizon_client::{
Result,
confirm_screen::Confirm,
@@ -89,9 +89,6 @@ pub(super) struct ServerPackState {
pub(super) active: Vec<packs::Pack>,
/// The download batches' notifiers, oldest first.
notifiers: Vec<PackNotifier>,
/// A `tick`-asked reload staged on a worker (`LoadingOverlay`): the
/// frame draws its progress until `tick_server_packs` applies it.
pub(super) reload: Option<PackReload>,
}
/// `DownloadedPackSource.createDownloadNotifier`: one batch's progress in its
/// own `SystemToast`.
@@ -204,21 +201,6 @@ pub(super) fn saves() -> PathBuf {
/// `WorldOpenFlows.WORLD_PACK_ID`: the id of a world's own resource pack.
const WORLD_PACK_ID: u128 = 0x640a6a92_b6cb_48a0_b391_831586500359;
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
const RELOAD_LINES: [&str; 4] = [
"Preparing resources",
"Loading assets",
"Loading pack tables",
"Applying resources",
];
/// A pack reload `ServerPacks::tick` asked for: the CPU half runs on a
/// worker while `progress` feeds the loading screen, then the apply lands
/// on the render thread.
pub(super) struct PackReload {
pub(super) progress: Progress,
handle: std::thread::JoinHandle<std::result::Result<super::packs::StagedPack, String>>,
}
impl Game<'_> {
/// `WorldOpenFlows.loadBundledResourcePack`: a world's own
/// `resourcepacks/resources.zip` is pushed as a local pack before the world
@@ -358,6 +340,13 @@ impl Game<'_> {
return;
}
self.drive_pack_reload();
// One reload at a time: the player's own waits under its overlay
// before the server's packs can ask for theirs (`ServerPacks` keeps
// its own reload from overlapping).
let selection_reloading = self
.pack_reload
.as_ref()
.is_some_and(|r| r.kind() == ReloadKind::Selection);
let finished = self
.server_packs
.queue
@@ -367,7 +356,7 @@ impl Game<'_> {
for batch in finished {
self.server_packs.packs.downloaded(batch);
}
while self.server_packs.packs.wants_tick() {
while !selection_reloading && self.server_packs.packs.wants_tick() {
let tick = self.server_packs.packs.tick();
if !tick.download.is_empty() {
self.start_pack_downloads(tick.download);
@@ -431,75 +420,14 @@ impl Game<'_> {
self.finish_pack_reload(false);
return;
};
self.server_packs.active = server.clone();
// The overlay swallows input until the apply (`LoadingOverlay`):
// release what is held so nothing keeps acting while it covers the
// frame (`KeyMapping.releaseAll` when a screen opens).
self.keys.clear();
self.physical_keys.clear();
self.left = false;
self.right = false;
let vanilla = self.vanilla_root();
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
self.server_packs.active = server;
if let Err(e) = self.spawn_pack_reload(ReloadKind::Server) {
log::error!("Horizon: could not start the pack reload worker: {e}");
self.finish_pack_reload(false);
}
let (selected, mods, dev_mods) = (
self.settings.resource_packs.clone(),
self.settings.mods.enabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
self.pack.assets.clone(),
self.res.clone(),
self.pack.context.clone(),
);
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
let progress = Progress::with(&RELOAD_LINES);
let worker = progress.clone();
let handle = std::thread::Builder::new()
.name("server-pack-reload".into())
.spawn(move || {
super::packs::stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context,
unicode, japanese, &worker,
)
.map_err(|e| e.to_string())
});
match handle {
Ok(handle) => self.server_packs.reload = Some(PackReload { progress, handle }),
Err(e) => {
log::error!("Horizon: could not start the pack reload worker: {e}");
self.finish_pack_reload(false);
}
}
}
/// `PackReloadConfig.Callbacks`: applies the staged reload once its
/// worker is done; on failure the packs active before reload again.
fn drive_pack_reload(&mut self) {
let Some(reload) = &self.server_packs.reload else {
return;
};
if !reload.handle.is_finished() {
return;
}
let reload = self.server_packs.reload.take().unwrap();
let staged = reload
.handle
.join()
.unwrap_or_else(|_| Err("the pack reload worker panicked".into()));
let ok = match staged
.and_then(|staged| self.apply_staged_pack(staged).map_err(|e| e.to_string()))
{
Ok(()) => true,
Err(e) => {
log::warn!("Horizon: server resource pack reload failed: {e}");
false
}
};
self.finish_pack_reload(ok);
}
/// `ServerPacks::reloaded` and the recovery reload it can ask for.
fn finish_pack_reload(&mut self, ok: bool) {
pub(super) fn finish_pack_reload(&mut self, ok: bool) {
if let Some(recovery) = self.server_packs.packs.reloaded(ok) {
self.start_pack_reload(&recovery);
}
@@ -725,6 +725,7 @@ impl Game<'_> {
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
}
}
}
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more